Compare commits

...
14 Commits
Author SHA1 Message Date
steffen911 879ef9c8f9 chore: release v3.97.4 2025-08-13 17:40:13 +02:00
Steffen SchmitzandGitHub a343d5b187 fix: reinstate ability to remove tags from prompts (#8505) 2025-08-13 15:28:51 +00:00
marliessophieandGitHub ac2b9eaa56 chore(dataset-run-items): add env variable for trace creation (#8508)
* chore(dataset-run-items): add env variable for trace creation

* chore: push

* chore: push
2025-08-13 16:38:59 +02:00
marliessophieandGitHub 43e9cba99a chore: create traces in CH execution path (#8504)
* chore: create traces in CH execution path

* chore: push

* chore: fix import

* chore: push
2025-08-13 15:47:11 +02:00
Steffen SchmitzandGitHub 601f431f69 perf: parallelize S3 and clickhouse deletions for data retention (#8501) 2025-08-13 13:44:49 +00:00
marliessophieandGitHub 4fd1f86f9d chore(dataset-run-items): sunset dual-write phase; do not fall back on PG in CH execution path (#8497)
* chore(dataset-run-items): return CH result

* chore: sunset dual-write phase for dri in CH execution path; do not fall back on PG for reads

* chore: eslint
2025-08-13 12:27:35 +00:00
marliessophieandGitHub 8245888e3c fix(dataset-runs): implement optimistic concurrency handling for dataset run creation in POST /dataset-run-items (#8494) 2025-08-13 10:18:00 +00:00
steffen911 daf4e2fe02 chore: release v3.97.3 2025-08-13 11:38:24 +02:00
Steffen SchmitzandGitHub 129693fb69 chore: skip body validation for bullmq GET requests (#8490) 2025-08-13 09:15:33 +00:00
Steffen SchmitzandGitHub 204948db44 perf: skip FINAL for traces all route without metrics (#8489) 2025-08-13 09:15:29 +00:00
marliessophieandGitHub d42ba5fc99 fix(evals): redirect and pull data for latest template version post template edit (#8491) 2025-08-13 09:15:25 +00:00
Steffen SchmitzandGitHub 97a539ced3 chore: allow whitelisting which AMTs can be used (#8488) 2025-08-13 09:12:07 +00:00
Leo WeigandandGitHub 9d8dace197 feat(llm-connections): populate gcp region in update form (#8487)
feat(llm-connections): correctly show gcp region in update form
2025-08-13 08:58:31 +00:00
marliessophieandGitHub bc2dc4d89c feat(annotation): add POST queue API (#8478)
* feat(annotation): add POST queue API

* chore: fix types

* docs: add API ref

* chore: improve score configs check

* chore: add postman collection
2025-08-12 17:25:19 +00:00
33 changed files with 477 additions and 140 deletions
@@ -22,6 +22,13 @@ service:
docs: limit of items per page
response: PaginatedAnnotationQueues
createQueue:
docs: Create an annotation queue
method: POST
path: /annotation-queues
request: CreateAnnotationQueueRequest
response: AnnotationQueue
getQueue:
docs: Get an annotation queue by ID
method: GET
@@ -168,6 +175,12 @@ types:
data: list<AnnotationQueueItem>
meta: pagination.MetaResponse
CreateAnnotationQueueRequest:
properties:
name: string
description: optional<string>
scoreConfigIds: list<string>
CreateAnnotationQueueItemRequest:
properties:
objectId: string
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.97.2",
"version": "3.97.4",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
+6
View File
@@ -158,6 +158,12 @@ const EnvSchema = z.object({
LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES: z
.enum(["true", "false"])
.default("false"),
LANGFUSE_EXPERIMENT_WHITELISTED_AMT_TABLES: z
.string()
.optional()
.transform((s) =>
s ? s.split(",").map((s) => s.toLowerCase().trim()) : [],
),
LANGFUSE_INGESTION_PROCESSING_SAMPLED_PROJECTS: z
.string()
.optional()
@@ -88,7 +88,7 @@ async function removeIngestionEventsFromS3AndDeleteClickhouseRefs(p: {
);
await softDeleteInClickhouse(blobStorageRefs);
logger.info(
`Deleted batch ${batch} of size ${blobStorageRefs.length} for ${projectId} of deleting s3 refs`,
`Deleted last batch ${batch} of size ${blobStorageRefs.length} for ${projectId} of deleting s3 refs`,
);
}
@@ -1,5 +1,4 @@
import { env } from "../../env";
import { logger } from "../../server/logger";
import {
DatasetRunItemsExecutionStrategy,
DatasetRunItemsOperationType,
@@ -48,23 +47,8 @@ export async function executeWithDatasetRunItemsStrategy<TInput, TOutput>({
if (operationType === DatasetRunItemsOperationType.WRITE) {
// For write operations, implement dual-write strategy
if (strategy.shouldWriteToClickHouse) {
// Dual-write phase: write to both databases
const postgresResult = await postgresExecution(input);
try {
await clickhouseExecution(input);
logger.debug("Successfully wrote to both PostgreSQL and ClickHouse", {
operation: `dataset_run_items_${operationType}`,
});
} catch (error) {
logger.error("ClickHouse write failed during dual-write phase", {
error: error instanceof Error ? error.message : String(error),
operation: `dataset_run_items_${operationType}`,
});
// Continue with PostgreSQL result since it succeeded
}
return postgresResult;
// Write only to ClickHouse
return await clickhouseExecution(input);
} else {
// Write only to PostgreSQL
return await postgresExecution(input);
@@ -74,20 +58,10 @@ export async function executeWithDatasetRunItemsStrategy<TInput, TOutput>({
const shouldExecuteClickhouse = strategy.shouldReadFromClickHouse;
if (shouldExecuteClickhouse) {
try {
return await clickhouseExecution(input);
} catch (error) {
logger.error(
"ClickHouse execution failed, falling back to PostgreSQL",
{
error: error instanceof Error ? error.message : String(error),
operation: `dataset_run_items_${operationType}`,
},
);
// Fallback to PostgreSQL for reliability
return await postgresExecution(input);
}
// Read from ClickHouse
return await clickhouseExecution(input);
} else {
// Read from PostgreSQL
return await postgresExecution(input);
}
}
@@ -22,7 +22,7 @@ export const testModelCall = async ({
apiKey: z.infer<typeof LLMApiKeySchema>;
prompt?: string;
modelConfig?: ModelConfig | null;
}): Promise<void> => {
}) => {
(
await fetchLLMCompletion({
streaming: false,
@@ -47,12 +47,16 @@ enum TracesAMTs {
* for <= 29 days, we use traces_30d_amt,
* for all other cases we use traces_all_amt.
*
* If LANGFUSE_EXPERIMENT_WHITELISTED_AMT_TABLES is set, we only return timeframes
* that are whitelisted or fallback to the traces_all_amt.
*
* @param fromTimestamp
*/
export const getTimeframesTracesAMT = (
fromTimestamp: Date | undefined,
): TracesAMTs => {
if (!fromTimestamp) {
// The TracesAllAMT must always be returned if there is no timestamp.
return TracesAMTs.TracesAllAMT;
}
@@ -60,12 +64,21 @@ export const getTimeframesTracesAMT = (
const diffInDays = Math.floor(
(now.getTime() - fromTimestamp.getTime()) / (1000 * 60 * 60 * 24),
);
let selectedTable: TracesAMTs;
if (diffInDays <= 6) {
return TracesAMTs.Traces7dAMT;
selectedTable = TracesAMTs.Traces7dAMT;
} else if (diffInDays <= 29) {
return TracesAMTs.Traces30dAMT;
selectedTable = TracesAMTs.Traces30dAMT;
} else {
selectedTable = TracesAMTs.TracesAllAMT;
}
return TracesAMTs.TracesAllAMT;
// Check if the selected table is whitelisted, fallback to TracesAllAMT if not
return env.LANGFUSE_EXPERIMENT_WHITELISTED_AMT_TABLES.length === 0 ||
env.LANGFUSE_EXPERIMENT_WHITELISTED_AMT_TABLES.includes(selectedTable)
? selectedTable
: TracesAMTs.TracesAllAMT;
};
/**
@@ -15,6 +15,7 @@ export function createBasicAuthHeader(
export type CreateOrgProjectAndApiKeyOptions = {
projectId?: string;
plan?: "Team" | "Hobby" | "Core" | "Pro" | "Enterprise";
};
export const createOrgProjectAndApiKey = async (
props?: CreateOrgProjectAndApiKeyOptions,
@@ -25,7 +26,7 @@ export const createOrgProjectAndApiKey = async (
id: v4(),
name: v4(),
cloudConfig: CloudConfigSchema.parse({
plan: "Team",
plan: props?.plan ?? "Team",
}),
},
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.97.2",
"version": "3.97.4",
"private": true,
"license": "MIT",
"engines": {
+63 -1
View File
@@ -79,6 +79,52 @@ paths:
schema: {}
security:
- BasicAuth: []
post:
description: Create an annotation queue
operationId: annotationQueues_createQueue
tags:
- AnnotationQueues
parameters: []
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/AnnotationQueue'
'400':
description: ''
content:
application/json:
schema: {}
'401':
description: ''
content:
application/json:
schema: {}
'403':
description: ''
content:
application/json:
schema: {}
'404':
description: ''
content:
application/json:
schema: {}
'405':
description: ''
content:
application/json:
schema: {}
security:
- BasicAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateAnnotationQueueRequest'
/api/public/annotation-queues/{queueId}:
get:
description: Get an annotation queue by ID
@@ -3254,7 +3300,7 @@ paths:
parameters:
- name: filter
in: query
description: Filter expression (e.g. userName eq 'value')
description: Filter expression (e.g. userName eq "value")
required: false
schema:
type: string
@@ -4459,6 +4505,22 @@ components:
required:
- data
- meta
CreateAnnotationQueueRequest:
title: CreateAnnotationQueueRequest
type: object
properties:
name:
type: string
description:
type: string
nullable: true
scoreConfigIds:
type: array
items:
type: string
required:
- name
- scoreConfigIds
CreateAnnotationQueueItemRequest:
title: CreateAnnotationQueueItemRequest
type: object
@@ -78,6 +78,39 @@
},
"response": []
},
{
"_type": "endpoint",
"name": "Create Queue",
"request": {
"description": "Create an annotation queue",
"url": {
"raw": "{{baseUrl}}/api/public/annotation-queues",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"annotation-queues"
],
"query": [],
"variable": []
},
"header": [],
"method": "POST",
"auth": null,
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"example\",\n \"description\": \"example\",\n \"scoreConfigIds\": [\n \"example\"\n ]\n}",
"options": {
"raw": {
"language": "json"
}
}
}
},
"response": []
},
{
"_type": "endpoint",
"name": "Get Queue",
@@ -14,6 +14,7 @@ import {
CreateAnnotationQueueItemResponse,
UpdateAnnotationQueueItemResponse,
DeleteAnnotationQueueItemResponse,
CreateAnnotationQueueResponse,
} from "@/src/features/public-api/types/annotation-queues";
import {
AnnotationQueueObjectType,
@@ -173,6 +174,120 @@ describe("Annotation Queues API Endpoints", () => {
});
});
describe("POST /annotation-queues", () => {
it("should create a new annotation queue", async () => {
const scoreConfig = await prisma.scoreConfig.create({
data: {
name: "Test Score Config",
description: "Test Score Config Description",
projectId,
dataType: "NUMERIC",
},
});
const response = await makeZodVerifiedAPICall(
CreateAnnotationQueueResponse,
"POST",
"/api/public/annotation-queues",
{
name: "Test Queue",
description: "Test Queue Description",
scoreConfigIds: [scoreConfig.id],
},
auth,
);
expect(response.status).toBe(200);
expect(response.body.id).toBeDefined();
expect(response.body.name).toBe("Test Queue");
expect(response.body.description).toBe("Test Queue Description");
expect(response.body.scoreConfigIds).toEqual([scoreConfig.id]);
});
it("should return 400 if the queue name already exists", async () => {
const response = await makeAPICall(
"POST",
"/api/public/annotation-queues",
{
name: "Test Queue",
description: "Test Queue Description",
scoreConfigIds: [],
},
auth,
);
expect(response.status).toBe(400);
});
it("should return 400 if no score config IDs are provided", async () => {
const response = await makeAPICall(
"POST",
"/api/public/annotation-queues",
{
name: "No configs queue",
description: "Test Queue Description",
scoreConfigIds: [],
},
auth,
);
expect(response.status).toBe(400);
});
it("should return 400 if the score config IDs are invalid", async () => {
const response = await makeAPICall(
"POST",
"/api/public/annotation-queues",
{
name: "Invalid configs queue",
description: "Test Queue Description",
scoreConfigIds: ["invalid-score-config-id"],
},
auth,
);
expect(response.status).toBe(400);
});
it("should return 405 if the user is on the Hobby plan and has reached the maximum number of annotation queues", async () => {
const { auth: hobbyPlanAuth, projectId: hobbyProjectId } =
await createOrgProjectAndApiKey({
plan: "Hobby",
});
const config = await prisma.scoreConfig.create({
data: {
name: "Test Score Config",
description: "Test Score Config Description",
projectId: hobbyProjectId,
dataType: "NUMERIC",
},
});
await prisma.annotationQueue.create({
data: {
name: "First queue",
description: "First queue description",
scoreConfigIds: [config.id],
projectId: hobbyProjectId,
},
});
const response = await makeAPICall(
"POST",
"/api/public/annotation-queues",
{
name: "Hobby plan queue",
description: "Test Queue Description",
scoreConfigIds: [config.id],
},
hobbyPlanAuth,
);
expect(response.status).toBe(405);
});
});
describe("GET /annotation-queues/:queueId", () => {
it("should get a specific annotation queue", async () => {
const response = await makeZodVerifiedAPICall(
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.97.2";
export const VERSION = "v3.97.4";
@@ -33,9 +33,6 @@ export const EvalTemplateDetail = () => {
const templateId = router.query.id as string;
const [isEditing, setIsEditing] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<EvalTemplate | null>(
null,
);
// get the current template by id
const template = api.evals.templateById.useQuery({
@@ -58,15 +55,7 @@ export const EvalTemplateDetail = () => {
},
);
// Set the selected template when data is loaded
React.useEffect(() => {
if (template.data && !selectedTemplate) {
setSelectedTemplate(template.data);
}
}, [template.data, selectedTemplate]);
const handleTemplateSelect = (newTemplate: EvalTemplate) => {
setSelectedTemplate(newTemplate);
// Update URL without full page reload
router.push(
`/project/${projectId}/evals/templates/${newTemplate.id}`,
@@ -75,13 +64,10 @@ export const EvalTemplateDetail = () => {
);
};
// Get the appropriate template to display
const displayTemplate = selectedTemplate || template.data;
return (
<Page
headerProps={{
title: `${displayTemplate?.name || ""}`,
title: `${template.data?.name ?? ""}`,
itemType: "EVALUATOR",
breadcrumb: [
{
@@ -95,7 +81,7 @@ export const EvalTemplateDetail = () => {
projectId={projectId}
isEditing={isEditing}
setIsEditing={setIsEditing}
isCustom={!!displayTemplate?.projectId}
isCustom={!!template.data?.projectId}
/>
{/* TODO: moved to LFE-4573 */}
@@ -114,14 +100,14 @@ export const EvalTemplateDetail = () => {
),
}}
>
{allTemplates.isLoading || !allTemplates.data || !displayTemplate ? (
{allTemplates.isLoading || !allTemplates.data || !template.data ? (
<div className="p-3">Loading...</div>
) : isEditing ? (
<div className="overflow-y-auto p-3 pt-1">
<EvalTemplateForm
useDialog={false}
projectId={projectId}
existingEvalTemplate={displayTemplate}
existingEvalTemplate={template.data}
isEditing={isEditing}
setIsEditing={setIsEditing}
/>
@@ -132,7 +118,7 @@ export const EvalTemplateDetail = () => {
<EvalTemplateForm
useDialog={false}
projectId={projectId}
existingEvalTemplate={displayTemplate}
existingEvalTemplate={template.data}
isEditing={isEditing}
setIsEditing={setIsEditing}
/>
@@ -150,7 +136,7 @@ export const EvalTemplateDetail = () => {
<div
key={template.id}
className={`flex cursor-pointer flex-col rounded-md px-2 py-1.5 hover:bg-accent ${
template.id === displayTemplate.id ? "bg-accent" : ""
template.id === templateId ? "bg-accent" : ""
}`}
onClick={() => handleTemplateSelect(template)}
>
@@ -58,6 +58,12 @@ export function EvaluatorSelector({
},
);
// Ensure per-name arrays are sorted by createdAt ascending so last is latest
const sortByCreatedAt = (arr: EvalTemplate[]) =>
arr.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
Object.values(groupedTemplates.custom).forEach(sortByCreatedAt);
Object.values(groupedTemplates.langfuse).forEach(sortByCreatedAt);
// Filter templates based on search
const filteredTemplates = {
langfuse: Object.entries(groupedTemplates.langfuse)
@@ -289,6 +289,7 @@ export const llmApiKeyRouter = createTRPCRouter({
customModels: true,
withDefaultModels: true,
extraHeaderKeys: true,
config: true,
},
where: {
projectId: input.projectId,
@@ -14,6 +14,19 @@ const isUniqueConstraintError = (error: any): boolean => {
);
};
/**
* Create or fetch a dataset run with optimistic concurrency handling.
*
* Behavior:
* - First tries to find an existing run by (projectId, datasetId, name).
* - If not found, attempts to create it.
* - If creation fails due to a unique constraint (likely created concurrently),
* fetches and returns the existing run.
* - If all steps fail, throws an error.
*
* Rationale: The public API can receive many POST requests almost simultaneously,
* which is not concurrency-safe without this guard.
*/
export const createOrFetchDatasetRun = async ({
projectId,
datasetId,
@@ -28,7 +41,21 @@ export const createOrFetchDatasetRun = async ({
metadata?: Json | null;
}) => {
try {
// Attempt optimistic creation
// Attempt to fetch existing run
const existingRun = await prisma.datasetRuns.findUnique({
where: {
datasetId_projectId_name: {
datasetId,
projectId,
name: name,
},
},
});
if (existingRun) {
return existingRun;
}
// Attempt creation
const datasetRun = await prisma.datasetRuns.create({
data: {
id: v4(),
+3 -4
View File
@@ -84,10 +84,9 @@ export const generateTracesForPublicApi = async ({
SELECT
trace_id,
project_id,
sum(total_cost) as total_cost,
date_diff('millisecond', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds,
groupArray(id) as observation_ids
FROM observations FINAL
${includeMetrics ? "sum(total_cost) as total_cost, date_diff('millisecond', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds, " : ""}
groupUniqArray(id) as observation_ids
FROM observations ${includeMetrics ? "FINAL" : ""}
WHERE project_id = {projectId: String}
${timeFilter ? `AND start_time >= {cteTimeFilter: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
${environmentFilter.length() > 0 ? `AND ${appliedEnvironmentFilter.query}` : ""}
@@ -57,6 +57,17 @@ export const GetAnnotationQueuesResponse = z
})
.strict();
// POST /annotation-queues
export const CreateAnnotationQueueBody = z
.object({
name: z.string(),
description: z.string().nullable(),
scoreConfigIds: z.array(z.string()).min(1),
})
.strict();
export const CreateAnnotationQueueResponse = AnnotationQueueSchema;
// GET /annotation-queues/:queueId
export const GetAnnotationQueueByIdQuery = z
.object({
+61 -32
View File
@@ -1,50 +1,79 @@
import React from "react";
import { cn } from "@/src/utils/tailwind";
import { X } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import { Command as CommandPrimitive } from "cmdk";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
type TagInputProps = React.ComponentPropsWithoutRef<
typeof CommandPrimitive.Input
> & {
selectedTags: string[];
setSelectedTags?: (tags: string[]) => void;
allowTagRemoval?: boolean;
};
export const TagInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
TagInputProps
>(({ className, selectedTags, ...props }, ref) => {
return (
<div
className="flex flex-wrap items-center overflow-auto rounded-lg border px-2"
cmdk-input-wrapper=""
>
{selectedTags.length > 0 && (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 pt-2">
{selectedTags.map((tag: string) => (
<Button
key={tag}
variant="tertiary"
size="icon-sm"
disabled
className="cursor-default"
>
{tag}
</Button>
))}
</div>
)}
<CommandPrimitive.Input
ref={ref}
className={cn(
"placeholder:muted-foreground flex h-8 w-full rounded-md border-transparent bg-transparent px-1 text-sm outline-none focus:border-0 focus:border-none focus:border-transparent focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50",
className,
>(
(
{
className,
selectedTags,
setSelectedTags,
allowTagRemoval = false,
...props
},
ref,
) => {
const capture = usePostHogClientCapture();
const removeTag = (tagToRemove: string) => {
if (setSelectedTags && allowTagRemoval) {
setSelectedTags(selectedTags.filter((t) => t !== tagToRemove));
capture("tag:remove_tag", {
name: tagToRemove,
});
}
};
return (
<div
className="flex flex-wrap items-center overflow-auto rounded-lg border px-2"
cmdk-input-wrapper=""
>
{selectedTags.length > 0 && (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 pt-2">
{selectedTags.map((tag: string) => (
<Button
key={tag}
variant="tertiary"
size="icon-sm"
disabled={!allowTagRemoval}
className={
allowTagRemoval ? "cursor-pointer" : "cursor-default"
}
onClick={allowTagRemoval ? () => removeTag(tag) : undefined}
>
{tag}
{allowTagRemoval && <X className="ml-1 h-3 w-3" />}
</Button>
))}
</div>
)}
autoFocus
{...props}
/>
</div>
);
});
<CommandPrimitive.Input
ref={ref}
className={cn(
"placeholder:muted-foreground flex h-8 w-full rounded-md border-transparent bg-transparent px-1 text-sm outline-none focus:border-0 focus:border-none focus:border-transparent focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
autoFocus
{...props}
/>
</div>
);
},
);
TagInput.displayName = CommandPrimitive.Input.displayName;
@@ -22,6 +22,7 @@ type TagManagerProps = {
mutateTags: (value: string[]) => void;
className?: string;
isTableCell?: boolean;
allowTagRemoval?: boolean;
};
const TagManager = ({
@@ -33,6 +34,7 @@ const TagManager = ({
mutateTags,
className,
isTableCell = false,
allowTagRemoval = true,
}: TagManagerProps) => {
const {
selectedTags,
@@ -114,6 +116,7 @@ const TagManager = ({
onValueChange={setInputValue}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
allowTagRemoval={allowTagRemoval}
/>
<CommandList>
<CommandGroup
@@ -2,7 +2,7 @@ import React, { useState } from "react";
import { api } from "@/src/utils/api";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { type RouterOutput } from "@/src/utils/types";
import TagManager from "@/src/features/tag/components/TagMananger";
import TagManager from "@/src/features/tag/components/TagManager";
import { trpcErrorToast } from "@/src/utils/trpcErrorToast";
type TagPromptDetailsPopoverProps = {
@@ -2,7 +2,7 @@ import React, { useState } from "react";
import { api } from "@/src/utils/api";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { type RouterOutput, type RouterInput } from "@/src/utils/types";
import TagManager from "@/src/features/tag/components/TagMananger";
import TagManager from "@/src/features/tag/components/TagManager";
import { trpcErrorToast } from "@/src/utils/trpcErrorToast";
type TagPromptPopverProps = {
@@ -2,7 +2,7 @@ import React, { useState } from "react";
import { api } from "@/src/utils/api";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { type RouterOutput } from "@/src/utils/types";
import TagManager from "@/src/features/tag/components/TagMananger";
import TagManager from "@/src/features/tag/components/TagManager";
import { trpcErrorToast } from "@/src/utils/trpcErrorToast";
type TagTraceDetailsPopoverProps = {
@@ -85,6 +85,7 @@ export function TagTraceDetailsPopover({
isLoading={isLoading}
mutateTags={mutateTags}
className={className}
allowTagRemoval={false}
/>
);
}
@@ -2,7 +2,7 @@ import React, { useState } from "react";
import { api } from "@/src/utils/api";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { type RouterOutput, type RouterInput } from "@/src/utils/types";
import TagManager from "@/src/features/tag/components/TagMananger";
import TagManager from "@/src/features/tag/components/TagManager";
import { trpcErrorToast } from "@/src/utils/trpcErrorToast";
type TagTracePopoverProps = {
@@ -75,6 +75,7 @@ export function TagTracePopover({
mutateTags={mutateTags}
className={className}
isTableCell
allowTagRemoval={false}
/>
);
}
+7 -7
View File
@@ -56,13 +56,6 @@ export default async function handler(
return;
}
const body = ManageBullBody.safeParse(req.body);
if (!body.success) {
res.status(400).json({ error: body.error });
return;
}
if (req.method === "GET") {
const queues: string[] = Object.values(QueueName);
queues.push(...IngestionQueue.getShardNames());
@@ -94,6 +87,13 @@ export default async function handler(
return res.status(200).json(queueCounts);
}
const body = ManageBullBody.safeParse(req.body);
if (!body.success) {
res.status(400).json({ error: body.error });
return;
}
if (req.method === "POST" && body.data.action === "remove") {
logger.info(
`Removing jobs for queues ${body.data.queueNames.join(", ")}`,
@@ -2,12 +2,14 @@ import { prisma } from "@langfuse/shared/src/db";
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
import { createAuthedProjectAPIRoute } from "@/src/features/public-api/server/createAuthedProjectAPIRoute";
import {
CreateAnnotationQueueBody,
CreateAnnotationQueueResponse,
GetAnnotationQueuesQuery,
GetAnnotationQueuesResponse,
} from "@/src/features/public-api/types/annotation-queues";
import { InvalidRequestError, MethodNotAllowedError } from "@langfuse/shared";
export default withMiddlewares({
// NOTE: Post API requires entitlement check
GET: createAuthedProjectAPIRoute({
name: "Get annotation queues",
querySchema: GetAnnotationQueuesQuery,
@@ -54,4 +56,72 @@ export default withMiddlewares({
};
},
}),
POST: createAuthedProjectAPIRoute({
name: "Create annotation queue",
bodySchema: CreateAnnotationQueueBody,
responseSchema: CreateAnnotationQueueResponse,
fn: async ({ body, auth }) => {
// entitlement check
if (auth.scope.plan === "cloud:hobby") {
if (
(await prisma.annotationQueue.count({
where: {
projectId: auth.scope.projectId,
},
})) >= 1
) {
throw new MethodNotAllowedError(
"Maximum number of annotation queues reached on Hobby plan.",
);
}
}
const existingQueue = await prisma.annotationQueue.findFirst({
where: {
projectId: auth.scope.projectId,
name: body.name,
},
});
if (existingQueue) {
throw new InvalidRequestError("A queue with this name already exists.");
}
// verify the score configs exist
const scoreConfigs = await prisma.scoreConfig.findMany({
where: {
id: { in: body.scoreConfigIds },
projectId: auth.scope.projectId,
},
select: {
id: true,
},
});
const scoreConfigIdSet = new Set(scoreConfigs.map((config) => config.id));
if (body.scoreConfigIds.some((id) => !scoreConfigIdSet.has(id))) {
throw new InvalidRequestError(
"At least one of the score config IDs cannot be found for the given project.",
);
}
const queue = await prisma.annotationQueue.create({
data: {
projectId: auth.scope.projectId,
name: body.name,
description: body.description,
scoreConfigIds: body.scoreConfigIds,
},
});
return {
id: queue.id,
name: queue.name,
description: queue.description,
scoreConfigIds: queue.scoreConfigIds,
createdAt: queue.createdAt,
updatedAt: queue.updatedAt,
};
},
}),
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "worker",
"version": "3.97.2",
"version": "3.97.4",
"description": "",
"license": "MIT",
"private": true,
@@ -588,26 +588,7 @@ describe("create experiment job calls with langfuse server side tracing", async
await createExperimentJobPostgres({ event: mockEvent });
// Verify callLLM was called with correct trace parameters
expect(callLLM).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Array),
expect.any(Object),
expect.any(String),
expect.any(String),
expect.objectContaining({
environment: PROMPT_EXPERIMENT_ENVIRONMENT,
traceName: expect.stringMatching(/^dataset-run-item-/),
traceId: expect.any(String),
projectId: mockEvent.projectId,
authCheck: expect.objectContaining({
validKey: true,
scope: expect.objectContaining({
projectId: mockEvent.projectId,
accessLevel: "project",
}),
}),
}),
);
expect(callLLM).toHaveBeenCalledTimes(0);
});
});
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.97.2";
export const VERSION = "v3.97.4";
@@ -56,22 +56,21 @@ export const handleDataRetentionProcessingJob = async (job: Job) => {
);
}
await removeIngestionEventsFromS3AndDeleteClickhouseRefsForProject(
projectId,
cutoffDate,
);
// Delete ClickHouse (TTL / Delete Queries)
logger.info(
`[Data Retention] Deleting ClickHouse data older than ${retention} days for project ${projectId}`,
`[Data Retention] Deleting ClickHouse and S3 data older than ${retention} days for project ${projectId}`,
);
await Promise.all([
removeIngestionEventsFromS3AndDeleteClickhouseRefsForProject(
projectId,
cutoffDate,
),
deleteTracesOlderThanDays(projectId, cutoffDate),
deleteObservationsOlderThanDays(projectId, cutoffDate),
deleteScoresOlderThanDays(projectId, cutoffDate),
]);
logger.info(
`[Data Retention] Deleted ClickHouse data older than ${retention} days for project ${projectId}`,
`[Data Retention] Deleted ClickHouse and S3 data older than ${retention} days for project ${projectId}`,
);
// Set S3 Lifecycle for deletion (Future)
+3
View File
@@ -262,6 +262,9 @@ const EnvSchema = z.object({
.positive()
.default(2),
LANGFUSE_DELETE_BATCH_SIZE: z.coerce.number().positive().default(2000),
LANGFUSE_EXPERIMENT_DATASET_RUN_ITEMS_TRACE_SOURCE_CH: z
.enum(["true", "false"])
.default("true"),
});
export const env: z.infer<typeof EnvSchema> =
+4 -1
View File
@@ -29,6 +29,7 @@ import {
import { kyselyPrisma, prisma } from "@langfuse/shared/src/db";
import z from "zod/v4";
import { createHash } from "crypto";
import { env } from "../../env";
export enum TraceExecutionSource {
// eslint-disable-next-line no-unused-vars
@@ -52,7 +53,9 @@ export enum TraceExecutionSource {
*
*/
export const shouldCreateTrace = (source: TraceExecutionSource) => {
// TODO: use env variable instead
if (env.LANGFUSE_EXPERIMENT_DATASET_RUN_ITEMS_TRACE_SOURCE_CH === "true") {
return source === TraceExecutionSource.CLICKHOUSE;
}
return source === TraceExecutionSource.POSTGRES;
};