Compare commits

...
7 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
19 changed files with 124 additions and 103 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.97.3",
"version": "3.97.4",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
@@ -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,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.97.3",
"version": "3.97.4",
"private": true,
"license": "MIT",
"engines": {
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.97.3";
export const VERSION = "v3.97.4";
@@ -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(),
+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}
/>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "worker",
"version": "3.97.3",
"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.3";
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;
};