Compare commits

...
33 Commits
Author SHA1 Message Date
Max Deichmann 231a8f82bc chore: release v3.104.0
Codespell / Check for spelling errors (push) Waiting to run
CI/CD / pre-job (push) Waiting to run
CI/CD / lint (push) Blocked by required conditions
CI/CD / prettier-check (push) Blocked by required conditions
CI/CD / test-docker-build (push) Blocked by required conditions
CI/CD / tests-web-sync (node24, pg12) (push) Blocked by required conditions
CI/CD / tests-web-sync (node24, pg15) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg12, mode) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg15, mode) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg12, mode-azure) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg15, mode-azure) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg12, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg15, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg12, mode) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg15, mode) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg12, mode-azure) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg15, mode-azure) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg12, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg15, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / e2e-tests (push) Blocked by required conditions
CI/CD / e2e-server-tests (push) Blocked by required conditions
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2025-08-27 11:58:38 +02:00
Max DeichmannandGitHub a933bf68fb chore: fix log comments (#8760) 2025-08-27 08:45:52 +02:00
Max DeichmannandGitHub 21a16963e2 chore: add log comments ch write (#8758)
push
2025-08-27 06:28:52 +00:00
marliessophieandGitHub fe6c647c2b chore(evals): graceful failure in case of timeouts of LLM providers (#8752) 2025-08-26 21:02:43 +00:00
marliessophieandGitHub f1e5f35659 chore(dataset-run-items): remove PG execution code (#8732)
* chore(dataset-run-items): remove PG execution code

* chore: remove all dri postgres code

* chore: fix return

* chore: remove env variables

* chore: drop unused exports

* remove: env's from test env configs
2025-08-26 15:42:15 +00:00
Max DeichmannandGitHub d39f7ba6e5 chore: upgrade to node 24 (#8734) 2025-08-26 17:06:04 +02:00
NimarandGitHub f701562396 fix: make chain coloring unique (#8749) 2025-08-26 13:57:56 +02:00
marliessophieandGitHub 83cbd336e2 fix(runs-compare): infinite re-renders on data table (#8744) 2025-08-26 13:02:29 +02:00
marliessophieandGitHub ec33992412 test(datasets): skip flaky dataset runs test in CI (#8738) 2025-08-25 19:06:04 +00:00
Marc KlingenandGitHub 4e67edaaff fix: cursor bg agent clock scew (skip check) (#8737)
push
2025-08-25 20:01:02 +02:00
marliessophieandGitHub ac3ee18373 chore(dataset-run-items-seeder): remove PG dataset run item creation path (#8733)
* chore(dataset-run-items-seeder): remove PG dataset run item creation path

* chore: drop import

* chore: increase timeout
2025-08-25 16:42:49 +00:00
Hassieb PakzadandGitHub 7f646757c5 feat(otel): parse vercel ai SDK ai.usage.* (#8730) 2025-08-25 14:53:15 +00:00
marliessophieandGitHub 2d82ae6e86 chore(dataset-runs): improve loading animations on runs table (#8719) 2025-08-25 13:04:46 +00:00
Marlies Mayerhofer 9aa4c11c56 chore: release v3.103.0
Codespell / Check for spelling errors (push) Waiting to run
CI/CD / pre-job (push) Waiting to run
CI/CD / lint (push) Blocked by required conditions
CI/CD / prettier-check (push) Blocked by required conditions
CI/CD / test-docker-build (push) Blocked by required conditions
CI/CD / tests-web-sync (node20, pg12) (push) Blocked by required conditions
CI/CD / tests-web-sync (node20, pg15) (push) Blocked by required conditions
CI/CD / tests-web-async (node20, pg12, mode) (push) Blocked by required conditions
CI/CD / tests-web-async (node20, pg15, mode) (push) Blocked by required conditions
CI/CD / tests-web-async (node20, pg12, mode-azure) (push) Blocked by required conditions
CI/CD / tests-web-async (node20, pg15, mode-azure) (push) Blocked by required conditions
CI/CD / tests-web-async (node20, pg12, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-web-async (node20, pg15, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-worker (node20, pg12, mode) (push) Blocked by required conditions
CI/CD / tests-worker (node20, pg15, mode) (push) Blocked by required conditions
CI/CD / tests-worker (node20, pg12, mode-azure) (push) Blocked by required conditions
CI/CD / tests-worker (node20, pg15, mode-azure) (push) Blocked by required conditions
CI/CD / tests-worker (node20, pg12, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-worker (node20, pg15, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / e2e-tests (push) Blocked by required conditions
CI/CD / e2e-server-tests (push) Blocked by required conditions
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2025-08-25 14:10:11 +02:00
marliessophieandGitHub 63a99b39fa chore(datasets): default to reading/writing CH (#8724) 2025-08-25 11:49:02 +00:00
marliessophieandGitHub 2066658773 chore(datasets): fix flaky test (#8722)
* chore(datasets): fix flaky test

* chore: specify timeout
2025-08-25 11:10:55 +00:00
marliessophieandGitHub 43ffd1b72d perf(evals): propagate trace timestamp to eval execution for better trace and observations filtering (#8687)
* chore(job_executions): add `job_input_trace_timestamp` to table

* chore: rely on trace timestamp in eval execution trace and observation queries

* fixup: return trace timestamp in existance check

* chore: return trace existence bool and timestamp

* chore: fix tests

* chore: rename

* chore: bump migration
2025-08-25 09:06:42 +00:00
Max DeichmannandGitHub e5f96790c8 chore: upgrade gcs storage client library (#8698) 2025-08-23 12:31:58 +00:00
0b062f7c5b feat: add dashboard filter persistence (#8377)
* docs: add spec

* feat: add filter and date range saving to dashboard

* feat: take out url params hook for date ranges

* docs: remove specs

* move migration

* move migration

* fixes

* fixes

---------

Co-authored-by: Max Deichmann <m.deichmann@tum.de>
2025-08-23 11:51:50 +00:00
Leo WeigandandGitHub 8dc119365a chore: re-enable focus-refetching for data tables (#8688) 2025-08-22 15:05:31 +00:00
marliessophieandGitHub 4bdc46ac31 chore(dataset-run-items): remove PG execution fallbacks (#8685) 2025-08-22 14:47:58 +00:00
Leo WeigandandGitHub 2308495088 fix(playground): empty message error when submitting all windows (#8686)
* fix(prompts): variables should not break

* fix(playground): empty message error when submitting all
2025-08-22 14:43:15 +00:00
marliessophieandGitHub 3f847591f4 feat(datasets): full text search for dataset items (#8680)
* feat(dataset-items-ui): allow filtering by metadata keys

* chore: push

* chore: push

* chore: fix after rebase

* feat(datasets): full text search for dataset items

* chore: remove checking for trace and observation existence before returning dataset items

* chore: eslint

* chore: pass search parameters
2025-08-22 13:51:38 +00:00
Hassieb PakzadandGitHub dff54e8ba9 fix(otel): override span observation types if generation-like attributes present (#8683) 2025-08-22 12:59:54 +00:00
marliessophieandGitHub 27d43a7a29 chore(dataset-run-items): remove PG execution path from tests (#8638)
* chore(dataset-run-items): remove PG execution path from web-async tests

* chore: return CH result on CH write

* chore: remove PG code

* chore: add timeouts

* chore: push

* chore: ch inserts

* chore: use CH inserts in worker batch action test
2025-08-22 07:26:09 +00:00
marliessophieandGitHub fced289c1c feat(dataset-items-ui): allow filtering by metadata keys (#8627)
* feat(dataset-items-ui): allow filtering by metadata keys

* chore: push

* chore: push

* chore: fix after rebase
2025-08-22 07:10:34 +00:00
Marc KlingenandGitHub f37b0cbe3b fix: clock sync issue with cursor bg agent (#8674) 2025-08-21 21:27:03 +02:00
Marc KlingenandGitHub ca9d42c7a3 chore: add TERM env to cursor dockerfile (#8673) 2025-08-21 21:21:32 +02:00
marliessophieandGitHub 4da2fe32b2 docs(dataset-runs-ui): rename experiments to dataset runs across ui (#8662)
* docs(dataset-runs-ui): rename `experiments` to `dataset runs` across ui

* chore: push
2025-08-21 17:53:12 +00:00
marliessophieandGitHub 3770613a03 fix(annotation): verify existing CH score data type prior to upsert (#8666)
* fix(annotation): verify existing CH score data type prior to upsert

* chore: push
2025-08-21 16:21:38 +00:00
Nimar f543dc2e38 chore: release v3.102.0
Codespell / Check for spelling errors (push) Waiting to run
CI/CD / pre-job (push) Waiting to run
CI/CD / lint (push) Blocked by required conditions
CI/CD / prettier-check (push) Blocked by required conditions
CI/CD / test-docker-build (push) Blocked by required conditions
CI/CD / tests-web-sync (node20, pg12) (push) Blocked by required conditions
CI/CD / tests-web-sync (node20, pg15) (push) Blocked by required conditions
CI/CD / tests-web-async (node20, pg12, mode) (push) Blocked by required conditions
CI/CD / tests-web-async (node20, pg15, mode) (push) Blocked by required conditions
CI/CD / tests-web-async (node20, pg12, mode-azure) (push) Blocked by required conditions
CI/CD / tests-web-async (node20, pg15, mode-azure) (push) Blocked by required conditions
CI/CD / tests-web-async (node20, pg12, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-web-async (node20, pg15, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-worker (node20, pg12, mode) (push) Blocked by required conditions
CI/CD / tests-worker (node20, pg15, mode) (push) Blocked by required conditions
CI/CD / tests-worker (node20, pg12, mode-azure) (push) Blocked by required conditions
CI/CD / tests-worker (node20, pg15, mode-azure) (push) Blocked by required conditions
CI/CD / tests-worker (node20, pg12, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-worker (node20, pg15, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / e2e-tests (push) Blocked by required conditions
CI/CD / e2e-server-tests (push) Blocked by required conditions
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2025-08-21 18:00:43 +02:00
NimarandGitHub db328c5d82 feat(tracing): map vercel AI sdk observation types (#8670) 2025-08-21 14:59:53 +00:00
NimarandGitHub 9a38bdcce2 feat(tracing): map openinference, openai to new observation types (#8664)
* feat(tracing): map openinference, openai to new observation types

* make it simpler!

* simplify

* add otel gen-ai

* move instatiation to otelingest
2025-08-21 14:15:25 +00:00
95 changed files with 4070 additions and 3040 deletions
+5 -1
View File
@@ -3,7 +3,10 @@ FROM node:20
# ---------- System packages --------------------------------------------------
# The buildpack-deps base already ships git, build-essential, python, etc.
# Add a few extra tools handy during Langfuse development.
RUN apt-get update && \
RUN apt-get update \
--option=Acquire::Check-Valid-Until=false \
--option=Acquire::Check-Date=false \
--option=APT::Get::Assume-Yes=true && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
openssl \
wget \
@@ -49,6 +52,7 @@ RUN pnpm store path > /dev/null && \
USER ubuntu
WORKDIR /home/ubuntu
ENV HOME=/home/ubuntu
ENV TERM=xterm-256color
# Container starts with a bash shell ready for hacking.
CMD ["bash"]
-3
View File
@@ -83,6 +83,3 @@ ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
# speeds up local development by not executing init scripts on server startup
NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
LANGFUSE_EXPERIMENT_DATASET_RUN_ITEMS_WRITE_CH=true
LANGFUSE_EXPERIMENT_DATASET_RUN_ITEMS_READ_CH=true
-3
View File
@@ -88,6 +88,3 @@ NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES="true"
LANGFUSE_EXPERIMENT_INSERT_INTO_TRACES_TABLE="false"
LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT="true"
LANGFUSE_EXPERIMENT_DATASET_RUN_ITEMS_WRITE_CH=true
LANGFUSE_EXPERIMENT_DATASET_RUN_ITEMS_READ_CH=true
+1 -4
View File
@@ -87,7 +87,4 @@ NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
# Slack credentials for development
SLACK_CLIENT_ID=your_slack_client_id
SLACK_CLIENT_SECRET=your_slack_client_secret
SLACK_STATE_SECRET=your_slack_state_secret
LANGFUSE_EXPERIMENT_DATASET_RUN_ITEMS_WRITE_CH=true
LANGFUSE_EXPERIMENT_DATASET_RUN_ITEMS_READ_CH=true
SLACK_STATE_SECRET=your_slack_state_secret
+12 -11
View File
@@ -40,7 +40,7 @@ jobs:
version: 9.5.0
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
cache: "pnpm"
cache-dependency-path: "pnpm-lock.yaml"
- name: install dependencies
@@ -66,7 +66,7 @@ jobs:
version: 9.5.0
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
cache: "pnpm"
cache-dependency-path: "pnpm-lock.yaml"
- name: install dependencies
@@ -82,12 +82,12 @@ jobs:
else
BASE_SHA=$(git merge-base origin/main HEAD)
fi
echo "Checking files changed from $BASE_SHA to HEAD"
# Get changed files
CHANGED_FILES=$(git diff --name-only $BASE_SHA HEAD -- '*.js' '*.jsx' '*.ts' '*.tsx' '*.css' | tr '\n' ' ')
if [ -n "$CHANGED_FILES" ] && [ "$CHANGED_FILES" != " " ]; then
echo "Files to check: $CHANGED_FILES"
pnpm prettier --check --experimental-cli $CHANGED_FILES
@@ -142,7 +142,7 @@ jobs:
name: tests-web-sync (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }})
strategy:
matrix:
node-version: [20]
node-version: [24]
postgres-version: [12, 15]
steps:
- uses: actions/checkout@v4
@@ -216,7 +216,7 @@ jobs:
name: tests-web-async (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }}, mode${{ matrix.deploy-mode }})
strategy:
matrix:
node-version: [20]
node-version: [24]
postgres-version: [12, 15]
deploy-mode: ["", "-azure", "-redis-cluster"]
steps:
@@ -291,7 +291,7 @@ jobs:
name: tests-worker (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }}, mode${{ matrix.deploy-mode }})
strategy:
matrix:
node-version: [20]
node-version: [24]
postgres-version: [12, 15]
deploy-mode: ["", "-azure", "-redis-cluster"]
steps:
@@ -359,7 +359,7 @@ jobs:
version: 9.5.0
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
cache: "pnpm"
cache-dependency-path: "pnpm-lock.yaml"
- name: Login to Docker Hub
@@ -423,9 +423,10 @@ jobs:
version: 9.5.0
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
cache: "pnpm"
cache-dependency-path: "pnpm-lock.yaml"
- name: install dependencies
run: |
pnpm install
@@ -517,7 +518,7 @@ jobs:
- name: Setup node
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
cache-dependency-path: "pnpm-lock.yaml"
- name: Checkout
uses: actions/checkout@v4
+1 -1
View File
@@ -1 +1 @@
v20.19.2
v24.6.0
+3 -3
View File
@@ -15,7 +15,7 @@
}
},
"engines": {
"node": "20"
"node": "24"
},
"scripts": {
"build": "tsc",
@@ -34,7 +34,7 @@
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/node": "^20.11.29",
"@types/node": "^24.3.0",
"@typescript-eslint/parser": "^7.12.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
@@ -45,4 +45,4 @@
"tsc-watch": "^6.2.0",
"typescript": "^5.7.2"
}
}
}
+2 -2
View File
@@ -3,10 +3,10 @@
"compilerOptions": {
"moduleResolution": "NodeNext",
"module": "NodeNext",
"lib": ["ES2020"],
"lib": ["es2023"],
"outDir": "./dist",
"types": ["node"],
"target": "ES2020",
"target": "es2024",
"rootDir": "."
},
"include": ["."],
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "langfuse",
"version": "3.101.0",
"version": "3.104.0",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
"engines": {
"node": "20"
"node": "24"
},
"scripts": {
"preinstall": "npx only-allow pnpm",
+23 -7
View File
@@ -3,9 +3,17 @@
"display": "Next.js",
"extends": "./base.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"target": "es2017",
"lib": ["dom", "dom.iterable", "esnext"],
"plugins": [
{
"name": "next"
}
],
"target": "es2024",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"checkJs": true,
"skipLibCheck": true,
@@ -18,8 +26,16 @@
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"types": ["jest", "node"],
"types": [
"jest",
"node"
],
},
"include": ["src", "next-env.d.ts"],
"exclude": ["node_modules"]
}
"include": [
"src",
"next-env.d.ts"
],
"exclude": [
"node_modules"
]
}
+4 -4
View File
@@ -6,7 +6,7 @@
"main": "./dist/src/index.js",
"types": "./dist/src/index.d.ts",
"engines": {
"node": "20"
"node": "24"
},
"exports": {
".": {
@@ -62,7 +62,7 @@
"@aws-sdk/s3-request-presigner": "^3.679.0",
"@azure/storage-blob": "^12.26.0",
"@clickhouse/client": "^1.12.1",
"@google-cloud/storage": "^7.15.2",
"@google-cloud/storage": "^7.17.0",
"@langchain/anthropic": "^0.3.22",
"@langchain/aws": "^0.1.11",
"@langchain/core": "^0.3.58",
@@ -101,7 +101,7 @@
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/lodash": "^4.17.10",
"@types/node": "^20.11.29",
"@types/node": "^24.3.0",
"@types/nodemailer": "^6.4.16",
"@types/pg": "^8.11.10",
"@types/react": "18.2.79",
@@ -125,4 +125,4 @@
"@types/react": "~18.2.79",
"react": "~18.2.0"
}
}
}
@@ -358,6 +358,7 @@ export type Dashboard = {
name: string;
description: string;
definition: unknown;
filters: Generated<unknown>;
};
export type DashboardWidget = {
id: string;
@@ -473,6 +474,7 @@ export type JobExecution = {
end_time: Timestamp | null;
error: string | null;
job_input_trace_id: string | null;
job_input_trace_timestamp: Timestamp | null;
job_input_observation_id: string | null;
job_input_dataset_item_id: string | null;
job_output_score_id: string | null;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "dashboards" ADD COLUMN "filters" JSONB NOT NULL DEFAULT '[]';
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "job_executions" ADD COLUMN "job_input_trace_timestamp" TIMESTAMP(3);
+3
View File
@@ -916,6 +916,8 @@ model JobExecution {
jobInputTraceId String? @map("job_input_trace_id") // no fk constraint - traces in ClickHouse, deletion handled via project cascade
jobInputTraceTimestamp DateTime? @map("job_input_trace_timestamp")
jobInputObservationId String? @map("job_input_observation_id") // no fk constraint - observations in ClickHouse, deletion handled via project cascade
jobInputDatasetItemId String? @map("job_input_dataset_item_id") // no fk constraint - job execution sensible standalone
@@ -1180,6 +1182,7 @@ model Dashboard {
description String @map("description")
definition Json @map("definition")
filters Json @default("[]") @map("filters")
@@map("dashboards")
}
@@ -24,7 +24,6 @@ import {
} from "./utils/postgres-seed-constants";
import {
generateDatasetItemId,
generateDatasetRunTraceId,
generateEvalObservationId,
generateEvalScoreId,
generateEvalTraceId,
@@ -564,17 +563,17 @@ export async function createDatasets(
}
for (let datasetRunNumber = 0; datasetRunNumber < 3; datasetRunNumber++) {
const datasetRun = await prisma.datasetRuns.upsert({
await prisma.datasetRuns.upsert({
where: {
id_projectId: {
id: `demo-dataset-run-${datasetRunNumber}-${projectId.slice(-8)}`,
id: `demo-dataset-run-${datasetRunNumber}-${datasetName}-${projectId.slice(-8)}`,
projectId,
},
},
create: {
projectId,
id: `demo-dataset-run-${datasetRunNumber}-${projectId.slice(-8)}`,
name: `demo-dataset-run-${datasetRunNumber}`,
id: `demo-dataset-run-${datasetRunNumber}-${datasetName}-${projectId.slice(-8)}`,
name: `demo-dataset-run-${datasetRunNumber}-${datasetName}`,
description: Math.random() > 0.5 ? "Dataset run description" : "",
datasetId: dataset.id,
metadata: [
@@ -587,25 +586,6 @@ export async function createDatasets(
},
update: {},
});
for (let index = 0; index < datasetItemIds.length; index++) {
await prisma.datasetRunItems.upsert({
where: {
id_projectId: {
id: `${dataset.id}-${index}-${datasetRunNumber}`,
projectId,
},
},
create: {
id: `${dataset.id}-${index}-${datasetRunNumber}`,
projectId,
datasetItemId: datasetItemIds[index],
traceId: `${generateDatasetRunTraceId(datasetName, index, projectId, datasetRunNumber)}`,
datasetRunId: datasetRun.id,
},
update: {},
});
}
}
}
}
@@ -86,7 +86,6 @@ export class DataGenerator {
input.runNumber || 0,
);
// TODO: there are too many dataset run items in the postgres database?
return createDatasetRunItem({
id: datasetRunItemId,
project_id: projectId,
@@ -97,8 +96,8 @@ export class DataGenerator {
input.runNumber || 0,
),
dataset_id: `${input.datasetName}-${projectId.slice(-8)}`,
dataset_run_id: `demo-dataset-run-${input.runNumber}-${projectId.slice(-8)}`,
dataset_run_name: `demo-dataset-run-${input.runNumber}-${projectId.slice(-8)}`,
dataset_run_id: `demo-dataset-run-${input.runNumber}-${input.datasetName}-${projectId.slice(-8)}`,
dataset_run_name: `demo-dataset-run-${input.runNumber}-${input.datasetName}`,
dataset_run_created_at: input.runCreatedAt,
dataset_run_description:
(input.runNumber || 0) % 2 === 0 ? "Dataset run description" : "",
@@ -110,7 +109,7 @@ export class DataGenerator {
input.runNumber || 0,
),
dataset_item_input: input.item.input,
dataset_item_expected_output: input.item.output,
dataset_item_expected_output: input.item.expectedOutput,
});
}
@@ -4,6 +4,7 @@ import { ClickHouseQueryBuilder } from "./clickhouse-builder";
import { EVAL_TRACE_COUNT, SEED_DATASETS } from "./postgres-seed-constants";
import {
clickhouseClient,
DatasetRunItemRecordInsertType,
logger,
ObservationRecordInsertType,
TraceRecordInsertType,
@@ -92,25 +93,25 @@ export class SeederOrchestrator {
logger.info(
`Processing run ${runNumber + 1}/${numberOfRuns} for project ${projectId}`,
);
// const now = Date.now();
const now = Date.now();
const traces: TraceRecordInsertType[] = [];
const observations: ObservationRecordInsertType[] = [];
// const datasetRunItems: DatasetRunItemRecordInsertType[] = [];
const datasetRunItems: DatasetRunItemRecordInsertType[] = [];
for (const seedDataset of SEED_DATASETS) {
for (const [itemIndex, datasetItem] of seedDataset.items.entries()) {
// // Generate dataset run item data
// const datasetRunItem = this.dataGenerator.generateDatasetRunItem(
// {
// datasetName: seedDataset.name,
// itemIndex,
// item: datasetItem,
// runNumber,
// runCreatedAt: now,
// },
// projectId,
// );
// Generate dataset run item data
const datasetRunItem = this.dataGenerator.generateDatasetRunItem(
{
datasetName: seedDataset.name,
itemIndex,
item: datasetItem,
runNumber,
runCreatedAt: now,
},
projectId,
);
// Generate trace data
const trace = this.dataGenerator.generateDatasetTrace(
@@ -137,14 +138,14 @@ export class SeederOrchestrator {
traces.push(trace);
observations.push(observation);
// datasetRunItems.push(datasetRunItem);
datasetRunItems.push(datasetRunItem);
}
}
try {
await this.queryBuilder.executeTracesInsert(traces);
await this.queryBuilder.executeObservationsInsert(observations);
// await this.queryBuilder.executeDatasetRunItemsInsert(datasetRunItems);
await this.queryBuilder.executeDatasetRunItemsInsert(datasetRunItems);
} catch (error) {
logger.error(`✗ Insert failed:`, error);
throw error;
+6 -7
View File
@@ -126,13 +126,6 @@ const EnvSchema = z.object({
LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS: z.coerce.number().default(600_000), // 10 minutes
LANGFUSE_CLICKHOUSE_QUERY_MAX_ATTEMPTS: z.coerce.number().default(3), // Maximum attempts for socket hang up errors
LANGFUSE_SKIP_S3_LIST_FOR_OBSERVATIONS_PROJECT_IDS: z.string().optional(),
// Dataset Run Items Migration Environment Variables
LANGFUSE_EXPERIMENT_DATASET_RUN_ITEMS_WRITE_CH: z
.enum(["true", "false"])
.default("false"),
LANGFUSE_EXPERIMENT_DATASET_RUN_ITEMS_READ_CH: z
.enum(["true", "false"])
.default("false"),
LANGFUSE_EXPERIMENT_COMPARE_READ_FROM_AGGREGATING_MERGE_TREES: z
.enum(["true", "false"])
.default("false"),
@@ -224,6 +217,12 @@ const EnvSchema = z.object({
.int()
.positive()
.default(600_000), // 10 minutes
LANGFUSE_FETCH_LLM_COMPLETION_TIMEOUT_MS: z.coerce
.number()
.int()
.positive()
.default(120_000), // 2 minutes
});
export const env: z.infer<typeof EnvSchema> =
@@ -7,4 +7,5 @@ export const QUEUE_ERROR_MESSAGES = {
TOO_LOW_MAX_TOKENS_ERROR: "Error: Unterminated string in JSON at position",
OUTPUT_TOKENS_TOO_LONG_ERROR:
"Could not parse response content as the length limit was reached",
TIMEOUT_ERROR: "Request timed out",
};
@@ -1,95 +0,0 @@
import { env } from "../../env";
import { logger } from "../../server/logger";
import {
DatasetRunItemsExecutionStrategy,
DatasetRunItemsOperationType,
} from "./types";
/**
* Returns the execution strategy for dataset run items based on environment variables.
*
* Two-phase migration approach:
* 1. Dual-write phase: DATASET_RUN_ITEMS_WRITE_TO_CLICKHOUSE=true (write to both databases)
* 2. Read migration phase: DATASET_RUN_ITEMS_READ_FROM_CLICKHOUSE=true (read from ClickHouse)
*/
function getDatasetRunItemsExecutionStrategy(): DatasetRunItemsExecutionStrategy {
return {
shouldWriteToClickHouse:
env.LANGFUSE_EXPERIMENT_DATASET_RUN_ITEMS_WRITE_CH === "true",
shouldReadFromClickHouse:
env.LANGFUSE_EXPERIMENT_DATASET_RUN_ITEMS_READ_CH === "true",
};
}
// Re-export the enum for backward compatibility
/**
* Executes the appropriate database operation based on the execution strategy.
*
* @param postgresExecution - Function to execute PostgreSQL operation
* @param clickhouseExecution - Function to execute ClickHouse operation
* @param operationType - Type of operation ("read" or "write")
* @returns Result from the selected execution strategy
*/
export async function executeWithDatasetRunItemsStrategy<TInput, TOutput>({
input,
operationType,
postgresExecution,
clickhouseExecution,
}: {
input: TInput;
operationType: DatasetRunItemsOperationType;
// eslint-disable-next-line no-unused-vars
postgresExecution: (input: TInput) => Promise<TOutput>;
// eslint-disable-next-line no-unused-vars
clickhouseExecution: (input: TInput) => Promise<TOutput>;
}): Promise<TOutput> {
const strategy = getDatasetRunItemsExecutionStrategy();
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;
} else {
// Write only to PostgreSQL
return await postgresExecution(input);
}
} else {
// For read operations, rely on the strategy
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);
}
} else {
// Read from PostgreSQL
return await postgresExecution(input);
}
}
}
@@ -1,16 +0,0 @@
/**
* Types and enums for dataset run items execution.
* This file is frontend-safe and doesn't import server-side dependencies.
*/
export enum DatasetRunItemsOperationType {
// eslint-disable-next-line no-unused-vars
READ = "read",
// eslint-disable-next-line no-unused-vars
WRITE = "write",
}
export type DatasetRunItemsExecutionStrategy = {
shouldWriteToClickHouse: boolean;
shouldReadFromClickHouse: boolean;
};
-3
View File
@@ -63,7 +63,6 @@ export * from "./repositories";
export * from "./utils/rendering";
export * from "./redis/evalExecutionQueue";
export * from "./services/sessions-ui-table-service";
export * from "./services/datasets-ui-table-service";
export * from "./services/DashboardService";
export * from "./services/TableViewService";
export * from "./services/DefaultEvaluationModelService";
@@ -75,8 +74,6 @@ export * from "./data-deletion/ingestionFileDeletion";
export * from "./s3";
// dataset run items
export * from "./dataset-run-items/datasetExecution";
export * from "./dataset-run-items/types";
export * from "./dataset-run-items/addToDeleteQueue";
// test utils
@@ -221,6 +221,7 @@ export async function fetchLLMCompletion(
// Common proxy configuration for all adapters
const proxyUrl = env.HTTPS_PROXY;
const proxyAgent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
const timeoutMs = env.LANGFUSE_FETCH_LLM_COMPLETION_TIMEOUT_MS;
let chatModel:
| ChatOpenAI
@@ -239,7 +240,7 @@ export async function fetchLLMCompletion(
callbacks: finalCallbacks,
clientOptions: {
maxRetries,
timeout: 1000 * 60 * 2, // 2 minutes timeout
timeout: timeoutMs,
...(proxyAgent && { httpAgent: proxyAgent }),
},
invocationKwargs: modelParams.providerOptions,
@@ -260,7 +261,7 @@ export async function fetchLLMCompletion(
...(proxyAgent && { httpAgent: proxyAgent }),
},
modelKwargs: modelParams.providerOptions,
timeout: 1000 * 60 * 2, // 2 minutes timeout
timeout: timeoutMs,
});
} else if (modelParams.adapter === LLMAdapter.Azure) {
chatModel = new AzureChatOpenAI({
@@ -273,7 +274,7 @@ export async function fetchLLMCompletion(
topP: modelParams.top_p,
callbacks: finalCallbacks,
maxRetries,
timeout: 1000 * 60 * 2, // 2 minutes timeout
timeout: timeoutMs,
configuration: {
defaultHeaders: extraHeaders,
...(proxyAgent && { httpAgent: proxyAgent }),
@@ -297,7 +298,7 @@ export async function fetchLLMCompletion(
topP: modelParams.top_p,
callbacks: finalCallbacks,
maxRetries,
timeout: 1000 * 60 * 2, // 2 minutes timeout
timeout: timeoutMs,
additionalModelRequestFields: modelParams.providerOptions as any,
});
} else if (modelParams.adapter === LLMAdapter.VertexAI) {
@@ -198,19 +198,19 @@ const getDatasetRunsTableInternal = async <T>(
dri.dataset_run_created_at as dataset_run_created_at,
count(DISTINCT dri.project_id, dri.dataset_id, dri.dataset_run_id, dri.dataset_item_id) as count_run_items,
-- Latency metrics (priority: observation > trace)
AVG(CASE
WHEN dri.observation_id IS NOT NULL AND od.latency_ms IS NOT NULL
THEN od.latency_ms / 1000.0
ELSE COALESCE(ta.latency_ms / 1000.0, 0)
END) as avg_latency_seconds,
-- Latency metrics (priority: trace > observation - matching old PostgreSQL behavior)
CASE
WHEN AVG(CASE WHEN dri.observation_id IS NULL THEN ta.latency_ms / 1000.0 ELSE NULL END) IS NOT NULL
THEN AVG(CASE WHEN dri.observation_id IS NULL THEN ta.latency_ms / 1000.0 ELSE NULL END)
ELSE AVG(CASE WHEN dri.observation_id IS NOT NULL THEN od.latency_ms / 1000.0 ELSE NULL END)
END as avg_latency_seconds,
-- Cost metrics (priority: observation > trace)
AVG(CASE
WHEN dri.observation_id IS NOT NULL AND od.total_cost IS NOT NULL
THEN od.total_cost
ELSE COALESCE(ta.total_cost, 0)
END) as avg_total_cost
-- Cost metrics (priority: trace > observation - matching old PostgreSQL behavior)
CASE
WHEN AVG(CASE WHEN dri.observation_id IS NULL THEN ta.total_cost ELSE NULL END) IS NOT NULL
THEN AVG(CASE WHEN dri.observation_id IS NULL THEN ta.total_cost ELSE NULL END)
ELSE COALESCE(AVG(CASE WHEN dri.observation_id IS NOT NULL THEN od.total_cost ELSE NULL END), 0)
END as avg_total_cost
FROM dataset_run_items_rmt dri
LEFT JOIN traces_aggregated ta
ON dri.trace_id = ta.trace_id
@@ -227,13 +227,19 @@ export const getObservationsForTrace = async <IncludeIO extends boolean>(
});
};
export const getObservationForTraceIdByName = async (
traceId: string,
projectId: string,
name: string,
timestamp?: Date,
fetchWithInputOutput: boolean = false,
) => {
export const getObservationForTraceIdByName = async ({
traceId,
projectId,
name,
timestamp,
fetchWithInputOutput = false,
}: {
traceId: string;
projectId: string;
name: string;
timestamp?: Date;
fetchWithInputOutput?: boolean;
}) => {
const query = `
SELECT
id,
@@ -42,6 +42,7 @@ export const searchExistingAnnotationScore = async (
sessionId: string | null,
name: string | undefined,
configId: string | undefined,
dataType: ScoreDataType,
) => {
if (!name && !configId) {
throw new Error("Either name or configId (or both) must be provided.");
@@ -52,8 +53,10 @@ export const searchExistingAnnotationScore = async (
FROM scores s
WHERE s.project_id = {projectId: String}
AND s.source = 'ANNOTATION'
AND s.trace_id = {traceId: String}
AND s.data_type = {dataType: String}
${traceId ? `AND s.trace_id = {traceId: String}` : "AND isNull(s.trace_id)"}
${observationId ? `AND s.observation_id = {observationId: String}` : "AND isNull(s.observation_id)"}
${sessionId ? `AND s.session_id = {sessionId: String}` : "AND isNull(s.session_id)"}
AND (
FALSE
${name ? `OR s.name = {name: String}` : ""}
@@ -72,6 +75,8 @@ export const searchExistingAnnotationScore = async (
configId,
traceId,
observationId,
sessionId,
dataType,
},
tags: {
feature: "tracing",
@@ -294,7 +299,27 @@ export const getTraceScoresForDatasetRuns = async (
const query = `
SELECT
s.* EXCEPT (metadata),
s.id as id,
s.timestamp as timestamp,
s.project_id as project_id,
s.environment as environment,
s.trace_id as trace_id,
s.session_id as session_id,
s.observation_id as observation_id,
s.dataset_run_id as dataset_run_id,
s.name as name,
s.value as value,
s.source as source,
s.comment as comment,
s.author_user_id as author_user_id,
s.config_id as config_id,
s.data_type as data_type,
s.string_value as string_value,
s.queue_id as queue_id,
s.created_at as created_at,
s.updated_at as updated_at,
s.event_ts as event_ts,
s.is_deleted as is_deleted,
length(mapKeys(s.metadata)) > 0 AS has_metadata,
dri.dataset_run_id as run_id
FROM dataset_run_items_rmt dri
@@ -83,6 +83,7 @@ export const getTimeframesTracesAMT = (
/**
* Checks if trace exists in clickhouse.
* Additionally, give back the timestamp of the trace as metadata.
*
* @param {string} projectId - Project ID for the trace
* @param {string} traceId - ID of the trace to check
@@ -94,7 +95,7 @@ export const getTimeframesTracesAMT = (
* • Filters within ±2 day window
* • Used for validating trace references before eval job creation
*/
export const checkTraceExists = async ({
export const checkTraceExistsAndGetTimestamp = async ({
projectId,
traceId,
timestamp,
@@ -108,7 +109,7 @@ export const checkTraceExists = async ({
filter: FilterState;
maxTimeStamp: Date | undefined;
exactTimestamp?: Date;
}): Promise<boolean> => {
}): Promise<{ exists: boolean; timestamp?: Date }> => {
const { tracesFilter } = getProjectIdDefaultFilter(projectId, {
tracesPrefix: "t",
});
@@ -158,7 +159,7 @@ export const checkTraceExists = async ({
`;
return measureAndReturn({
operationName: "checkTraceExists",
operationName: "checkTraceExistsAndGetTimestamp",
projectId,
minStartTime: timestamp ?? exactTimestamp,
input: {
@@ -181,7 +182,7 @@ export const checkTraceExists = async ({
type: "trace",
kind: "exists",
projectId,
operation_name: "checkTraceExists",
operation_name: "checkTraceExistsAndGetTimestamp",
},
timestamp: timestamp ?? exactTimestamp,
},
@@ -190,7 +191,8 @@ export const checkTraceExists = async ({
${observations_cte}
SELECT
t.id as id,
t.project_id as project_id
t.project_id as project_id,
t.timestamp as timestamp
FROM traces t FINAL
${observationFilterRes ? `INNER JOIN observations_agg o ON t.id = o.trace_id AND t.project_id = o.project_id` : ""}
WHERE ${tracesFilterRes.query}
@@ -199,16 +201,26 @@ export const checkTraceExists = async ({
${maxTimeStamp ? `AND timestamp <= {maxTimeStamp: DateTime64(3)}` : ""}
${!maxTimeStamp ? `AND timestamp <= {timestamp: DateTime64(3)} + INTERVAL 2 DAY` : ""}
${exactTimestamp ? `AND timestamp = {exactTimestamp: DateTime64(3)}` : ""}
GROUP BY t.id, t.project_id
GROUP BY t.id, t.project_id, t.timestamp
`;
const rows = await queryClickhouse<{ id: string; project_id: string }>({
const rows = await queryClickhouse<{
id: string;
project_id: string;
timestamp: string;
}>({
query,
params: input.params,
tags: { ...input.tags, experiment_amt: "original" },
});
return rows.length > 0;
return {
exists: rows.length > 0,
timestamp:
rows.length > 0
? parseClickhouseUTCDateTimeFormat(rows[0].timestamp)
: undefined,
};
},
newExecution: async (input) => {
const traceAmt = getTimeframesTracesAMT(input.timestamp);
@@ -225,13 +237,23 @@ export const checkTraceExists = async ({
AND t.project_id = {projectId: String}
`;
const rows = await queryClickhouse<{ id: string; project_id: string }>({
const rows = await queryClickhouse<{
id: string;
project_id: string;
timestamp: string;
}>({
query,
params: input.params,
tags: { ...input.tags, experiment_amt: "new" },
});
return rows.length > 0;
return {
exists: rows.length > 0,
timestamp:
rows.length > 0
? parseClickhouseUTCDateTimeFormat(rows[0].timestamp)
: undefined,
};
},
});
};
@@ -15,6 +15,7 @@ import {
DashboardDefinitionSchema,
} from "./types";
import { z } from "zod/v4";
import { singleFilter } from "../../../";
export class DashboardService {
/**
@@ -155,6 +156,32 @@ export class DashboardService {
});
}
/**
* Updates a dashboard's filters.
*/
public static async updateDashboardFilters(
dashboardId: string,
projectId: string,
filters: z.infer<typeof singleFilter>[],
userId?: string,
): Promise<DashboardDomain> {
const updatedDashboard = await prisma.dashboard.update({
where: {
id: dashboardId,
projectId,
},
data: {
updatedBy: userId,
filters,
},
});
return DashboardDomainSchema.parse({
...updatedDashboard,
owner: updatedDashboard.projectId ? "PROJECT" : "LANGFUSE",
});
}
/**
* Gets a dashboard by ID.
*/
@@ -97,6 +97,7 @@ export const DashboardDomainSchema = z.object({
name: z.string(),
description: z.string(),
definition: DashboardDefinitionSchema,
filters: z.array(singleFilter).default([]),
owner: OwnerEnum,
});
@@ -1,61 +0,0 @@
import { evalDatasetFormFilterCols } from "../../tableDefinitions/tracesTable";
import { FilterState } from "../../types";
import { tableColumnsToSqlFilterAndPrefix } from "../filterToPrisma";
import { Prisma, prisma } from "../../db";
type FetchDatasetItemsTableProps = {
select: "count";
projectId: string;
filter: FilterState;
};
const getDatasetRunItemsTableGenericPg = async <T>(
props: FetchDatasetItemsTableProps,
) => {
const { select, projectId, filter } = props;
let sqlSelect: Prisma.Sql;
switch (select) {
case "count":
sqlSelect = Prisma.sql`count(*) as count`;
break;
default:
// eslint-disable-next-line no-case-declarations, no-unused-vars
const exhaustiveCheckDefault: never = select;
throw new Error(`Unknown select type: ${select}`);
}
const datasetItemsFilter = tableColumnsToSqlFilterAndPrefix(
filter,
evalDatasetFormFilterCols,
"dataset_items",
);
const query = Prisma.sql`
SELECT
${sqlSelect}
FROM dataset_run_items as dri
JOIN dataset_items as di ON di.id = dri.dataset_item_id AND di.project_id = ${projectId}
WHERE dri.project_id = ${projectId}
${datasetItemsFilter}
`;
const res = await prisma.$queryRaw<T>(query);
return res;
};
export const getDatasetRunItemsTableCountPg = async (props: {
projectId: string;
filter: FilterState;
}) => {
const res = await getDatasetRunItemsTableGenericPg<Array<{ count: bigint }>>({
select: "count",
projectId: props.projectId,
filter: props.filter,
});
const totalCount = res.length > 0 ? Number(res[0].count) : 0;
return { totalCount };
};
@@ -0,0 +1,10 @@
import { ColumnDefinition } from "./types";
export const datasetItemFilterColumns: ColumnDefinition[] = [
{
name: "Metadata",
id: "metadata",
type: "stringObject",
internal: 'di."metadata"',
},
];
@@ -2,3 +2,4 @@ export * from "./sessionsView";
export * from "./types";
export * from "./mapDashboards";
export * from "./promptsTable";
export * from "./datasetItemsTable";
+1
View File
@@ -37,4 +37,5 @@ export type TableName =
| "widgets"
| "users"
| "eval_configs"
| "dataset_items"
| "job_executions";
+2 -2
View File
@@ -4,10 +4,10 @@
"jsx": "react",
"moduleResolution": "NodeNext",
"module": "NodeNext",
"lib": ["ES2021"],
"lib": ["es2023"],
"outDir": "./dist",
"types": ["node"],
"target": "ES2020",
"target": "es2024",
"rootDir": "."
},
"include": ["."],
+393 -503
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
FROM --platform=${TARGETPLATFORM:-linux/amd64} node:20-alpine AS alpine
FROM --platform=${TARGETPLATFORM:-linux/amd64} node:24-alpine AS alpine
# It's important to update the index before installing packages to ensure you're getting the latest versions.
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
+3 -3
View File
@@ -1,10 +1,10 @@
{
"name": "web",
"version": "3.101.0",
"version": "3.104.0",
"private": true,
"license": "MIT",
"engines": {
"node": "20"
"node": "24"
},
"scripts": {
"build": "INLINE_RUNTIME_CHUNK=false dotenv -e ../.env -- next build",
@@ -175,7 +175,7 @@
"@types/eslint": "^8.56.7",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.10",
"@types/node": "20.11.29",
"@types/node": "24.3.0",
"@types/react": "~18.2.79",
"@types/react-dom": "~18.2.25",
"@types/react-grid-layout": "^1.3.5",
File diff suppressed because it is too large Load Diff
@@ -30,6 +30,8 @@ import {
createTracesCh,
createOrgProjectAndApiKey,
getDatasetRunItemsByDatasetIdCh,
createDatasetRunItemsCh,
createDatasetRunItem,
} from "@langfuse/shared/src/server";
import waitForExpect from "wait-for-expect";
@@ -773,20 +775,12 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
projectId,
name: "run + only + observation",
},
include: {
datasetRunItems: true,
},
});
expect(dbRunObservation).not.toBeNull();
expect(dbRunObservation?.datasetId).toBe(dataset.body.id);
expect(dbRunObservation?.metadata).toMatchObject({ key: "value" });
expect(dbRunObservation?.description).toBe("run-description");
expect(runItemObservation.status).toBe(200);
expect(dbRunObservation?.datasetRunItems[0]).toMatchObject({
datasetItemId: "dataset-item-id",
observationId: observationId,
traceId: traceId,
});
await waitForExpect(async () => {
const runItems = await getDatasetRunItemsByDatasetIdCh({
@@ -859,19 +853,11 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
projectId,
name: "run-only-trace",
},
include: {
datasetRunItems: true,
},
});
expect(dbRunTrace).not.toBeNull();
expect(dbRunTrace?.datasetId).toBe(dataset.body.id);
expect(dbRunTrace?.metadata).toMatchObject({ key: "value" });
expect(runItemTrace.status).toBe(200);
expect(dbRunTrace?.datasetRunItems[0]).toMatchObject({
datasetItemId: "dataset-item-id",
traceId: traceId,
observationId: null,
});
const runItemBoth = await makeZodVerifiedAPICall(
PostDatasetRunItemsV1Response,
@@ -891,19 +877,11 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
projectId,
name: "run-name-both",
},
include: {
datasetRunItems: true,
},
});
expect(dbRunBoth).not.toBeNull();
expect(dbRunBoth?.datasetId).toBe(dataset.body.id);
expect(dbRunBoth?.metadata).toMatchObject({ key: "value" });
expect(runItemBoth.status).toBe(200);
expect(dbRunBoth?.datasetRunItems[0]).toMatchObject({
datasetItemId: "dataset-item-id",
observationId: observationId,
traceId: traceId,
});
}, 90000);
it("GET /api/public/datasets/{datasetName}/runs", async () => {
@@ -1129,12 +1107,8 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
name: datasetName,
},
},
include: {
datasetRunItems: true,
},
});
expect(dbRunBeforeDelete).not.toBeNull();
expect(dbRunBeforeDelete?.datasetRunItems.length).toBe(1);
// Delete the run and verify response matches DeleteDatasetRunV1Response
const deleteResponse = await makeZodVerifiedAPICall(
@@ -1162,14 +1136,20 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
expect(dbRunAfterDelete).toBeNull();
// Verify run items are also deleted
const dbRunItems = await prisma.datasetRunItems.findMany({
where: {
datasetRunId: dbRunBeforeDelete?.id,
await waitForExpect(async () => {
const dbRunItems = await getDatasetRunItemsByDatasetIdCh({
projectId: dataset.body.projectId,
},
});
expect(dbRunItems).toHaveLength(0);
});
datasetId: dataset.body.id,
filter: [],
orderBy: {
column: "createdAt",
order: "DESC",
},
limit: 10,
});
expect(dbRunItems).toHaveLength(0);
}, 30000);
}, 90000);
it("dataset-run-items should fail when neither trace nor observation provided", async () => {
const response = await makeAPICall(
@@ -1274,7 +1254,7 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
);
});
it("should delete a dataset item and its run items", async () => {
it("should delete a dataset item but not its run items", async () => {
const datasetName = `dataset-${uuidv4()}`;
const itemId = `item-${uuidv4()}`;
const nonExistentItemId = `non-existent-${uuidv4()}`;
@@ -1329,29 +1309,16 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
);
expect(deleteNonExistent.status).toBe(404);
// Create a run item associated with the dataset item
const runItem = await makeZodVerifiedAPICall(
PostDatasetRunItemsV1Response,
"POST",
"/api/public/dataset-run-items",
{
datasetItemId: itemId,
traceId: traceId,
runName: `run-${uuidv4()}`,
metadata: { key: "value" },
},
auth,
);
expect(runItem.status).toBe(200);
// Verify run item exists in database
const dbRunItem = await prisma.datasetRunItems.findFirst({
where: {
datasetItemId: itemId,
projectId: dataset.body.projectId,
},
});
expect(dbRunItem).not.toBeNull();
await createDatasetRunItemsCh([
createDatasetRunItem({
dataset_item_id: itemId,
trace_id: traceId,
dataset_run_name: `run-${uuidv4()}`,
dataset_item_metadata: { key: "value" },
dataset_id: dataset.body.id,
project_id: dataset.body.projectId,
}),
]);
// Delete the item and verify response matches DeleteDatasetItemV1Response
const deleteResponse = await makeZodVerifiedAPICall(
@@ -1387,14 +1354,20 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
expect(dbItem).toBeNull();
// Verify run items are also deleted
const dbRunItemAfterDelete = await prisma.datasetRunItems.findFirst({
where: {
datasetItemId: itemId,
await waitForExpect(async () => {
const dbRunItems = await getDatasetRunItemsByDatasetIdCh({
projectId: dataset.body.projectId,
},
});
expect(dbRunItemAfterDelete).toBeNull();
});
datasetId: dataset.body.id,
filter: [],
orderBy: {
column: "createdAt",
order: "DESC",
},
limit: 10,
});
expect(dbRunItems).toHaveLength(1);
}, 60000);
}, 90000);
it("should properly paginate and filter dataset run items", async () => {
// Create a dataset
@@ -1,6 +1,8 @@
import {
createDatasetRunItem,
createDatasetRunItemsCh,
createOrgProjectAndApiKey,
getDatasetRunItemsTableCountPg,
getDatasetRunItemsCountCh,
} from "@langfuse/shared/src/server";
import { v4 as uuidv4 } from "uuid";
import { prisma } from "@langfuse/shared/src/db";
@@ -53,33 +55,35 @@ describe("trpc.datasets", () => {
})),
});
await prisma.datasetRunItems.createMany({
data: datasetItemIds.map((datasetItemId, index) => ({
id: uuidv4(),
projectId: projectId,
datasetItemId: datasetItemId,
traceId: uuidv4(),
datasetRunId: datasetRunIds[index],
})),
});
await createDatasetRunItemsCh(
datasetItemIds.map((datasetItemId, index) =>
createDatasetRunItem({
dataset_item_id: datasetItemId,
dataset_run_id: datasetRunIds[index],
trace_id: uuidv4(),
project_id: projectId,
dataset_id: datasetIds[index],
}),
),
);
});
describe("GET datasetItems.countAll", () => {
it("should GET all dataset run items with no filter", async () => {
const { totalCount } = await getDatasetRunItemsTableCountPg({
const count = await getDatasetRunItemsCountCh({
projectId: projectId,
filter: [],
});
expect(totalCount).toBe(2);
expect(count).toBe(2);
});
it("should GET all dataset run items with filter", async () => {
const { totalCount } = await getDatasetRunItemsTableCountPg({
const count = await getDatasetRunItemsCountCh({
projectId: projectId,
filter: generateFilter([datasetIds[0]]),
});
expect(totalCount).toBe(1);
expect(count).toBe(1);
});
});
});
@@ -1,4 +1,7 @@
import { checkTraceExists, createTracesCh } from "@langfuse/shared/src/server";
import {
checkTraceExistsAndGetTimestamp,
createTracesCh,
} from "@langfuse/shared/src/server";
import {
getTraceById,
getTracesBySessionId,
@@ -215,7 +218,7 @@ describe("Clickhouse Traces Repository Test", () => {
await createTracesCh([trace]);
await createObservationsCh(observations);
const exists = await checkTraceExists({
const { exists } = await checkTraceExistsAndGetTimestamp({
projectId,
traceId,
timestamp: new Date(),
@@ -270,7 +273,7 @@ describe("Clickhouse Traces Repository Test", () => {
await createTracesCh([trace]);
await createObservationsCh(observations);
const exists = await checkTraceExists({
const { exists } = await checkTraceExistsAndGetTimestamp({
projectId,
traceId,
timestamp: new Date(),
@@ -324,7 +327,7 @@ describe("Clickhouse Traces Repository Test", () => {
await createTracesCh([trace]);
await createObservationsCh(observations);
const exists = await checkTraceExists({
const { exists } = await checkTraceExistsAndGetTimestamp({
projectId,
traceId,
timestamp: new Date(),
@@ -371,7 +374,7 @@ describe("Clickhouse Traces Repository Test", () => {
await createTracesCh([trace]);
await createObservationsCh(observations);
const exists = await checkTraceExists({
const { exists } = await checkTraceExistsAndGetTimestamp({
projectId,
traceId,
timestamp: new Date(),
@@ -388,7 +391,7 @@ describe("Clickhouse Traces Repository Test", () => {
expect(exists).toBe(true);
});
it("should handle timestamp filter in checkTraceExists", async () => {
it("should handle timestamp filter in checkTraceExistsAndGetTimestamp", async () => {
const traceId = v4();
const trace = createTrace({
id: traceId,
@@ -402,7 +405,7 @@ describe("Clickhouse Traces Repository Test", () => {
await createTracesCh([trace]);
const exists = await checkTraceExists({
const { exists } = await checkTraceExistsAndGetTimestamp({
projectId,
traceId,
timestamp: new Date(),
@@ -1097,7 +1097,164 @@ describe("OTel Resource Span Mapping", () => {
expect(langfuseEvents).toHaveLength(2);
});
it("should interpret openinference LLM calls as a generation", async () => {
it.each([
["CHAIN", "chain-create"],
["RETRIEVER", "retriever-create"],
["LLM", "generation-create"],
["EMBEDDING", "embedding-create"],
["AGENT", "agent-create"],
["TOOL", "tool-create"],
["GUARDRAIL", "guardrail-create"],
["EVALUATOR", "evaluator-create"],
["", "span-create"],
["UnknownKind", "span-create"],
])(
"should map OpenInference %s span kind to %s event",
async (spanKind, expectedEventType) => {
const resourceSpan = {
scopeSpans: [
{
spans: [
{
...defaultSpanProps,
attributes: [
{
key: "openinference.span.kind",
value: { stringValue: spanKind },
},
],
},
],
},
],
};
// When
const langfuseEvents = await convertOtelSpanToIngestionEvent(
resourceSpan,
new Set(),
);
// Then
expect(langfuseEvents).toHaveLength(2); // Should create trace + observation
expect(
langfuseEvents.some((event) => event.type === expectedEventType),
).toBe(true);
// Verify the observation has the correct type
const observationEvent = langfuseEvents.find(
(event) =>
event.type.endsWith("-create") && event.type !== "trace-create",
);
expect(observationEvent?.type).toBe(expectedEventType);
},
);
it.each([
["chat", "generation-create"],
["completion", "generation-create"],
["generate_content", "generation-create"],
["generate", "generation-create"],
["embeddings", "embedding-create"],
["invoke_agent", "agent-create"],
["create_agent", "agent-create"],
["execute_tool", "tool-create"],
])(
"should map OTel GenAI %s operation to %s event",
async (operationName, expectedEventType) => {
const resourceSpan = {
scopeSpans: [
{
spans: [
{
...defaultSpanProps,
attributes: [
{
key: "gen_ai.operation.name",
value: { stringValue: operationName },
},
],
},
],
},
],
};
// When
const langfuseEvents = await convertOtelSpanToIngestionEvent(
resourceSpan,
new Set(),
);
// Then
expect(langfuseEvents).toHaveLength(2); // Should create trace + observation
expect(
langfuseEvents.some((event) => event.type === expectedEventType),
).toBe(true);
// Verify the observation has the correct type
const observationEvent = langfuseEvents.find(
(event) =>
event.type.endsWith("-create") && event.type !== "trace-create",
);
expect(observationEvent?.type).toBe(expectedEventType);
},
);
it.each([
["ai.generateText", "generation-create"],
["ai.generateText.doGenerate", "generation-create"],
["ai.streamText", "generation-create"],
["ai.streamText.doStream", "generation-create"],
["ai.generateObject", "generation-create"],
["ai.generateObject.doGenerate", "generation-create"],
["ai.streamObject", "generation-create"],
["ai.streamObject.doStream", "generation-create"],
["ai.embed", "embedding-create"],
["ai.embed.doEmbed", "embedding-create"],
["ai.embedMany", "embedding-create"],
["ai.embedMany.doEmbed", "embedding-create"],
["ai.toolCall", "tool-create"],
])(
"should map AI SDK %s operation to %s event",
async (operationName, expectedEventType) => {
const resourceSpan = {
scopeSpans: [
{
spans: [
{
...defaultSpanProps,
attributes: [
{
key: "operation.name",
value: { stringValue: operationName },
},
],
},
],
},
],
};
// When
const langfuseEvents = await convertOtelSpanToIngestionEvent(
resourceSpan,
new Set(),
);
// Then
expect(langfuseEvents).toHaveLength(2); // Should create trace + observation
expect(
langfuseEvents.some((event) => event.type === expectedEventType),
).toBe(true);
// Verify the observation has the correct type
const observationEvent = langfuseEvents.find(
(event) =>
event.type.endsWith("-create") && event.type !== "trace-create",
);
expect(observationEvent?.type).toBe(expectedEventType);
},
);
it("should prioritize OpenInference over OTel GenAI and model detection", async () => {
const resourceSpan = {
scopeSpans: [
{
@@ -1107,7 +1264,15 @@ describe("OTel Resource Span Mapping", () => {
attributes: [
{
key: "openinference.span.kind",
value: { stringValue: "LLM" },
value: { stringValue: "TOOL" }, // Should be tool-create
},
{
key: "gen_ai.operation.name", // Would normally trigger generation
value: { stringValue: "chat" },
},
{
key: "gen_ai.request.model", // Would normally trigger generation
value: { stringValue: "gpt-4" },
},
],
},
@@ -1123,10 +1288,157 @@ describe("OTel Resource Span Mapping", () => {
);
// Then
// Check that we create a generation
expect(langfuseEvents).toHaveLength(2);
// Should be tool-create, NOT generation-create (OpenInference takes priority)
expect(langfuseEvents.some((event) => event.type === "tool-create")).toBe(
true,
);
expect(
langfuseEvents.some((event) => event.type === "generation-create"),
).toBe(false);
});
it("should prioritize OTel GenAI over model-based detection", async () => {
const resourceSpan = {
scopeSpans: [
{
spans: [
{
...defaultSpanProps,
attributes: [
{
key: "gen_ai.operation.name",
value: { stringValue: "embeddings" }, // Should be embedding-create
},
{
key: "gen_ai.request.model", // Would normally trigger generation
value: { stringValue: "gpt-4" },
},
],
},
],
},
],
};
// When
const langfuseEvents = await convertOtelSpanToIngestionEvent(
resourceSpan,
new Set(),
);
// Then
expect(langfuseEvents).toHaveLength(2);
// Should be embedding-create, NOT generation-create (OTel GenAI takes priority over model detection)
expect(
langfuseEvents.some((event) => event.type === "embedding-create"),
).toBe(true);
expect(
langfuseEvents.some((event) => event.type === "generation-create"),
).toBe(false);
});
it("should trust Langfuse type over OpenInference or model detection", async () => {
const resourceSpan = {
scopeSpans: [
{
spans: [
{
...defaultSpanProps,
attributes: [
// Explicit Langfuse type (should always win)
{
key: "langfuse.observation.type",
value: { stringValue: "span" },
},
// OpenInference span kind
{
key: "openinference.span.kind",
value: { stringValue: "Agent" },
},
// Model indicators
{
key: "gen_ai.request.model",
value: { stringValue: "gpt-4" },
},
],
},
],
},
],
};
// When
const langfuseEvents = await convertOtelSpanToIngestionEvent(
resourceSpan,
new Set(),
);
// Then
expect(langfuseEvents).toHaveLength(2);
// Explicit Langfuse type should always win over inferred types
expect(langfuseEvents.some((event) => event.type === "span-create")).toBe(
true,
);
expect(
langfuseEvents.some((event) => event.type === "agent-create"),
).toBe(false);
expect(
langfuseEvents.some((event) => event.type === "generation-create"),
).toBe(false);
});
it("should trust OpenInference over model detection but keep model attributes", async () => {
const resourceSpan = {
scopeSpans: [
{
spans: [
{
...defaultSpanProps,
attributes: [
// OpenInference span kind (should take priority over model detection)
{
key: "openinference.span.kind",
value: { stringValue: "RETRIEVER" },
},
// Model indicators (would be fallback)
{
key: "gen_ai.request.model",
value: { stringValue: "text-embedding-ada-002" },
},
{
key: "gen_ai.usage.input_tokens",
value: { intValue: { low: 50, high: 0, unsigned: false } },
},
],
},
],
},
],
};
// When
const langfuseEvents = await convertOtelSpanToIngestionEvent(
resourceSpan,
new Set(),
);
// Then
expect(langfuseEvents).toHaveLength(2);
// OpenInference should win over model detection
expect(
langfuseEvents.some((event) => event.type === "retriever-create"),
).toBe(true);
expect(
langfuseEvents.some((event) => event.type === "generation-create"),
).toBe(false);
// Should still extract model and usage info
const retrieverEvent = langfuseEvents.find(
(event) => event.type === "retriever-create",
);
expect(retrieverEvent?.body.model).toBe("text-embedding-ada-002");
expect(retrieverEvent?.body.usageDetails.input).toBe(50);
});
it("should use logfire.msg as span name", async () => {
@@ -3474,5 +3786,132 @@ describe("OTel Resource Span Mapping", () => {
expect(generationEvents[0].body.name).toBe("test-llm-call");
expect(generationEvents[0].body.model).toBe("gpt-4");
});
it("should default to span-create when no mapper can handle the attributes", async () => {
const otelSpans = [
{
resource: { attributes: [] },
scopeSpans: [
{
scope: { name: "test-scope" },
spans: [
{
traceId: {
data: [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
],
},
spanId: {
data: [1, 2, 3, 4, 5, 6, 7, 8],
},
name: "unknown-operation",
startTimeUnixNano: 1000000000,
endTimeUnixNano: 2000000000,
attributes: [
// No openinference.span.kind, no model indicators, no explicit type
{
key: "custom.attribute",
value: { stringValue: "some-value" },
},
{
key: "service.name",
value: { stringValue: "my-service" },
},
{
key: "operation.type",
value: { stringValue: "unknown" },
},
],
status: {},
},
],
},
],
},
];
const events = await convertOtelSpanToIngestionEvent(
otelSpans[0],
new Set(),
publicKey,
);
// Should create a span-create event (default when no mapping found)
const spanEvents = events.filter((e) => e.type === "span-create");
expect(spanEvents.length).toBe(1);
expect(spanEvents[0].body.name).toBe("unknown-operation");
// Should not create any generation-create or other typed events
const nonSpanEvents = events.filter(
(e) => e.type !== "span-create" && e.type !== "trace-create",
);
expect(nonSpanEvents.length).toBe(0);
// Should still create a trace
const traceEvents = events.filter((e) => e.type === "trace-create");
expect(traceEvents.length).toBe(1);
});
it("should override the observation type if it is declared as 'span' but holds generation-like attributes", async () => {
// Issue: https://github.com/langfuse/langfuse/issues/8682
const otelSpans = [
{
resource: { attributes: [] },
scopeSpans: [
{
scope: { name: "test-scope" },
spans: [
{
traceId: {
data: [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
],
},
spanId: {
data: [1, 2, 3, 4, 5, 6, 7, 8],
},
name: "unknown-operation",
startTimeUnixNano: 1000000000,
endTimeUnixNano: 2000000000,
attributes: [
// No openinference.span.kind, no model indicators, no explicit type
{
key: "langfuse.observation.type",
value: { stringValue: "span" },
},
{
key: "langfuse.observation.model.name",
value: { stringValue: "gpt-4o" },
},
],
status: {},
},
],
},
],
},
];
const events = await convertOtelSpanToIngestionEvent(
otelSpans[0],
new Set(),
publicKey,
);
// Should create a span-create event (default when no mapping found)
const spanEvents = events.filter((e) => e.type === "generation-create");
expect(spanEvents.length).toBe(1);
expect(spanEvents[0].body.name).toBe("unknown-operation");
// Should not create any span-create or other typed events
const nonSpanEvents = events.filter(
(e) => e.type !== "generation-create" && e.type !== "trace-create",
);
expect(nonSpanEvents.length).toBe(0);
// Should still create a trace
const traceEvents = events.filter((e) => e.type === "trace-create");
expect(traceEvents.length).toBe(1);
});
});
});
+2 -2
View File
@@ -70,7 +70,7 @@ const iconVariants = cva(cn("h-4 w-4"), {
SPAN: "text-muted-blue",
AGENT: "text-purple-600",
TOOL: "text-orange-600",
CHAIN: "text-indigo-600",
CHAIN: "text-violet-600",
RETRIEVER: "text-teal-600",
EMBEDDING: "text-amber-600",
GUARDRAIL: "text-red-600",
@@ -82,7 +82,7 @@ const iconVariants = cva(cn("h-4 w-4"), {
DATASET_ITEM: "text-primary-accent",
ANNOTATION_QUEUE: "text-primary-accent",
PROMPT: "text-primary-accent",
EVALUATOR: "text-primary-accent",
EVALUATOR: "text-primary-accent", // usually text-indigo-600
RUNNING_EVALUATOR: "text-primary-accent",
},
},
+1 -1
View File
@@ -184,5 +184,5 @@ function TablePeekViewComponent<TData>(props: TablePeekViewProps<TData>) {
}
export const TablePeekView = memo(TablePeekViewComponent, (prev, next) => {
return prev.selectedRowId === next.selectedRowId;
return prev.selectedRowId === next.selectedRowId && !!prev.row && !!next.row;
}) as typeof TablePeekViewComponent;
@@ -265,8 +265,12 @@ export default function ObservationsTable({
orderBy: orderByState,
};
const generations = api.generations.all.useQuery(getAllPayload);
const totalCountQuery = api.generations.countAll.useQuery(getCountPayload);
const generations = api.generations.all.useQuery(getAllPayload, {
refetchOnWindowFocus: true,
});
const totalCountQuery = api.generations.countAll.useQuery(getCountPayload, {
refetchOnWindowFocus: true,
});
const totalCount = totalCountQuery.data?.totalCount ?? null;
@@ -171,8 +171,12 @@ export default function SessionsTable({
limit: paginationState.pageSize,
};
const sessions = api.sessions.all.useQuery(payloadGetAll);
const sessionCountQuery = api.sessions.countAll.useQuery(payloadCount);
const sessions = api.sessions.all.useQuery(payloadGetAll, {
refetchOnWindowFocus: true,
});
const sessionCountQuery = api.sessions.countAll.useQuery(payloadCount, {
refetchOnWindowFocus: true,
});
const addToQueueMutation = api.annotationQueueItems.createMany.useMutation({
onSuccess: (data) => {
@@ -202,6 +206,7 @@ export default function SessionsTable({
},
{
enabled: sessions.data !== undefined,
refetchOnWindowFocus: true,
},
);
@@ -256,7 +256,7 @@ export default function TracesTable({
const traces = api.traces.all.useQuery(tracesAllQueryFilter, {
enabled: environmentFilterOptions.data !== undefined,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnWindowFocus: true,
});
const traceMetrics = api.traces.metrics.useQuery(
@@ -268,7 +268,7 @@ export default function TracesTable({
{
enabled: traces.data !== undefined,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnWindowFocus: true,
},
);
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.101.0";
export const VERSION = "v3.104.0";
@@ -29,6 +29,7 @@ import {
orderBy,
StringNoHTML,
InvalidRequestError,
singleFilter,
} from "@langfuse/shared";
import { throwIfNoProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { executeQuery } from "@/src/features/query/server/queryExecutor";
@@ -74,6 +75,13 @@ const CloneDashboardInput = z.object({
dashboardId: z.string(),
});
// Update dashboard filters input schema
const UpdateDashboardFiltersInput = z.object({
projectId: z.string(),
dashboardId: z.string(),
filters: z.array(singleFilter),
});
export const dashboardRouter = createTRPCRouter({
chart: protectedProjectProcedure
.input(
@@ -318,6 +326,25 @@ export const dashboardRouter = createTRPCRouter({
return clonedDashboard;
}),
updateDashboardFilters: protectedProjectProcedure
.input(UpdateDashboardFiltersInput)
.mutation(async ({ ctx, input }) => {
throwIfNoProjectAccess({
session: ctx.session,
projectId: input.projectId,
scope: "dashboards:CUD",
});
const dashboard = await DashboardService.updateDashboardFilters(
input.dashboardId,
input.projectId,
input.filters,
ctx.session.user.id,
);
return dashboard;
}),
// Delete dashboard input schema
delete: protectedProjectProcedure
.input(
@@ -183,19 +183,26 @@ function DatasetCompareRunsTableInternal(props: {
)
) {
const newCount = prevCount + 1;
return { ...prev, [runId]: newCount };
// Only update if the count actually changed
if (prev[runId] !== newCount) {
return { ...prev, [runId]: newCount };
}
return prev;
}
return { ...prev, [runId]: 0 };
// Only reset to 0 if it wasn't already 0
if (prev[runId] !== 0) {
return { ...prev, [runId]: 0 };
}
return prev;
});
},
[queryClient, runQueries],
);
// 3. Use the queries with success callback
const runs = runQueries.map(({ runId }) => ({
runId,
items: api.datasets.runitemsByRunIdOrItemId.useQuery(
const runs = runQueries.map(({ runId }) => {
const query = api.datasets.runitemsByRunIdOrItemId.useQuery(
{
projectId: props.projectId,
datasetRunId: runId,
@@ -214,26 +221,29 @@ function DatasetCompareRunsTableInternal(props: {
unchangedCounts,
),
},
),
}));
);
const runStatusDeps = useMemo(
return { runId, items: query };
});
// Create stable dependency for useEffect
const runStatesKey = useMemo(
() =>
runs.map((r) => ({
runId: r.runId,
isSuccess: r.items.isSuccess,
dataHash: JSON.stringify(r.items.data),
})),
runs
.map((r) => `${r.runId}-${r.items.isSuccess}-${r.items.dataUpdatedAt}`)
.join("|"),
[runs],
);
// Handle success callbacks for all queries - replaces `onSuccess`
useEffect(() => {
runs.forEach(({ runId, items }) => {
if (items.isSuccess && items.data) {
handleQuerySuccess(runId, items.data);
}
});
}, [runs, runStatusDeps, handleQuerySuccess]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [runStatesKey, handleQuerySuccess]);
const combinedData = useMemo(() => {
if (!baseDatasetItems.data) return null;
@@ -14,7 +14,12 @@ import {
import { useQueryParams, withDefault, NumberParam } from "use-query-params";
import { Archive, Edit, ListTree, MoreVertical, Trash2 } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import { type DatasetItem, DatasetStatus, type Prisma } from "@langfuse/shared";
import {
type DatasetItem,
datasetItemFilterColumns,
DatasetStatus,
type Prisma,
} from "@langfuse/shared";
import { type LangfuseColumnDef } from "@/src/components/table/types";
import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context";
import { useEffect, useState } from "react";
@@ -32,6 +37,9 @@ import { UploadDatasetCsv } from "@/src/features/datasets/components/UploadDatas
import { LocalIsoDate } from "@/src/components/LocalIsoDate";
import { BatchExportTableButton } from "@/src/components/BatchExportTableButton";
import { BatchExportTableName } from "@langfuse/shared";
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
import { useDebounce } from "@/src/hooks/useDebounce";
import { useFullTextSearch } from "@/src/components/table/use-cases/useFullTextSearch";
type RowData = {
id: string;
@@ -71,13 +79,30 @@ export function DatasetItemsTable({
"s",
);
const [filterState, setFilterState] = useQueryFilterState(
[],
"dataset_items",
projectId,
);
const { searchQuery, searchType, setSearchQuery, setSearchType } =
useFullTextSearch();
const hasAccess = useHasProjectAccess({ projectId, scope: "datasets:CUD" });
const items = api.datasets.itemsByDatasetId.useQuery({
projectId,
datasetId,
filter: filterState,
page: paginationState.pageIndex,
limit: paginationState.pageSize,
searchQuery: searchQuery ?? undefined,
searchType: searchType,
});
const totalDatasetItemCount = api.datasets.countItemsByDatasetId.useQuery({
projectId,
datasetId,
});
useEffect(() => {
@@ -341,11 +366,17 @@ export function DatasetItemsTable({
/>
);
if (items.data?.totalDatasetItems === 0 && hasAccess) {
const setFilterStateWithDebounce = useDebounce(setFilterState);
const setSearchQueryWithDebounce = useDebounce(setSearchQuery, 300);
if (totalDatasetItemCount.data === 0 && hasAccess) {
return (
<>
<DataTableToolbar
columns={columns}
filterColumnDefinition={datasetItemFilterColumns}
filterState={filterState}
setFilterState={setFilterStateWithDebounce}
columnVisibility={columnVisibility}
setColumnVisibility={setColumnVisibility}
columnOrder={columnOrder}
@@ -353,6 +384,20 @@ export function DatasetItemsTable({
rowHeight={rowHeight}
setRowHeight={setRowHeight}
actionButtons={[menuItems, batchExportButton].filter(Boolean)}
searchConfig={{
metadataSearchFields: ["ID"],
updateQuery: setSearchQueryWithDebounce,
currentQuery: searchQuery ?? undefined,
// Disable full text search as we don't have any dataset items added to the dataset yet.
tableAllowsFullTextSearch: false,
setSearchType,
searchType,
customDropdownLabels: {
metadata: "IDs",
fullText: "Full Text",
},
hidePerformanceWarning: true,
}}
/>
{preview ? (
<PreviewCsvImport
@@ -374,6 +419,9 @@ export function DatasetItemsTable({
<>
<DataTableToolbar
columns={columns}
filterColumnDefinition={datasetItemFilterColumns}
filterState={filterState}
setFilterState={setFilterStateWithDebounce}
columnVisibility={columnVisibility}
setColumnVisibility={setColumnVisibility}
columnOrder={columnOrder}
@@ -381,6 +429,19 @@ export function DatasetItemsTable({
rowHeight={rowHeight}
setRowHeight={setRowHeight}
actionButtons={[menuItems, batchExportButton].filter(Boolean)}
searchConfig={{
metadataSearchFields: ["ID"],
updateQuery: setSearchQueryWithDebounce,
currentQuery: searchQuery ?? undefined,
tableAllowsFullTextSearch: true,
setSearchType,
searchType,
customDropdownLabels: {
metadata: "IDs",
fullText: "Full Text",
},
hidePerformanceWarning: true,
}}
/>
<DataTable
tableName={"datasetItems"}
@@ -381,6 +381,13 @@ export function DatasetRunsTable(props: {
id: "countRunItems",
size: 90,
enableHiding: true,
cell: ({ row }) => {
const countRunItems: DatasetRunRowData["countRunItems"] =
row.getValue("countRunItems");
if (countRunItems === undefined || runsMetrics.isPending)
return <Skeleton className="h-3 w-1/2" />;
return <>{countRunItems}</>;
},
},
{
accessorKey: "avgLatency",
@@ -391,7 +398,8 @@ export function DatasetRunsTable(props: {
cell: ({ row }) => {
const avgLatency: DatasetRunRowData["avgLatency"] =
row.getValue("avgLatency");
if (avgLatency === undefined) return <Skeleton className="h-3 w-1/2" />;
if (avgLatency === undefined || runsMetrics.isPending)
return <Skeleton className="h-3 w-1/2" />;
return <>{formatIntervalSeconds(avgLatency)}</>;
},
},
@@ -404,7 +412,8 @@ export function DatasetRunsTable(props: {
cell: ({ row }) => {
const avgTotalCost: DatasetRunRowData["avgTotalCost"] =
row.getValue("avgTotalCost");
if (!avgTotalCost) return <Skeleton className="h-3 w-1/2" />;
if (!avgTotalCost || runsMetrics.isPending)
return <Skeleton className="h-3 w-1/2" />;
return <>{avgTotalCost}</>;
},
},
@@ -29,14 +29,12 @@ export const UploadDatasetCsvButton = (props: {
});
const capture = usePostHogClientCapture();
const items = api.datasets.itemsByDatasetId.useQuery({
const itemCount = api.datasets.countItemsByDatasetId.useQuery({
projectId: props.projectId,
datasetId: props.datasetId,
page: 0,
limit: 50,
});
if (hasAccess && items.data?.totalDatasetItems === 0) {
if (hasAccess && itemCount.data === 0) {
return null;
}
+253 -452
View File
@@ -13,14 +13,12 @@ import {
singleFilter,
StringNoHTML,
StringNoHTMLNonEmpty,
type ScoreAggregate,
type FilterState,
isPresent,
TracingSearchType,
} from "@langfuse/shared";
import { TRPCError } from "@trpc/server";
import {
createDatasetRunsTable,
createDatasetRunsTableWithoutMetrics,
datasetRunsTableSchema,
fetchDatasetItems,
getRunItemsByRunIdOrItemId,
@@ -28,9 +26,6 @@ import {
import {
logger,
getRunScoresGroupedByNameSourceType,
getDatasetRunItemsTableCountPg,
executeWithDatasetRunItemsStrategy,
DatasetRunItemsOperationType,
addToDeleteDatasetQueue,
getDatasetRunItemsByDatasetIdCh,
getDatasetRunItemsCountByDatasetIdCh,
@@ -45,43 +40,6 @@ import {
aggregateScores,
composeAggregateScoreKey,
} from "@/src/features/scores/lib/aggregateScores";
import { type Decimal } from "decimal.js";
type RunItemTableRow = {
id: string;
traceId: string;
observationId: string | null;
createdAt: Date;
updatedAt: Date;
datasetItemCreatedAt: Date;
datasetItemId: string;
projectId: string;
datasetRunId: string;
datasetRunName: string;
};
type RunItemsByIdQueryResult = {
totalRunItems: number;
runItems: Array<{
datasetRunName: string;
id: string;
createdAt: Date;
datasetItemId: string;
observation:
| {
id: string;
latency: number;
calculatedTotalCost: Decimal;
}
| undefined;
trace: {
id: string;
duration: number;
totalCost: number;
};
scores: ScoreAggregate;
}>;
};
const formatDatasetItemData = (data: string | null | undefined) => {
if (data === "") return Prisma.DbNull;
@@ -256,25 +214,11 @@ export const datasetRouter = createTRPCRouter({
}),
)
.query(async ({ input }) => {
return await executeWithDatasetRunItemsStrategy({
input,
operationType: DatasetRunItemsOperationType.READ,
postgresExecution: async () => {
const count = await getDatasetRunItemsTableCountPg({
projectId: input.projectId,
filter: input.filter ?? [],
});
return count;
},
clickhouseExecution: async () => {
const count = await getDatasetRunItemsCountCh({
projectId: input.projectId,
filter: input.filter ?? [],
});
return { totalCount: count };
},
const count = await getDatasetRunItemsCountCh({
projectId: input.projectId,
filter: input.filter ?? [],
});
return { totalCount: count };
}),
byId: protectedProjectProcedure
.input(
@@ -329,193 +273,128 @@ export const datasetRouter = createTRPCRouter({
runsByDatasetId: protectedProjectProcedure
.input(datasetRunsTableSchema)
.query(async ({ input, ctx }) => {
return await executeWithDatasetRunItemsStrategy({
input,
operationType: DatasetRunItemsOperationType.READ,
postgresExecution: async (queryInput: typeof input) => {
// we cannot easily join all the tracing data with the dataset run items
// hence, we pull the trace_ids and observation_ids separately for all run items
// afterwards, we aggregate them per run
const runs = await createDatasetRunsTableWithoutMetrics(queryInput);
const [runs, totalRuns] = await Promise.all([
await ctx.prisma.datasetRuns.findMany({
where: {
datasetId: input.datasetId,
projectId: input.projectId,
},
orderBy: {
createdAt: "desc",
},
take: input.limit,
skip:
isPresent(input.page) && isPresent(input.limit)
? input.page * input.limit
: undefined,
}),
// dataset run items will continue to be stored in postgres
await ctx.prisma.datasetRuns.count({
where: {
datasetId: input.datasetId,
projectId: input.projectId,
},
}),
]);
const totalRuns = await ctx.prisma.datasetRuns.count({
where: {
datasetId: queryInput.datasetId,
projectId: queryInput.projectId,
},
});
return {
totalRuns,
runs,
};
},
clickhouseExecution: async (queryInput: typeof input) => {
const [runs, totalRuns] = await Promise.all([
await ctx.prisma.datasetRuns.findMany({
where: {
datasetId: queryInput.datasetId,
projectId: queryInput.projectId,
},
orderBy: {
createdAt: "desc",
},
take: queryInput.limit,
skip:
isPresent(queryInput.page) && isPresent(queryInput.limit)
? queryInput.page * queryInput.limit
: undefined,
}),
// dataset run items will continue to be stored in postgres
await ctx.prisma.datasetRuns.count({
where: {
datasetId: queryInput.datasetId,
projectId: queryInput.projectId,
},
}),
]);
return {
totalRuns,
runs,
};
},
});
return {
totalRuns,
runs,
};
}),
runsByDatasetIdMetrics: protectedProjectProcedure
.input(datasetRunsTableSchema)
.query(async ({ input, ctx }) => {
return await executeWithDatasetRunItemsStrategy({
input,
operationType: DatasetRunItemsOperationType.READ,
postgresExecution: async (queryInput: typeof input) => {
// we cannot easily join all the tracing data with the dataset run items
// hence, we pull the trace_ids and observation_ids separately for all run items
// afterwards, we aggregate them per run
const runs = await createDatasetRunsTable(queryInput);
const totalRuns = await ctx.prisma.datasetRuns.count({
where: {
datasetId: queryInput.datasetId,
projectId: queryInput.projectId,
},
});
return {
totalRuns,
runs: runs.map((r) => ({
id: r.id,
projectId: r.projectId,
name: r.name,
description: r.description,
metadata: r.metadata,
createdAt: r.createdAt,
datasetId: r.datasetId,
countRunItems: r.countRunItems,
avgTotalCost: r.avgTotalCost,
avgLatency: r.avgLatency,
scores: r.scores,
runScores: r.runScores,
})),
};
},
clickhouseExecution: async (queryInput: typeof input) => {
// Get all runs from PostgreSQL and merge with ClickHouse metrics to maintain consistent count
const [runsWithMetrics, totalRuns, allRunsBasicInfo] =
await Promise.all([
// Get runs that have metrics (only runs with dataset_run_items_rmt)
getDatasetRunsTableMetricsCh({
projectId: queryInput.projectId,
datasetId: queryInput.datasetId,
limit: queryInput.limit,
offset:
isPresent(queryInput.page) && isPresent(queryInput.limit)
? queryInput.page * queryInput.limit
: undefined,
}),
// Count all runs (including those without dataset_run_items_rmt)
ctx.prisma.datasetRuns.count({
where: {
datasetId: queryInput.datasetId,
projectId: queryInput.projectId,
},
}),
// Get basic info for all runs to ensure we return all runs, even those without dataset_run_items_rmt
ctx.prisma.datasetRuns.findMany({
where: {
datasetId: queryInput.datasetId,
projectId: queryInput.projectId,
},
select: {
id: true,
name: true,
description: true,
metadata: true,
createdAt: true,
datasetId: true,
projectId: true,
},
...(isPresent(queryInput.limit) && {
take: queryInput.limit,
}),
...(isPresent(queryInput.page) &&
isPresent(queryInput.limit) && {
skip: queryInput.page * queryInput.limit,
}),
orderBy: {
createdAt: "desc",
},
}),
]);
// Create lookup map for runs that have metrics
const metricsLookup = new Map<string, DatasetRunsMetrics>(
runsWithMetrics.map((run) => [run.id, run]),
);
// Only fetch scores for runs that have metrics (runs without dataset_run_items_rmt won't have trace scores)
const runsWithMetricsIds = runsWithMetrics.map((run) => run.id);
const [traceScores, runScores] = await Promise.all([
runsWithMetricsIds.length > 0
? getTraceScoresForDatasetRuns(
queryInput.projectId,
runsWithMetricsIds,
)
: [],
getScoresForDatasetRuns({
projectId: queryInput.projectId,
runIds: allRunsBasicInfo.map((run) => run.id),
includeHasMetadata: true,
excludeMetadata: false,
// Get all runs from PostgreSQL and merge with ClickHouse metrics to maintain consistent count
const [runsWithMetrics, totalRuns, allRunsBasicInfo] = await Promise.all([
// Get runs that have metrics (only runs with dataset_run_items_rmt)
getDatasetRunsTableMetricsCh({
projectId: input.projectId,
datasetId: input.datasetId,
limit: input.limit,
offset:
isPresent(input.page) && isPresent(input.limit)
? input.page * input.limit
: undefined,
}),
// Count all runs (including those without dataset_run_items_rmt)
ctx.prisma.datasetRuns.count({
where: {
datasetId: input.datasetId,
projectId: input.projectId,
},
}),
// Get basic info for all runs to ensure we return all runs, even those without dataset_run_items_rmt
ctx.prisma.datasetRuns.findMany({
where: {
datasetId: input.datasetId,
projectId: input.projectId,
},
select: {
id: true,
name: true,
description: true,
metadata: true,
createdAt: true,
datasetId: true,
projectId: true,
},
...(isPresent(input.limit) && {
take: input.limit,
}),
...(isPresent(input.page) &&
isPresent(input.limit) && {
skip: input.page * input.limit,
}),
]);
orderBy: {
createdAt: "desc",
},
}),
]);
// Merge all runs: use metrics where available, defaults otherwise
const allRuns = allRunsBasicInfo.map((run) => {
const metrics = metricsLookup.get(run.id);
// Create lookup map for runs that have metrics
const metricsLookup = new Map<string, DatasetRunsMetrics>(
runsWithMetrics.map((run) => [run.id, run]),
);
return {
...run,
// Use ClickHouse metrics if available, otherwise use defaults for runs without dataset_run_items_rmt
countRunItems: metrics?.countRunItems ?? 0,
avgTotalCost: metrics?.avgTotalCost ?? null,
avgLatency: metrics?.avgLatency ?? null,
scores: aggregateScores(
traceScores.filter((s) => s.datasetRunId === run.id),
),
runScores: aggregateScores(
runScores.filter((s) => s.datasetRunId === run.id),
),
};
});
// Only fetch scores for runs that have metrics (runs without dataset_run_items_rmt won't have trace scores)
const runsWithMetricsIds = runsWithMetrics.map((run) => run.id);
const [traceScores, runScores] = await Promise.all([
runsWithMetricsIds.length > 0
? getTraceScoresForDatasetRuns(input.projectId, runsWithMetricsIds)
: [],
getScoresForDatasetRuns({
projectId: input.projectId,
runIds: allRunsBasicInfo.map((run) => run.id),
includeHasMetadata: true,
excludeMetadata: false,
}),
]);
return {
totalRuns,
runs: allRuns,
};
},
// Merge all runs: use metrics where available, defaults otherwise
const allRuns = allRunsBasicInfo.map((run) => {
const metrics = metricsLookup.get(run.id);
return {
...run,
// Use ClickHouse metrics if available, otherwise use defaults for runs without dataset_run_items_rmt
countRunItems: metrics?.countRunItems ?? 0,
avgTotalCost: metrics?.avgTotalCost ?? null,
avgLatency: metrics?.avgLatency ?? null,
scores: aggregateScores(
traceScores.filter((s) => s.datasetRunId === run.id),
),
runScores: aggregateScores(
runScores.filter((s) => s.datasetRunId === run.id),
),
};
});
return {
totalRuns,
runs: allRuns,
};
}),
itemById: protectedProjectProcedure
.input(
@@ -533,11 +412,24 @@ export const datasetRouter = createTRPCRouter({
},
});
}),
countItemsByDatasetId: protectedProjectProcedure
.input(z.object({ projectId: z.string(), datasetId: z.string() }))
.query(async ({ input, ctx }) => {
return await ctx.prisma.datasetItem.count({
where: {
datasetId: input.datasetId,
projectId: input.projectId,
},
});
}),
itemsByDatasetId: protectedProjectProcedure
.input(
z.object({
projectId: z.string(),
datasetId: z.string(),
filter: z.array(singleFilter).nullish(),
searchQuery: z.string().optional(),
searchType: z.array(TracingSearchType).optional(),
...paginationZod,
}),
)
@@ -545,9 +437,12 @@ export const datasetRouter = createTRPCRouter({
return await fetchDatasetItems({
projectId: input.projectId,
datasetId: input.datasetId,
filter: input.filter ?? [],
limit: input.limit,
page: input.page,
prisma: ctx.prisma,
searchQuery: input.searchQuery,
searchType: input.searchType,
});
}),
baseDatasetItemByDatasetId: protectedProjectProcedure
@@ -746,17 +641,10 @@ export const datasetRouter = createTRPCRouter({
},
});
await executeWithDatasetRunItemsStrategy({
input,
operationType: DatasetRunItemsOperationType.WRITE,
postgresExecution: async () => {},
clickhouseExecution: async (queryInput: typeof input) => {
await addToDeleteDatasetQueue({
deletionType: "dataset",
projectId: queryInput.projectId,
datasetId: deletedDataset.id,
});
},
await addToDeleteDatasetQueue({
deletionType: "dataset",
projectId: input.projectId,
datasetId: deletedDataset.id,
});
await auditLog({
@@ -1062,212 +950,132 @@ export const datasetRouter = createTRPCRouter({
),
)
.query(async ({ input, ctx }) => {
return await executeWithDatasetRunItemsStrategy({
input,
operationType: DatasetRunItemsOperationType.READ,
postgresExecution: async (
queryInput: typeof input,
): Promise<RunItemsByIdQueryResult> => {
const { datasetRunId, datasetItemId } = queryInput;
const { datasetRunId, datasetItemId, datasetId } = input;
const filterQuery =
datasetRunId && datasetItemId
? Prisma.sql`AND (dri.dataset_run_id = ${datasetRunId} OR dri.dataset_item_id = ${datasetItemId})`
: datasetRunId
? Prisma.sql`AND dri.dataset_run_id = ${datasetRunId}`
: datasetItemId
? Prisma.sql`AND dri.dataset_item_id = ${datasetItemId}`
: Prisma.sql``;
const filter = [
...(datasetRunId
? [
{
column: "datasetRunId",
operator: "any of",
value: [datasetRunId],
type: "stringOptions" as const,
},
]
: []),
...(datasetItemId
? [
{
column: "datasetItemId",
operator: "any of",
value: [datasetItemId],
type: "stringOptions" as const,
},
]
: []),
] as FilterState;
const runItems = await ctx.prisma.$queryRaw<Array<RunItemTableRow>>`
SELECT
di.id AS "datasetItemId",
di.created_at AS "datasetItemCreatedAt",
dri.id,
dri.trace_id AS "traceId",
dri.observation_id AS "observationId",
dri.created_at AS "createdAt",
dri.updated_at AS "updatedAt",
dri.project_id AS "projectId",
dri.dataset_run_id AS "datasetRunId",
dr.name AS "datasetRunName"
FROM dataset_run_items dri
INNER JOIN dataset_items di
ON dri.dataset_item_id = di.id
AND dri.project_id = di.project_id
INNER JOIN dataset_runs dr
ON dri.dataset_run_id = dr.id
AND dri.project_id = dr.project_id
WHERE
dri.project_id = ${input.projectId}
${filterQuery}
ORDER BY
di.created_at DESC,
di.id DESC
LIMIT ${input.limit}
OFFSET ${input.page * input.limit}
`;
if (runItems.length === 0) return { totalRunItems: 0, runItems: [] };
let finalDatasetId: string | undefined = datasetId;
const totalRunItems = await ctx.prisma.datasetRunItems.count({
if (!finalDatasetId) {
if (datasetRunId) {
const datasetRun = await ctx.prisma.datasetRuns.findFirst({
where: {
id: datasetRunId,
projectId: input.projectId,
datasetRunId: input.datasetRunId,
datasetItemId: input.datasetItemId,
},
});
// Add scores to the run items while also keeping the datasetRunName
const runItemNameMap = runItems.reduce(
(map, item) => {
map[item.id] = item.datasetRunName;
return map;
},
{} as Record<string, string>,
);
const parsedRunItems = (
await getRunItemsByRunIdOrItemId(input.projectId, runItems)
).map((ri) => ({
...ri,
datasetRunName: runItemNameMap[ri.id],
}));
// Note: We early return in case of no run items, when adding parameters here, make sure to update the early return above
return {
totalRunItems,
runItems: parsedRunItems,
};
},
clickhouseExecution: async (
queryInput: typeof input,
): Promise<RunItemsByIdQueryResult> => {
const { datasetRunId, datasetItemId, datasetId } = queryInput;
const filter = [
...(datasetRunId
? [
{
column: "datasetRunId",
operator: "any of",
value: [datasetRunId],
type: "stringOptions" as const,
},
]
: []),
...(datasetItemId
? [
{
column: "datasetItemId",
operator: "any of",
value: [datasetItemId],
type: "stringOptions" as const,
},
]
: []),
] as FilterState;
let finalDatasetId: string | undefined = datasetId;
if (!finalDatasetId) {
if (datasetRunId) {
const datasetRun = await ctx.prisma.datasetRuns.findFirst({
where: {
id: datasetRunId,
projectId: queryInput.projectId,
},
});
if (!datasetRun) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Dataset run not found",
});
}
finalDatasetId = datasetRun?.datasetId;
} else if (datasetItemId) {
const datasetItem = await ctx.prisma.datasetItem.findFirst({
where: {
id: datasetItemId,
projectId: queryInput.projectId,
},
});
if (!datasetItem) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Dataset item not found",
});
}
finalDatasetId = datasetItem?.datasetId;
}
}
if (!finalDatasetId) {
if (!datasetRun) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Dataset not found",
message: "Dataset run not found",
});
}
const [runItems, totalRunItems] = await Promise.all([
getDatasetRunItemsByDatasetIdCh({
projectId: queryInput.projectId,
datasetId: finalDatasetId,
filter,
// ensure consistent ordering with datasets.baseDatasetItemByDatasetId
// CH run items are created in reverse order as postgres execution path
// can be refactored once we switch to CH only implementation
orderBy: [
{
column: "createdAt",
order: "ASC",
},
{ column: "datasetItemId", order: "DESC" },
],
limit: queryInput.limit,
offset: queryInput.page * queryInput.limit,
}),
getDatasetRunItemsCountByDatasetIdCh({
projectId: queryInput.projectId,
datasetId: finalDatasetId,
filter,
}),
]);
const runItemNameMap = runItems.reduce(
(map, item) => {
map[item.id] = item.datasetRunName;
return map;
finalDatasetId = datasetRun?.datasetId;
} else if (datasetItemId) {
const datasetItem = await ctx.prisma.datasetItem.findFirst({
where: {
id: datasetItemId,
projectId: input.projectId,
},
{} as Record<string, string>,
);
});
if (!datasetItem) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Dataset item not found",
});
}
const enrichedRunItems = (
await getRunItemsByRunIdOrItemId(
queryInput.projectId,
runItems.map((runItem) => ({
id: runItem.id,
traceId: runItem.traceId,
observationId: runItem.observationId,
createdAt: runItem.createdAt,
updatedAt: runItem.updatedAt,
projectId: runItem.projectId,
datasetRunId: runItem.datasetRunId,
datasetItemId: runItem.datasetItemId,
})),
)
).map((runItem) => ({
...runItem,
datasetRunName: runItemNameMap[runItem.id],
}));
finalDatasetId = datasetItem?.datasetId;
}
}
// Note: We early return in case of no run items, when adding parameters here, make sure to update the early return above
return {
totalRunItems,
runItems: enrichedRunItems,
};
if (!finalDatasetId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Dataset not found",
});
}
const [runItems, totalRunItems] = await Promise.all([
getDatasetRunItemsByDatasetIdCh({
projectId: input.projectId,
datasetId: finalDatasetId,
filter,
// ensure consistent ordering with datasets.baseDatasetItemByDatasetId
// CH run items are created in reverse order as postgres execution path
// can be refactored once we switch to CH only implementation
orderBy: [
{
column: "createdAt",
order: "ASC",
},
{ column: "datasetItemId", order: "DESC" },
],
limit: input.limit,
offset: input.page * input.limit,
}),
getDatasetRunItemsCountByDatasetIdCh({
projectId: input.projectId,
datasetId: finalDatasetId,
filter,
}),
]);
const runItemNameMap = runItems.reduce(
(map, item) => {
map[item.id] = item.datasetRunName;
return map;
},
});
{} as Record<string, string>,
);
const enrichedRunItems = (
await getRunItemsByRunIdOrItemId(
input.projectId,
runItems.map((runItem) => ({
id: runItem.id,
traceId: runItem.traceId,
observationId: runItem.observationId,
createdAt: runItem.createdAt,
updatedAt: runItem.updatedAt,
projectId: runItem.projectId,
datasetRunId: runItem.datasetRunId,
datasetItemId: runItem.datasetItemId,
})),
)
).map((runItem) => ({
...runItem,
datasetRunName: runItemNameMap[runItem.id],
}));
// Note: We early return in case of no run items, when adding parameters here, make sure to update the early return above
return {
totalRunItems,
runItems: enrichedRunItems,
};
}),
datasetItemsBasedOnTraceOrObservation: protectedProjectProcedure
.input(
@@ -1332,21 +1140,14 @@ export const datasetRouter = createTRPCRouter({
},
});
await executeWithDatasetRunItemsStrategy({
input,
operationType: DatasetRunItemsOperationType.WRITE,
postgresExecution: async () => {},
clickhouseExecution: async () => {
// Trigger async delete of dataset run items
await addToDeleteDatasetQueue({
deletionType: "dataset-runs",
projectId: input.projectId,
// temporary: while dataset id is optional, we can pull it from the first run
// users can only use this on pages in UI that are pre-filtered by dataset id
datasetId: input.datasetId ?? datasetRuns[0].datasetId,
datasetRunIds: input.datasetRunIds,
});
},
// Trigger async delete of dataset run items
await addToDeleteDatasetQueue({
deletionType: "dataset-runs",
projectId: input.projectId,
// temporary: while dataset id is optional, we can pull it from the first run
// users can only use this on pages in UI that are pre-filtered by dataset id
datasetId: input.datasetId ?? datasetRuns[0].datasetId,
datasetRunIds: input.datasetRunIds,
});
// Log audit entries for each deleted run
+136 -466
View File
@@ -4,28 +4,21 @@ import {
type PrismaClient,
type DatasetRunItems,
optionalPaginationZod,
type FilterState,
datasetItemFilterColumns,
type DatasetItem,
type TracingSearchType,
} from "@langfuse/shared";
import { prisma } from "@langfuse/shared/src/db";
import { z } from "zod/v4";
import {
clickhouseClient,
clickhouseCompliantRandomCharacters,
commandClickhouse,
convertToScore,
getLatencyAndTotalCostForObservations,
getLatencyAndTotalCostForObservationsByTraces,
getObservationsById,
getScoresForDatasetRuns,
getScoresForTraces,
getTracesByIds,
logger,
queryClickhouse,
type ScoreRecordReadType,
tableColumnsToSqlFilterAndPrefix,
traceException,
} from "@langfuse/shared/src/server";
import { aggregateScores } from "@/src/features/scores/lib/aggregateScores";
import Decimal from "decimal.js";
import { env } from "@/src/env.mjs";
export const datasetRunsTableSchema = z.object({
projectId: z.string(),
@@ -34,475 +27,152 @@ export const datasetRunsTableSchema = z.object({
...optionalPaginationZod,
});
type PostgresRunItem = {
trace_id: string;
observation_id: string;
ri_id: string;
};
type PostgresDatasetRun = {
run_id: string;
run_name: string;
run_description: string;
run_metadata: Prisma.JsonValue;
run_created_at: Date;
run_updated_at: Date;
run_items: PostgresRunItem[];
};
export type DatasetRunsTableInput = z.infer<typeof datasetRunsTableSchema>;
export const createDatasetRunsTableWithoutMetrics = async (
input: DatasetRunsTableInput,
) => {
const runs = await getDatasetRunsFromPostgres(input);
return runs.map(({ run_items, ...run }) => ({
...run,
projectId: input.projectId,
datasetId: input.datasetId,
id: run.run_id,
countRunItems: run_items.length,
name: run.run_name,
description: run.run_description,
metadata: run.run_metadata,
createdAt: run.run_created_at,
updatedAt: run.run_updated_at,
// return metric fields as undefined
avgTotalCost: undefined as Decimal | undefined,
avgLatency: undefined as number | undefined,
scores: undefined,
}));
};
// we might have many traces / observations in Postgres which belong to data in clickhouse.
// We need to create a temp table in CH, dump the data in there, and then join in CH.
export const createDatasetRunsTable = async (input: DatasetRunsTableInput) => {
const tableName = `dataset_runs_${clickhouseCompliantRandomCharacters()}`;
try {
const runs = await getDatasetRunsFromPostgres(input);
await createTempTableInClickhouse(tableName);
await insertPostgresDatasetRunsIntoClickhouse(
runs,
tableName,
input.projectId,
input.datasetId,
);
// these calls need to happen sequentially as there can be only one active session with
// the same session_id at the time.
const traceScores = await getTraceScoresFromTempTable(input, tableName);
const runScores = await getScoresForDatasetRuns({
projectId: input.projectId,
runIds: runs.map((r) => r.run_id),
includeHasMetadata: true,
excludeMetadata: false,
});
const obsAgg = await getObservationLatencyAndCostForDataset(
input,
tableName,
);
const traceAgg = await getTraceLatencyAndCostForDataset(input, tableName);
const enrichedRuns = runs.map(({ run_items, ...run }) => {
const observation = obsAgg.find((o) => o.runId === run.run_id);
const trace = traceAgg.find((t) => t.runId === run.run_id);
return {
...run,
projectId: input.projectId,
datasetId: input.datasetId,
id: run.run_id,
avgTotalCost: trace?.cost
? new Decimal(trace.cost)
: observation?.cost
? new Decimal(observation.cost)
: new Decimal(0),
countRunItems: run_items.length,
name: run.run_name,
description: run.run_description,
metadata: run.run_metadata,
createdAt: run.run_created_at,
updatedAt: run.run_updated_at,
avgLatency: trace?.latency ?? observation?.latency ?? 0,
scores: aggregateScores(
traceScores.filter((s) => s.run_id === run.run_id),
),
// check this one
runScores: aggregateScores(
runScores.filter((s) => s.datasetRunId === run.run_id),
),
};
});
return enrichedRuns;
} catch (e) {
logger.error("Failed to fetch dataset runs from clickhouse", e);
throw e;
} finally {
await deleteTempTableInClickhouse(tableName);
}
};
const insertPostgresDatasetRunsIntoClickhouse = async (
runs: PostgresDatasetRun[],
tableName: string,
projectId: string,
datasetId: string,
) => {
const rows = runs.flatMap((run) =>
run.run_items.map((item) => ({
project_id: projectId,
run_id: run.run_id,
run_item_id: item.ri_id,
dataset_id: datasetId,
trace_id: item.trace_id,
observation_id: item.observation_id,
})),
);
await clickhouseClient().insert({
table: tableName,
values: rows,
format: "JSONEachRow",
clickhouse_settings: {
log_comment: JSON.stringify({ feature: "dataset", projectId }),
insert_quorum_parallel: 0,
insert_quorum: "auto",
},
});
};
const createTempTableInClickhouse = async (tableName: string) => {
const query = `
CREATE TABLE IF NOT EXISTS ${tableName} ${env.CLICKHOUSE_CLUSTER_ENABLED === "true" ? "ON CLUSTER " + env.CLICKHOUSE_CLUSTER_NAME : ""}
(
project_id String,
run_id String,
run_item_id String,
dataset_id String,
trace_id String,
observation_id Nullable(String)
)
ENGINE = ${env.CLICKHOUSE_CLUSTER_ENABLED === "true" ? "ReplicatedMergeTree()" : "MergeTree()"}
PRIMARY KEY (project_id, dataset_id, run_id, trace_id)
`;
await commandClickhouse({
query,
params: { tableName },
tags: { feature: "dataset" },
});
};
const deleteTempTableInClickhouse = async (tableName: string) => {
const query = `
DROP TABLE IF EXISTS ${tableName} ${env.CLICKHOUSE_CLUSTER_ENABLED === "true" ? "ON CLUSTER " + env.CLICKHOUSE_CLUSTER_NAME : ""}
`;
await commandClickhouse({
query,
params: { tableName },
tags: { feature: "dataset" },
});
};
const getDatasetRunsFromPostgres = async (input: DatasetRunsTableInput) => {
return await prisma.$queryRaw<PostgresDatasetRun[]>(
Prisma.sql`
SELECT
runs.id as run_id,
runs.name as run_name,
runs.description as run_description,
runs.metadata as run_metadata,
runs.created_at as run_created_at,
runs.updated_at as run_updated_at,
JSON_AGG(JSON_BUILD_OBJECT(
'trace_id', ri.trace_id,
'observation_id', ri.observation_id,
'ri_id', ri.id
)) AS run_items
FROM
datasets d
JOIN dataset_runs runs ON d.id = runs.dataset_id AND d.project_id = runs.project_id
LEFT JOIN dataset_run_items ri ON ri.dataset_run_id = runs.id
AND ri.project_id = runs.project_id
WHERE
d.id = ${input.datasetId}
AND d.project_id = ${input.projectId}
${input.runIds?.length ? Prisma.sql`AND runs.id IN (${Prisma.join(input.runIds)})` : Prisma.empty}
GROUP BY runs.id, runs.name, runs.description, runs.metadata, runs.created_at, runs.updated_at
ORDER BY runs.created_at DESC
${input.limit ? Prisma.sql`LIMIT ${input.limit}` : Prisma.empty}
${input.page && input.limit ? Prisma.sql`OFFSET ${input.page * input.limit}` : Prisma.empty}
`,
);
};
const getTraceScoresFromTempTable = async (
input: DatasetRunsTableInput,
tableName: string,
) => {
// adds a setting to read data once it is replicated from the writer node.
// Only then, we can guarantee that the created mergetree before was replicated.
const query = `
SELECT
s.* EXCEPT (metadata),
length(mapKeys(s.metadata)) > 0 AS has_metadata,
tmp.run_id
FROM ${tableName} tmp JOIN scores s
ON tmp.project_id = s.project_id
AND tmp.trace_id = s.trace_id
WHERE s.project_id = {projectId: String}
AND tmp.project_id = {projectId: String}
AND tmp.dataset_id = {datasetId: String}
ORDER BY s.event_ts DESC
LIMIT 1 BY s.id, s.project_id, tmp.run_id
`;
const rows = await queryClickhouse<
ScoreRecordReadType & {
run_id: string;
// has_metadata is 0 or 1 from ClickHouse, later converted to a boolean
has_metadata: 0 | 1;
}
>({
query: query,
params: {
projectId: input.projectId,
datasetId: input.datasetId,
},
clickhouseConfigs: {
clickhouse_settings: {
select_sequential_consistency: "1",
},
},
tags: { feature: "dataset", projectId: input.projectId },
});
return rows.map((row) => ({
...convertToScore({ ...row, metadata: {} }),
run_id: row.run_id,
hasMetadata: !!row.has_metadata,
}));
};
const getObservationLatencyAndCostForDataset = async (
input: DatasetRunsTableInput,
tableName: string,
) => {
// the subquery here will improve performance as it allows clickhouse to use skip-indices on
// the observations table
const query = `
WITH agg AS (
SELECT
dateDiff('millisecond', start_time, end_time) AS latency_ms,
total_cost AS cost,
run_id
FROM observations AS o
INNER JOIN ${tableName} AS tmp ON (o.id = tmp.observation_id) AND (o.project_id = tmp.project_id) AND (tmp.trace_id = o.trace_id)
WHERE
o.project_id = {projectId: String}
AND (id, trace_id) IN (
SELECT
observation_id,
trace_id
FROM ${tableName}
WHERE (project_id = {projectId: String}) AND (dataset_id = {datasetId: String}) AND (observation_id IS NOT NULL)
)
)
SELECT
run_id,
avg(latency_ms) as avg_latency_ms,
avg(cost) as avg_total_cost
FROM agg
GROUP BY run_id
`;
const rows = await queryClickhouse<{
run_id: string;
avg_latency_ms: string;
avg_total_cost: string;
}>({
query: query,
params: {
projectId: input.projectId,
datasetId: input.datasetId,
},
clickhouseConfigs: {
clickhouse_settings: {
select_sequential_consistency: "1",
},
},
tags: { feature: "dataset", projectId: input.projectId ?? "" },
});
return rows.map((row) => ({
runId: row.run_id,
latency: Number(row.avg_latency_ms) / 1000,
cost: Number(row.avg_total_cost),
}));
};
const getTraceLatencyAndCostForDataset = async (
input: DatasetRunsTableInput,
tableName: string,
) => {
const query = `
WITH agg AS (
SELECT
o.trace_id,
run_id,
dateDiff('millisecond', min(start_time), max(end_time)) AS latency_ms,
sum(total_cost) AS cost
FROM observations o JOIN ${tableName} tmp
ON tmp.project_id = o.project_id
AND tmp.trace_id = o.trace_id
WHERE o.project_id = {projectId: String}
AND tmp.project_id = {projectId: String}
AND tmp.dataset_id = {datasetId: String}
AND tmp.observation_id IS NULL
GROUP BY o.trace_id, run_id
)
SELECT
run_id,
avg(latency_ms) as avg_latency_ms,
avg(cost) as avg_total_cost
FROM agg
GROUP BY run_id
`;
const rows = await queryClickhouse<{
run_id: string;
avg_latency_ms: string;
avg_total_cost: string;
}>({
query: query,
params: {
projectId: input.projectId,
datasetId: input.datasetId,
},
clickhouseConfigs: {
clickhouse_settings: {
select_sequential_consistency: "1",
},
},
tags: { feature: "dataset", projectId: input.projectId ?? "" },
});
return rows.map((row) => ({
runId: row.run_id,
latency: Number(row.avg_latency_ms) / 1000,
cost: Number(row.avg_total_cost),
}));
};
export type DatasetRunItemsTableInput = {
projectId: string;
datasetId: string;
limit: number;
page: number;
prisma: PrismaClient;
filter: FilterState;
searchQuery?: string;
searchType?: TracingSearchType[];
};
type DatasetItemsByDatasetIdQuery = {
select: "rows" | "count";
projectId: string;
datasetId: string;
filter: FilterState;
limit: number;
page: number;
searchFilter?: Prisma.Sql;
};
const generateDatasetItemQuery = ({
select,
projectId,
datasetId,
filter,
limit,
page,
searchFilter = Prisma.empty,
}: DatasetItemsByDatasetIdQuery) => {
const filterCondition = tableColumnsToSqlFilterAndPrefix(
filter,
datasetItemFilterColumns,
"dataset_items",
);
let selectClause: Prisma.Sql;
switch (select) {
case "rows":
selectClause = Prisma.sql`
di.id as "id",
di.project_id as "projectId",
di.dataset_id as "datasetId",
di.status as "status",
di.created_at as "createdAt",
di.updated_at as "updatedAt",
di.source_trace_id as "sourceTraceId",
di.source_observation_id as "sourceObservationId",
di.input as "input",
di.expected_output as "expectedOutput",
di.metadata as "metadata"
`;
break;
case "count":
selectClause = Prisma.sql`count(*) AS "totalCount"`;
break;
default:
throw new Error(`Unknown select type: ${select}`);
}
const orderByClause =
select === "rows"
? Prisma.sql`
ORDER BY di.status ASC, di.created_at DESC, di.id DESC
`
: Prisma.empty;
return Prisma.sql`
SELECT ${selectClause}
FROM dataset_items di
WHERE di.project_id = ${projectId}
AND di.dataset_id = ${datasetId}
${filterCondition}
${searchFilter}
${orderByClause}
LIMIT ${limit} OFFSET ${page * limit}
`;
};
const buildDatasetItemSearchFilter = (
searchQuery: string | undefined | null,
searchType?: TracingSearchType[],
): Prisma.Sql => {
if (searchQuery === undefined || searchQuery === null || searchQuery === "") {
return Prisma.empty;
}
const q = searchQuery;
const types = searchType ?? ["content"];
const searchConditions: Prisma.Sql[] = [];
if (types.includes("id")) {
searchConditions.push(Prisma.sql`di.id ILIKE ${`%${q}%`}`);
}
if (types.includes("content")) {
searchConditions.push(Prisma.sql`di.input::text ILIKE ${`%${q}%`}`);
searchConditions.push(
Prisma.sql`di.expected_output::text ILIKE ${`%${q}%`}`,
);
searchConditions.push(Prisma.sql`di.metadata::text ILIKE ${`%${q}%`}`);
}
return searchConditions.length > 0
? Prisma.sql` AND (${Prisma.join(searchConditions, " OR ")})`
: Prisma.empty;
};
export const fetchDatasetItems = async (input: DatasetRunItemsTableInput) => {
const dataset = await input.prisma.dataset.findUnique({
where: {
id_projectId: {
id: input.datasetId,
projectId: input.projectId,
},
},
include: {
datasetItems: {
orderBy: [
{
status: "asc",
},
{
createdAt: "desc",
},
{
id: "desc",
},
],
take: input.limit,
skip: input.page * input.limit,
},
},
});
const datasetItems = dataset?.datasetItems ?? [];
const totalDatasetItems = await input.prisma.datasetItem.count({
where: {
dataset: {
id: input.datasetId,
projectId: input.projectId,
},
projectId: input.projectId,
},
});
// check in clickhouse if the traces already exist. They arrive delayed.
const traces = await getTracesByIds(
datasetItems
.map((item) => item.sourceTraceId)
.filter((id): id is string => Boolean(id)),
input.projectId,
const searchFilter = buildDatasetItemSearchFilter(
input.searchQuery,
input.searchType,
);
const observations = await getObservationsById(
datasetItems
.map((item) => item.sourceObservationId)
.filter((id): id is string => Boolean(id)),
input.projectId,
);
const tracingData = {
traceIds: traces.map((t) => t.id),
observationIds: observations.map((o) => ({
id: o.id,
traceId: o.traceId,
})),
};
const [datasetItems, countDatasetItems] = await Promise.all([
// datasetItems
input.prisma.$queryRaw<Array<DatasetItem>>(
generateDatasetItemQuery({
select: "rows",
projectId: input.projectId,
datasetId: input.datasetId,
filter: input.filter,
limit: input.limit,
page: input.page,
searchFilter,
}),
),
// countDatasetItems
input.prisma.$queryRaw<Array<{ totalCount: bigint }>>(
generateDatasetItemQuery({
select: "count",
projectId: input.projectId,
datasetId: input.datasetId,
filter: input.filter,
limit: 1,
page: 0,
searchFilter,
}),
),
]);
return {
totalDatasetItems,
datasetItems: datasetItems.map((item) => {
if (!item.sourceTraceId) {
return {
...item,
sourceTraceId: null,
sourceObservationId: null,
};
}
const traceIdExists = tracingData.traceIds.includes(item.sourceTraceId);
const observationIdExists = tracingData.observationIds.some(
(obs) =>
obs.id === item.sourceObservationId &&
obs.traceId === item.sourceTraceId,
);
if (observationIdExists) {
return {
...item,
sourceTraceId: item.sourceTraceId,
sourceObservationId: item.sourceObservationId,
};
} else if (traceIdExists) {
return {
...item,
sourceTraceId: item.sourceTraceId,
sourceObservationId: null,
};
} else {
return {
...item,
sourceTraceId: null,
sourceObservationId: null,
};
}
}),
totalDatasetItems: Number(countDatasetItems[0].totalCount),
datasetItems: datasetItems,
};
};
@@ -509,7 +509,7 @@ export const InnerEvaluatorForm = (props: {
value="dataset"
disabled={props.disabled || props.mode === "edit"}
>
Experiment runs
Dataset runs
</TabsTrigger>
</TabsList>
</Tabs>
@@ -222,7 +222,7 @@ export const TemplateSelector = ({
)}
{isInactive && (
<div
title="The evaluator has been used in the past but is currently paused. It will not run in this experiment. You can reactivate it if you wish"
title="The evaluator has been used in the past but is currently paused. It will not run against outputs created in this dataset run. You can reactivate it if you wish"
className="ml-2 text-xs text-muted-foreground"
>
Paused
@@ -297,7 +297,7 @@ export const TemplateSelector = ({
)}
{isInactive && (
<div
title="The evaluator has been used in the past but is currently paused. It will not run in this experiment. You can reactivate it if you wish"
title="The evaluator has been used in the past but is currently paused. It will not run against outputs created in this dataset run. You can reactivate it if you wish"
className="ml-2 text-xs text-muted-foreground"
>
Paused
@@ -98,10 +98,10 @@ export const CreateExperimentsForm = ({
return (
<>
<DialogHeader>
<DialogTitle>Run Experiment on Dataset</DialogTitle>
<DialogTitle>Start Dataset Run</DialogTitle>
<DialogDescription>
Experiments allow to test iterations of your application or prompt
on a dataset. Learn more about datasets and experiments{" "}
Dataset runs allow to test iterations of your application or prompt
on a dataset. Learn more about dataset runs{" "}
<Link
href="https://langfuse.com/docs/evaluation/dataset-runs/datasets"
target="_blank"
@@ -118,7 +118,7 @@ export const CreateExperimentsForm = ({
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Wand2 className="size-4" />
Prompt Experiment
via User Interface
</CardTitle>
<CardDescription>
Test single prompts and model configurations via Langfuse UI
@@ -136,7 +136,7 @@ export const CreateExperimentsForm = ({
className="w-full"
onClick={() => setShowPromptForm(true)}
>
Create
Configure
</Button>
<Button
variant="outline"
@@ -157,15 +157,15 @@ export const CreateExperimentsForm = ({
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Code2 className="size-4" />
Custom Experiment
via SDK / API
</CardTitle>
<CardDescription>
Run any experiment via the Langfuse SDKs
Start any dataset run via the Langfuse SDKs
</CardDescription>
</CardHeader>
<CardContent>
<ul className="list-disc space-y-2 pl-4 text-sm text-muted-foreground">
<li>Full control over experiment execution</li>
<li>Full control over dataset run execution</li>
<li>Custom evaluation logic</li>
<li>Integration with your codebase</li>
</ul>
@@ -207,7 +207,7 @@ export const CreateExperimentsForm = ({
{!existingRemoteExperiment.data && (
<Button
variant="outline"
title="Set up remote experiment in UI trigger"
title="Set up remote dataset run in UI trigger"
className="h-8 w-8 flex-shrink-0"
size="icon"
onClick={() => setShowRemoteExperimentUpsertForm(true)}
@@ -208,7 +208,7 @@ export const PromptExperimentsForm = ({
onSuccess: handleExperimentSuccess ?? (() => {}),
onError: (error) => {
showErrorToast(
error.message || "Failed to trigger experiment run",
error.message || "Failed to trigger dataset run",
"Please try again.",
);
},
@@ -273,9 +273,9 @@ export const PromptExperimentsForm = ({
Back
</Button>
)}
<DialogTitle>New Prompt Experiment</DialogTitle>
<DialogTitle>New Dataset Run</DialogTitle>
<DialogDescription>
Create an experiment to test a prompt version on a dataset. See{" "}
Start a dataset run to test a prompt version on a dataset. See{" "}
<Link
href="https://langfuse.com/docs/evaluation/dataset-runs/native-run"
target="_blank"
@@ -294,7 +294,7 @@ export const PromptExperimentsForm = ({
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Experiment name (optional)</FormLabel>
<FormLabel>Dataset run name (optional)</FormLabel>
<FormControl>
<Input {...field} type="string" />
</FormControl>
@@ -560,7 +560,7 @@ export const PromptExperimentsForm = ({
<FormItem>
<FormLabel>Evaluators</FormLabel>
<FormDescription>
Will run against your experiment results.
Will run against the LLM outputs
</FormDescription>
<TemplateSelector
projectId={projectId}
@@ -639,7 +639,7 @@ export const PromptExperimentsForm = ({
))}
</ul>
Items missing all required variables and placeholders will
be excluded from the experiment.
be excluded from the dataset run.
</div>
</CardHeader>
</Card>
@@ -658,7 +658,7 @@ export const PromptExperimentsForm = ({
}
loading={form.formState.isSubmitting}
>
Create
Start
</Button>
</div>
</DialogFooter>
@@ -72,21 +72,21 @@ export const RemoteExperimentTriggerModal = ({
onSuccess: (data) => {
if (data.success) {
showSuccessToast({
title: "Experiment started",
description: "Your experiment may take a few minutes to complete.",
title: "Dataset run started",
description: "Your dataset run may take a few minutes to complete.",
});
} else {
showErrorToast(
"Failed to start experiment",
"Please try again or check your remote experiment configuration.",
"Failed to start dataset run",
"Please try again or check your remote dataset run configuration.",
);
}
setShowTriggerModal(false);
},
onError: (error) => {
showErrorToast(
error.message || "Failed to start experiment",
"Please try again or check your remote experiment configuration.",
error.message || "Failed to start dataset run",
"Please try again or check your remote dataset run configuration.",
);
},
});
@@ -124,7 +124,7 @@ export const RemoteExperimentTriggerModal = ({
>
Back
</Button>
<DialogTitle>Run remote experiment</DialogTitle>
<DialogTitle>Run remote dataset run</DialogTitle>
<DialogDescription>
This action will send the following information to{" "}
<strong>{remoteExperimentConfig.url}</strong>.
@@ -142,8 +142,8 @@ export const RemoteExperimentTriggerModal = ({
<FormItem>
<FormLabel>Config</FormLabel>
<FormDescription>
Confirm the config you want to send to the remote
experiment URL along with the{" "}
Confirm the config you want to send to the remote dataset
run URL along with the{" "}
<strong>{dataset.data?.name}</strong> dataset information.
</FormDescription>
<FormControl>
@@ -97,13 +97,13 @@ export const RemoteExperimentUpsertForm = ({
showSuccessToast({
title: "Deleted successfully",
description:
"The remote experiment trigger has been removed from this dataset.",
"The remote dataset run trigger has been removed from this dataset.",
});
setShowRemoteExperimentUpsertForm(false);
},
onError: (error) => {
showErrorToast(
error.message || "Failed to delete remote experiment trigger",
error.message || "Failed to delete remote dataset run trigger",
"Please try again.",
);
},
@@ -131,7 +131,9 @@ export const RemoteExperimentUpsertForm = ({
const handleDelete = () => {
if (
confirm("Are you sure you want to delete this remote experiment trigger?")
confirm(
"Are you sure you want to delete this remote dataset run trigger?",
)
) {
deleteRemoteExperimentMutation.mutate({
projectId,
@@ -160,11 +162,11 @@ export const RemoteExperimentUpsertForm = ({
</Button>
<DialogTitle>
{existingRemoteExperiment
? "Edit remote experiment trigger"
: "Set up remote experiment trigger in UI"}
? "Edit remote dataset run trigger"
: "Set up remote dataset run trigger in UI"}
</DialogTitle>
<DialogDescription>
Enable your team to run custom experiments on dataset{" "}
Enable your team to run custom dataset runs on dataset{" "}
<strong>
{dataset.isSuccess ? (
<>&quot;{dataset.data?.name}&quot;</>
@@ -172,9 +174,9 @@ export const RemoteExperimentUpsertForm = ({
<Loader2 className="inline h-4 w-4 animate-spin" />
)}
</strong>
. Configure a webhook URL to trigger remote custom experiments from
. Configure a webhook URL to trigger remote custom dataset runs from
UI. We will send dataset info (name, id) and config to your service,
which can run experiments and post results to Langfuse.
which can run against the dataset and post results to Langfuse.
</DialogDescription>
</DialogHeader>
@@ -188,7 +190,7 @@ export const RemoteExperimentUpsertForm = ({
<FormItem>
<FormLabel>URL</FormLabel>
<FormDescription>
The URL that will be called when the remote experiment is
The URL that will be called when the remote dataset run is
triggered.
</FormDescription>
<FormControl>
@@ -209,9 +211,9 @@ export const RemoteExperimentUpsertForm = ({
<FormItem>
<FormLabel>Default config</FormLabel>
<FormDescription>
Set a default config that will be sent to the remote
experiment URL. This can be modified when running the
experiment. View docs for more details.
Set a default config that will be sent to the remote dataset
run URL. This can be modified before starting a new run.
View docs for more details.
</FormDescription>
<CodeMirrorEditor
value={field.value}
@@ -6,6 +6,7 @@ import {
singleFilter,
sessionsViewCols,
promptsTableCols,
datasetItemFilterColumns,
} from "@langfuse/shared";
import { scoresTableCols } from "@/src/server/api/definitions/scoresTable";
import {
@@ -141,6 +142,7 @@ const tableCols = {
users: usersTableCols,
eval_configs: evalConfigFilterColumns,
job_executions: evalExecutionsFilterCols,
dataset_items: datasetItemFilterColumns,
widgets: [
{ id: "environment", name: "Environment" },
{ id: "traceName", name: "Trace Name" },
@@ -0,0 +1,187 @@
import { LangfuseOtelSpanAttributes } from "./attributes";
import { type ObservationType, ObservationTypeDomain } from "@langfuse/shared";
type LangfuseObservationType = keyof typeof ObservationType;
interface ObservationTypeMapper {
readonly name: string;
readonly priority: number; // Lower numbers = higher priority
canMap(attributes: Record<string, unknown>): boolean;
mapToObservationType(
attributes: Record<string, unknown>,
): LangfuseObservationType | null;
}
/**
* Simple mapper for direct attribute key-value mappings.
*/
class SimpleAttributeMapper implements ObservationTypeMapper {
constructor(
public readonly name: string,
public readonly priority: number,
private readonly attributeKey: string,
private readonly mappings: Record<string, string>,
) {}
canMap(attributes: Record<string, unknown>): boolean {
return (
this.attributeKey in attributes && attributes[this.attributeKey] != null
);
}
mapToObservationType(
attributes: Record<string, unknown>,
): LangfuseObservationType | null {
const value = attributes[this.attributeKey] as string;
const mappedType = this.mappings[value];
if (
mappedType &&
ObservationTypeDomain.safeParse(mappedType.toUpperCase()).success
) {
return mappedType as LangfuseObservationType;
}
return null;
}
}
/**
* Mapper allowing for conditional logic, multiple attribute checks
*/
class CustomAttributeMapper implements ObservationTypeMapper {
constructor(
public readonly name: string,
public readonly priority: number,
private readonly canMapFn: (attributes: Record<string, unknown>) => boolean,
private readonly mapFn: (
attributes: Record<string, unknown>,
) => LangfuseObservationType | null,
) {}
canMap(attributes: Record<string, unknown>): boolean {
return this.canMapFn(attributes);
}
mapToObservationType(
attributes: Record<string, unknown>,
): LangfuseObservationType | null {
const result = this.mapFn(attributes);
if (
result &&
ObservationTypeDomain.safeParse(result.toUpperCase()).success
) {
return result;
}
return null;
}
}
/**
* Registry to manage observation type mappers with a unified interface to map
* span attributes to observation types.
*
* Mappers are evaluated in priority order (lower number = higher priority).
*
* **NOTE**: This is the constructor to modify if you want to add new mappings.
*/
export class ObservationTypeMapperRegistry {
private readonly mappers: ObservationTypeMapper[] = [
new SimpleAttributeMapper("OpenInference", 1, "openinference.span.kind", {
CHAIN: "CHAIN",
RETRIEVER: "RETRIEVER",
LLM: "GENERATION",
EMBEDDING: "EMBEDDING",
AGENT: "AGENT",
TOOL: "TOOL",
GUARDRAIL: "GUARDRAIL",
EVALUATOR: "EVALUATOR",
}),
new SimpleAttributeMapper(
"OTel_GenAI_Operation",
2,
"gen_ai.operation.name",
{
chat: "GENERATION",
completion: "GENERATION",
generate_content: "GENERATION",
generate: "GENERATION",
embeddings: "EMBEDDING",
invoke_agent: "AGENT",
create_agent: "AGENT",
execute_tool: "TOOL",
},
),
new SimpleAttributeMapper("Vercel_AI_SDK_Operation", 3, "operation.name", {
"ai.generateText": "GENERATION",
"ai.generateText.doGenerate": "GENERATION",
"ai.streamText": "GENERATION",
"ai.streamText.doStream": "GENERATION",
"ai.generateObject": "GENERATION",
"ai.generateObject.doGenerate": "GENERATION",
"ai.streamObject": "GENERATION",
"ai.streamObject.doStream": "GENERATION",
"ai.embed": "EMBEDDING",
"ai.embed.doEmbed": "EMBEDDING",
"ai.embedMany": "EMBEDDING",
"ai.embedMany.doEmbed": "EMBEDDING",
"ai.toolCall": "TOOL",
}),
new CustomAttributeMapper(
"ModelBased",
4,
(attributes) => {
const modelKeys = [
LangfuseOtelSpanAttributes.OBSERVATION_MODEL,
"gen_ai.request.model",
"gen_ai.response.model",
"llm.model_name",
"model",
];
return modelKeys.some((key) => attributes[key] != null);
},
() => "GENERATION",
),
];
private sortedMappersCache: ObservationTypeMapper[] | null = null;
private getSortedMappers(): ObservationTypeMapper[] {
if (!this.sortedMappersCache) {
this.sortedMappersCache = [...this.mappers].sort(
(a, b) => a.priority - b.priority,
);
}
return this.sortedMappersCache;
}
/**
* Maps span attributes to a Langfuse observation type.
* Returns null if no mapper can handle the attributes.
*/
mapToObservationType(
attributes: Record<string, unknown>,
): LangfuseObservationType | null {
const sortedMappers = this.getSortedMappers();
for (const mapper of sortedMappers) {
if (mapper.canMap(attributes)) {
const result = mapper.mapToObservationType(attributes);
if (result) {
return result;
}
}
}
return null;
}
getMappersForDebugging(): ReadonlyArray<ObservationTypeMapper> {
return [...this.mappers];
}
}
@@ -16,6 +16,7 @@ import {
} from "@langfuse/shared/src/server";
import { LangfuseOtelSpanAttributes } from "./attributes";
import { ObservationTypeMapperRegistry } from "./ObservationTypeMapper";
// Type definitions for internal processor state
interface TraceState {
@@ -85,6 +86,8 @@ interface ResourceSpan {
}>;
}
const observationTypeMapper = new ObservationTypeMapperRegistry();
/**
* Processor class that encapsulates all logic for converting OpenTelemetry
* resource spans into Langfuse ingestion events.
@@ -638,14 +641,38 @@ export class OtelIngestionProcessor {
}),
};
const observationType = attributes[
let observationType = attributes[
LangfuseOtelSpanAttributes.OBSERVATION_TYPE
] as string;
const isGeneration =
observationType === "generation" ||
Boolean(observation.model) ||
("openinference.span.kind" in attributes &&
attributes["openinference.span.kind"] === "LLM");
// If generation-like attributes are set even though observation type is span, override to 'generation'
// Issue: https://github.com/langfuse/langfuse/issues/8682
// Affected SDK versions: Python SDK <= 3.3.0
const hasGenerationAttributes = Object.keys(attributes).some((key) => {
const generationKeys: LangfuseOtelSpanAttributes[] = [
LangfuseOtelSpanAttributes.OBSERVATION_MODEL,
LangfuseOtelSpanAttributes.OBSERVATION_COST_DETAILS,
LangfuseOtelSpanAttributes.OBSERVATION_USAGE_DETAILS,
LangfuseOtelSpanAttributes.OBSERVATION_COMPLETION_START_TIME,
LangfuseOtelSpanAttributes.OBSERVATION_MODEL_PARAMETERS,
LangfuseOtelSpanAttributes.OBSERVATION_PROMPT_NAME,
LangfuseOtelSpanAttributes.OBSERVATION_PROMPT_VERSION,
];
return generationKeys.includes(key as any);
});
if (observationType === "span" && hasGenerationAttributes) {
observationType = "generation";
}
// If no explicit observation type, try mapping from various frameworks
if (!observationType) {
const mappedType = observationTypeMapper.mapToObservationType(attributes);
if (mappedType) {
observationType = mappedType.toLowerCase();
}
}
const isKnownObservationType =
observationType &&
@@ -655,7 +682,6 @@ export class OtelIngestionProcessor {
if (isKnownObservationType) {
return `${observationType.toLowerCase()}-create`;
}
if (isGeneration) return "generation-create";
return "span-create";
};
@@ -1387,7 +1413,25 @@ export class OtelIngestionProcessor {
};
const providerMetadata = attributes["ai.response.providerMetadata"];
if (providerMetadata) {
// Try reading token details from ai.usage
if (
["ai.usage.cachedInputTokens", "ai.usage.reasoningTokens"].some((k) =>
Object.keys(attributes).includes(k),
)
) {
if ("ai.usage.cachedInputTokens" in attributes) {
usageDetails["input_cached_tokens"] = JSON.parse(
attributes["ai.usage.cachedInputTokens"] as string,
).intValue;
}
if ("ai.usage.reasoningTokens" in attributes) {
usageDetails["output_reasoning_tokens"] = JSON.parse(
attributes["ai.usage.reasoningTokens"] as string,
).intValue;
}
} else if (providerMetadata) {
// Fall back to providerMetadata
const parsed = JSON.parse(providerMetadata as string);
if ("openai" in parsed) {
@@ -13,6 +13,7 @@ import { createEmptyMessage } from "@/src/components/ChatMessages/utils/createEm
import { useModelParams } from "@/src/features/playground/page/hooks/useModelParams";
import usePlaygroundCache from "@/src/features/playground/page/hooks/usePlaygroundCache";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { showErrorToast } from "@/src/features/notifications/showErrorToast";
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
import {
ChatMessageRole,
@@ -319,7 +320,7 @@ export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({
);
if (finalMessages.length === 0) {
throw new Error("Please add at least one message with content");
throw new Error("Please add at least one message with content.");
}
const leftOverVariables = extractVariables(
@@ -422,8 +423,9 @@ export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({
isStructuredOutput: Boolean(structuredOutputSchema),
});
} catch (err) {
alert(err instanceof Error ? err.message : "An error occurred");
// TODO: add error handling via toast
const errorMessage =
err instanceof Error ? err.message : "An error occurred";
showErrorToast("Error", errorMessage);
} finally {
setIsStreaming(false);
}
@@ -559,7 +561,22 @@ export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({
const handleGlobalExecute = () => {
if (!isStreamingRef.current) {
handleSubmit(true);
// Check if this window has any content at all (including placeholders)
const hasAnyContent = messages.some((message) => {
if (message.type === ChatMessageType.Placeholder) {
return true; // Placeholders are considered content
}
if (typeof message.content === "string") {
return message.content.trim().length > 0;
}
return true; // Non-string content (tool calls, etc.) is considered valid
});
if (hasAnyContent) {
// Window has content - let it execute and show any validation errors
handleSubmit(true).catch((err) => console.error(err));
}
// If no content, skip silently
}
};
@@ -591,7 +608,7 @@ export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({
handleGlobalStop,
);
};
}, [windowId, handleSubmit, registerWindow, unregisterWindow]);
}, [windowId, handleSubmit, registerWindow, unregisterWindow, messages]);
// Keep ref in sync with state for external consumers
useEffect(() => {
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import {
type PlaygroundHandle,
type WindowCoordinationReturn,
PLAYGROUND_EVENTS,
} from "../types";
import { showErrorToast } from "@/src/features/notifications/showErrorToast";
/**
* Playground window registry for coordinating actions across multiple playground windows
@@ -33,7 +34,6 @@ const playgroundEventBus = new EventTarget();
*/
export const useWindowCoordination = (): WindowCoordinationReturn => {
const [isExecutingAll, setIsExecutingAll] = useState(false);
const [executionVersion, setExecutionVersion] = useState(0);
const executionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
/**
@@ -96,44 +96,61 @@ export const useWindowCoordination = (): WindowCoordinationReturn => {
return;
}
setIsExecutingAll(true);
// Clear any existing timeout
if (executionTimeoutRef.current) {
clearTimeout(executionTimeoutRef.current);
}
// Dispatch execute-all event
// Dispatch execute-all event first
playgroundEventBus.dispatchEvent(
new CustomEvent(PLAYGROUND_EVENTS.EXECUTE_ALL),
);
// Set a timeout to reset the execution state
// This provides a fallback in case some windows don't respond
executionTimeoutRef.current = setTimeout(() => {
setIsExecutingAll(false);
}, 30000); // 30 second timeout
// Monitor execution completion
const checkExecutionCompletion = () => {
const stillExecuting = Array.from(playgroundWindowRegistry.values()).some(
// Check after a short delay if any windows started executing
setTimeout(() => {
const anyExecuting = Array.from(playgroundWindowRegistry.values()).some(
(handle) => handle.getIsStreaming(),
);
if (!stillExecuting) {
if (!anyExecuting) {
// No windows are executing - they must all be empty
showErrorToast(
"No content to execute",
"Please add at least one message with content to any window.",
);
setIsExecutingAll(false);
} else {
// At least one window is executing, set global state
setIsExecutingAll(true);
// Clear any existing timeout
if (executionTimeoutRef.current) {
clearTimeout(executionTimeoutRef.current);
executionTimeoutRef.current = null;
}
} else {
// Check again in a short interval
setTimeout(checkExecutionCompletion, 500);
}
};
// Start monitoring after a short delay to allow windows to start
setTimeout(checkExecutionCompletion, 1000);
// Set a timeout to reset the execution state
// This provides a fallback in case some windows don't respond
executionTimeoutRef.current = setTimeout(() => {
setIsExecutingAll(false);
}, 30000); // 30 second timeout
// Monitor execution completion
const checkExecutionCompletion = () => {
const stillExecuting = Array.from(
playgroundWindowRegistry.values(),
).some((handle) => handle.getIsStreaming());
if (!stillExecuting) {
setIsExecutingAll(false);
if (executionTimeoutRef.current) {
clearTimeout(executionTimeoutRef.current);
executionTimeoutRef.current = null;
}
} else {
// Check again in a short interval
setTimeout(checkExecutionCompletion, 500);
}
};
// Start monitoring after a short delay to allow windows to start
setTimeout(checkExecutionCompletion, 1000);
}
}, 500); // Check after 500ms
}, []);
/**
@@ -184,34 +201,6 @@ export const useWindowCoordination = (): WindowCoordinationReturn => {
return `Executing ${executingCount} of ${totalCount} windows`;
}, []);
/**
* Cleanup function to clear timeouts when component unmounts
* Prevents memory leaks from lingering timeouts
*/
useEffect(() => {
const handleExecutionChange = () => {
setExecutionVersion((v) => v + 1);
};
playgroundEventBus.addEventListener(
PLAYGROUND_EVENTS.WINDOW_EXECUTION_STATE_CHANGE,
handleExecutionChange,
);
return () => {
playgroundEventBus.removeEventListener(
PLAYGROUND_EVENTS.WINDOW_EXECUTION_STATE_CHANGE,
handleExecutionChange,
);
if (executionTimeoutRef.current) {
clearTimeout(executionTimeoutRef.current);
}
};
}, []);
executionVersion;
return {
registerWindow,
unregisterWindow,
@@ -11,7 +11,12 @@ import { Button } from "@/src/components/ui/button";
import { FileCode } from "lucide-react";
const PromptVar = ({ name, isValid }: { name: string; isValid: boolean }) => (
<span className={cn(isValid ? "text-primary-accent" : "text-destructive")}>
<span
className={cn(
isValid ? "text-primary-accent" : "text-destructive",
"whitespace-nowrap",
)}
>
{`{{${name}}}`}
</span>
);
@@ -197,10 +197,10 @@ export const PromptDetail = ({
void utils.datasets.baseRunDataByDatasetId.invalidate();
void utils.datasets.runsByDatasetId.invalidate();
showSuccessToast({
title: "Experiment run triggered successfully",
description: "Waiting for experiment to complete...",
title: "Dataset run triggered successfully",
description: "Waiting for dataset run to complete...",
link: {
text: "View experiment",
text: "View dataset run",
href: `/project/${projectId}/datasets/${data.datasetId}/compare?runs=${data.runId}`,
},
});
@@ -410,7 +410,7 @@ export const PromptDetail = ({
>
<FlaskConical className="h-4 w-4" />
<span className="hidden md:ml-2 md:inline">
Experiment
Dataset run
</span>
</Button>
</DialogTrigger>
@@ -6,7 +6,6 @@ import {
queryStringZod,
type DatasetRuns as DbDatasetRuns,
type DatasetItem as DbDatasetItems,
type DatasetRunItems as DbDatasetRunItems,
type Dataset as DbDataset,
removeObjectKeys,
type DatasetRunItemDomain,
@@ -87,11 +86,6 @@ export const transformDbDatasetItemToAPIDatasetItem = (
): z.infer<typeof APIDatasetItem> =>
removeObjectKeys(dbDatasetItem, ["projectId"]);
export const transformDbDatasetRunItemToAPIDatasetRunItemPg = (
dbDatasetRunItem: DbDatasetRunItems & { datasetRunName: string },
): z.infer<typeof APIDatasetRunItem> =>
removeObjectKeys(dbDatasetRunItem, ["projectId"]);
export const transformDbDatasetRunItemToAPIDatasetRunItemCh = (
dbDatasetRunItem: DatasetRunItemDomain,
): z.infer<typeof APIDatasetRunItem> =>
+9 -28
View File
@@ -3,8 +3,6 @@ import { ServerPosthog } from "@/src/features/posthog-analytics/ServerPosthog";
import { Prisma, prisma } from "@langfuse/shared/src/db";
import { v4 as uuidv4 } from "uuid";
import {
DatasetRunItemsOperationType,
executeWithDatasetRunItemsStrategy,
getDatasetRunItemCountsByProjectInCreationInterval,
getObservationCountsByProjectInCreationInterval,
getScoreCountsByProjectInCreationInterval,
@@ -229,32 +227,15 @@ async function posthogTelemetry({
},
});
const countDatasetRunItems = await executeWithDatasetRunItemsStrategy({
input: {},
operationType: DatasetRunItemsOperationType.READ,
postgresExecution: async () => {
// Count dataset run items
return await prisma.datasetRunItems.count({
where: {
createdAt: {
gte: startTimeframe?.toISOString(),
lt: endTimeframe.toISOString(),
},
},
});
},
clickhouseExecution: async () => {
const countDatasetRunItemsClickhouse =
await getDatasetRunItemCountsByProjectInCreationInterval({
start: startTimeframe ?? new Date(0),
end: endTimeframe,
});
return countDatasetRunItemsClickhouse.reduce(
(acc, curr) => acc + curr.count,
0,
);
},
});
const countDatasetRunItemsClickhouse =
await getDatasetRunItemCountsByProjectInCreationInterval({
start: startTimeframe ?? new Date(0),
end: endTimeframe,
});
const countDatasetRunItems = countDatasetRunItemsClickhouse.reduce(
(acc, curr) => acc + curr.count,
0,
);
// Domains (no PII)
const domains = await prisma.$queryRaw<Array<{ domain: string }>>`
+91 -179
View File
@@ -7,7 +7,6 @@ import {
GetDatasetRunItemsV1Response,
PostDatasetRunItemsV1Body,
PostDatasetRunItemsV1Response,
transformDbDatasetRunItemToAPIDatasetRunItemPg,
} from "@/src/features/public-api/types/datasets";
import { LangfuseNotFoundError } from "@langfuse/shared";
import { addDatasetRunItemsToEvalQueue } from "@/src/features/evals/server/addDatasetRunItemsToEvalQueue";
@@ -15,8 +14,6 @@ import {
eventTypes,
logger,
processEventBatch,
executeWithDatasetRunItemsStrategy,
DatasetRunItemsOperationType,
getObservationById,
} from "@langfuse/shared/src/server";
import { v4 } from "uuid";
@@ -92,106 +89,68 @@ export default withMiddlewares({
const runItemId = v4();
return await executeWithDatasetRunItemsStrategy({
input: body,
operationType: DatasetRunItemsOperationType.WRITE,
postgresExecution: async () => {
/********************
* RUN ITEM CREATION *
********************/
/********************
* RUN ITEM CREATION *
********************/
const runItem = await prisma.datasetRunItems.create({
data: {
id: runItemId,
datasetItemId,
traceId: finalTraceId,
observationId: observationId ?? undefined,
datasetRunId: run.id,
projectId: auth.scope.projectId,
},
});
const createdAt = new Date();
/********************
* ASYNC RUN ITEM EVAL *
********************/
await addDatasetRunItemsToEvalQueue({
projectId: auth.scope.projectId,
datasetItemId,
traceId: finalTraceId,
observationId: observationId ?? undefined,
});
return transformDbDatasetRunItemToAPIDatasetRunItemPg({
...runItem,
datasetRunName: run.name,
});
},
clickhouseExecution: async () => {
/********************
* RUN ITEM CREATION *
********************/
const createdAt = new Date();
const event = {
id: runItemId,
type: eventTypes.DATASET_RUN_ITEM_CREATE,
timestamp: new Date().toISOString(),
body: {
id: runItemId,
traceId: finalTraceId,
observationId: observationId ?? undefined,
error: null,
createdAt: createdAt.toISOString(),
datasetId: datasetItem.datasetId,
runId: run.id,
datasetItemId: datasetItem.id,
},
};
// note: currently we do not accept user defined ids for dataset run items
const ingestionResult = await processEventBatch([event], auth, {
isLangfuseInternal: true,
});
if (ingestionResult.errors.length > 0) {
const error = ingestionResult.errors[0];
res
.status(error.status)
.json({ message: error.error ?? error.message });
// We will still return the mock dataset run item in the response for now. Logs are to be monitored.
}
if (ingestionResult.successes.length !== 1) {
logger.error("Failed to create dataset run item", {
result: ingestionResult,
});
throw new Error("Failed to create dataset run item");
}
/********************
* ASYNC RUN ITEM EVAL *
********************/
await addDatasetRunItemsToEvalQueue({
projectId: auth.scope.projectId,
datasetItemId: datasetItem.id,
traceId: finalTraceId,
observationId: observationId ?? undefined,
});
const mockDatasetRunItem: APIDatasetRunItem = {
id: event.body.id,
datasetRunId: run.id,
datasetRunName: run.name,
datasetItemId: datasetItem.id,
traceId: finalTraceId,
observationId: observationId ?? null,
createdAt: createdAt,
updatedAt: createdAt,
};
return mockDatasetRunItem;
const event = {
id: runItemId,
type: eventTypes.DATASET_RUN_ITEM_CREATE,
timestamp: new Date().toISOString(),
body: {
id: runItemId,
traceId: finalTraceId,
observationId: observationId ?? undefined,
error: null,
createdAt: createdAt.toISOString(),
datasetId: datasetItem.datasetId,
runId: run.id,
datasetItemId: datasetItem.id,
},
};
// note: currently we do not accept user defined ids for dataset run items
const ingestionResult = await processEventBatch([event], auth, {
isLangfuseInternal: true,
});
if (ingestionResult.errors.length > 0) {
const error = ingestionResult.errors[0];
res
.status(error.status)
.json({ message: error.error ?? error.message });
// We will still return the mock dataset run item in the response for now. Logs are to be monitored.
}
if (ingestionResult.successes.length !== 1) {
logger.error("Failed to create dataset run item", {
result: ingestionResult,
});
throw new Error("Failed to create dataset run item");
}
/********************
* ASYNC RUN ITEM EVAL *
********************/
await addDatasetRunItemsToEvalQueue({
projectId: auth.scope.projectId,
datasetItemId: datasetItem.id,
traceId: finalTraceId,
observationId: observationId ?? undefined,
});
const mockDatasetRunItem: APIDatasetRunItem = {
id: event.body.id,
datasetRunId: run.id,
datasetRunName: run.name,
datasetItemId: datasetItem.id,
traceId: finalTraceId,
observationId: observationId ?? null,
createdAt: createdAt,
updatedAt: createdAt,
};
return mockDatasetRunItem;
},
}),
GET: createAuthedProjectAPIRoute({
@@ -224,89 +183,42 @@ export default withMiddlewares({
);
}
const res = await executeWithDatasetRunItemsStrategy({
input: query,
operationType: DatasetRunItemsOperationType.READ,
postgresExecution: async (queryInput: typeof query) => {
const datasetRunItems = await prisma.datasetRunItems.findMany({
where: {
datasetRunId: datasetRun.id,
projectId: auth.scope.projectId,
},
orderBy: {
createdAt: "desc",
},
take: queryInput.limit,
skip: (queryInput.page - 1) * queryInput.limit,
});
const { datasetId, limit, page } = query;
/**************
* RESPONSE *
**************/
const totalItems = await prisma.datasetRunItems.count({
where: {
datasetRunId: datasetRun.id,
projectId: auth.scope.projectId,
},
});
const [items, count] = await Promise.all([
generateDatasetRunItemsForPublicApi({
props: {
datasetId,
runId: datasetRun.id,
projectId: auth.scope.projectId,
limit,
page,
},
}),
getDatasetRunItemsCountForPublicApi({
props: {
datasetId,
runId: datasetRun.id,
projectId: auth.scope.projectId,
limit,
page,
},
}),
]);
/**************
* RESPONSE *
**************/
return {
data: datasetRunItems.map((runItem) =>
transformDbDatasetRunItemToAPIDatasetRunItemPg({
...runItem,
datasetRunName: datasetRun.name,
}),
),
meta: {
page: queryInput.page,
limit: queryInput.limit,
totalItems,
totalPages: Math.ceil(totalItems / queryInput.limit),
},
};
const finalCount = count || 0;
return {
data: items,
meta: {
page,
limit,
totalItems: finalCount,
totalPages: Math.ceil(finalCount / limit),
},
clickhouseExecution: async (queryInput: typeof query) => {
const { datasetId } = queryInput;
/**************
* RESPONSE *
**************/
const [items, count] = await Promise.all([
generateDatasetRunItemsForPublicApi({
props: {
datasetId,
runId: datasetRun.id,
projectId: auth.scope.projectId,
limit: queryInput.limit,
page: queryInput.page,
},
}),
getDatasetRunItemsCountForPublicApi({
props: {
datasetId,
runId: datasetRun.id,
projectId: auth.scope.projectId,
limit: queryInput.limit,
page: queryInput.page,
},
}),
]);
const finalCount = count || 0;
return {
data: items,
meta: {
page: queryInput.page,
limit: queryInput.limit,
totalItems: finalCount,
totalPages: Math.ceil(finalCount / queryInput.limit),
},
};
},
});
return res;
};
},
}),
});
@@ -4,18 +4,13 @@ import {
GetDatasetRunV1Response,
DeleteDatasetRunV1Query,
DeleteDatasetRunV1Response,
transformDbDatasetRunItemToAPIDatasetRunItemPg,
transformDbDatasetRunToAPIDatasetRun,
} from "@/src/features/public-api/types/datasets";
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
import { createAuthedProjectAPIRoute } from "@/src/features/public-api/server/createAuthedProjectAPIRoute";
import { ApiError, LangfuseNotFoundError } from "@langfuse/shared";
import { auditLog } from "@/src/features/audit-logs/auditLog";
import {
DatasetRunItemsOperationType,
executeWithDatasetRunItemsStrategy,
addToDeleteDatasetQueue,
} from "@langfuse/shared/src/server";
import { addToDeleteDatasetQueue } from "@langfuse/shared/src/server";
import { generateDatasetRunItemsForPublicApi } from "@/src/features/public-api/server/dataset-run-items";
export default withMiddlewares({
@@ -35,7 +30,6 @@ export default withMiddlewares({
},
},
include: {
datasetRunItems: true,
dataset: {
select: {
name: true,
@@ -49,45 +43,23 @@ export default withMiddlewares({
if (!datasetRuns[0])
throw new LangfuseNotFoundError("Dataset run not found");
const { dataset, datasetRunItems, ...run } = datasetRuns[0];
const { dataset, ...run } = datasetRuns[0];
const res = await executeWithDatasetRunItemsStrategy({
input: query,
operationType: DatasetRunItemsOperationType.READ,
postgresExecution: async () => {
return {
...transformDbDatasetRunToAPIDatasetRun({
...run,
datasetName: dataset.name,
}),
datasetRunItems: datasetRunItems
.map((item) => ({
...item,
datasetRunName: run.name,
}))
.map(transformDbDatasetRunItemToAPIDatasetRunItemPg),
};
},
clickhouseExecution: async () => {
const datasetRunItems = await generateDatasetRunItemsForPublicApi({
props: {
datasetId: run.datasetId,
runId: run.id,
projectId: auth.scope.projectId,
},
});
return {
...transformDbDatasetRunToAPIDatasetRun({
...run,
datasetName: dataset.name,
}),
datasetRunItems,
};
const datasetRunItems = await generateDatasetRunItemsForPublicApi({
props: {
datasetId: run.datasetId,
runId: run.id,
projectId: auth.scope.projectId,
},
});
return res;
return {
...transformDbDatasetRunToAPIDatasetRun({
...run,
datasetName: dataset.name,
}),
datasetRunItems,
};
},
}),
DELETE: createAuthedProjectAPIRoute({
@@ -138,19 +110,12 @@ export default withMiddlewares({
before: datasetRun,
});
await executeWithDatasetRunItemsStrategy({
input: query,
operationType: DatasetRunItemsOperationType.WRITE,
postgresExecution: async () => {},
clickhouseExecution: async () => {
// Trigger async delete of dataset run items
await addToDeleteDatasetQueue({
deletionType: "dataset-runs",
projectId: auth.scope.projectId,
datasetRunIds: [datasetRun.id],
datasetId: datasetRun.datasetId,
});
},
// Trigger async delete of dataset run items
await addToDeleteDatasetQueue({
deletionType: "dataset-runs",
projectId: auth.scope.projectId,
datasetRunIds: [datasetRun.id],
datasetId: datasetRun.datasetId,
});
return {
@@ -4,8 +4,7 @@ import Page from "@/src/components/layouts/page";
import { NoDataOrLoading } from "@/src/components/NoDataOrLoading";
import { DatePickerWithRange } from "@/src/components/date-picker";
import { PopoverFilterBuilder } from "@/src/features/filters/components/filter-builder";
import { useDashboardDateRange } from "@/src/hooks/useDashboardDateRange";
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState, useMemo, useCallback } from "react";
import type { ColumnDefinition, FilterState } from "@langfuse/shared";
import { Button } from "@/src/components/ui/button";
import { PlusIcon, Copy } from "lucide-react";
@@ -20,6 +19,7 @@ import { v4 as uuidv4 } from "uuid";
import { useDebounce } from "@/src/hooks/useDebounce";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { DashboardGrid } from "@/src/features/widgets/components/DashboardGrid";
import { useDashboardDateRange } from "@/src/hooks/useDashboardDateRange";
interface WidgetPlacement {
id: string;
@@ -61,10 +61,18 @@ export default function DashboardDetail() {
scope: "dashboards:CUD",
}) && dashboard.data?.owner === "LANGFUSE";
// Filter state
// Filter state - use persistent filters from dashboard
const [savedFilters, setSavedFilters] = useState<FilterState>([]);
const [currentFilters, setCurrentFilters] = useState<FilterState>([]);
// Date range state - use the hook for all date range logic
const { selectedOption, dateRange, setDateRangeAndOption } =
useDashboardDateRange({ defaultRelativeAggregation: "7 days" });
const [userFilterState, setUserFilterState] = useState<FilterState>([]);
// Check if current filters differ from saved filters
const hasUnsavedFilterChanges = useMemo(() => {
return JSON.stringify(currentFilters) !== JSON.stringify(savedFilters);
}, [currentFilters, savedFilters]);
// State for handling widget deletion and addition
const [localDashboardDefinition, setLocalDashboardDefinition] = useState<{
@@ -91,6 +99,23 @@ export default function DashboardDetail() {
},
});
// Mutation for updating dashboard filters
const updateDashboardFilters =
api.dashboard.updateDashboardFilters.useMutation({
onSuccess: () => {
showSuccessToast({
title: "Filters saved",
description: "Dashboard filters have been saved successfully",
duration: 2000,
});
// Update saved state to match current state
setSavedFilters(currentFilters);
},
onError: (error) => {
showErrorToast("Error saving filters", error.message);
},
});
const saveDashboardChanges = useDebounce(
(definition: { widgets: WidgetPlacement[] }) => {
if (!hasCUDAccess) return;
@@ -104,6 +129,17 @@ export default function DashboardDetail() {
false,
);
// Function to save current filters
const handleSaveFilters = () => {
if (!hasCUDAccess) return;
updateDashboardFilters.mutate({
projectId,
dashboardId,
filters: currentFilters,
});
};
// Helper function to add a widget to the dashboard
const addWidgetToDashboard = useCallback(
(widget: WidgetItem) => {
@@ -263,6 +299,14 @@ export default function DashboardDetail() {
}
}, [dashboard.data, localDashboardDefinition]);
// Initialize filters from dashboard data
useEffect(() => {
if (dashboard.data?.filters) {
setSavedFilters(dashboard.data.filters);
setCurrentFilters(dashboard.data.filters);
}
}, [dashboard.data?.filters]);
useEffect(() => {
if (localDashboardDefinition && widgetToAdd.data && addWidgetId) {
if (
@@ -351,6 +395,17 @@ export default function DashboardDetail() {
},
actionButtonsRight: (
<>
{hasCUDAccess && hasUnsavedFilterChanges && (
<Button
onClick={handleSaveFilters}
disabled={updateDashboardFilters.isPending}
variant="outline"
>
{updateDashboardFilters.isPending
? "Saving..."
: "Save Filters"}
</Button>
)}
{hasCUDAccess && (
<Button onClick={handleAddWidget}>
<PlusIcon size={16} className="mr-1 h-4 w-4" />
@@ -397,8 +452,8 @@ export default function DashboardDetail() {
/>
<PopoverFilterBuilder
columns={filterColumns}
filterState={userFilterState}
onChange={setUserFilterState}
filterState={currentFilters}
onChange={setCurrentFilters}
/>
</div>
</div>
@@ -418,7 +473,7 @@ export default function DashboardDetail() {
dashboardId={dashboardId}
projectId={projectId}
dateRange={dateRange}
filterState={userFilterState}
filterState={currentFilters}
onDeleteWidget={handleDeleteWidget}
dashboardOwner={dashboard.data?.owner}
/>
@@ -193,7 +193,7 @@ export default function DatasetCompare() {
onClick={() => capture("dataset_run:new_form_open")}
>
<FlaskConical className="h-4 w-4" />
<span className="ml-2 hidden md:block">New experiment</span>
<span className="ml-2 hidden md:block">New dataset run</span>
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto">
@@ -258,12 +258,12 @@ export default function DatasetCompare() {
/>
</div>
<SidePanel
mobileTitle="Compare Experiments"
id="compare-experiments"
mobileTitle="Compare Dataset Runs"
id="compare-dataset-runs"
scrollable={false}
>
<SidePanelHeader>
<SidePanelTitle>Compare Experiments</SidePanelTitle>
<SidePanelTitle>Compare Dataset Runs</SidePanelTitle>
</SidePanelHeader>
<SidePanelContent className="overflow-y-auto p-1">
<div className="w-full space-y-4">
@@ -85,10 +85,10 @@ export default function Dataset() {
void utils.datasets.runsByDatasetId.invalidate();
void utils.datasets.baseRunDataByDatasetId.invalidate();
showSuccessToast({
title: "Experiment run triggered successfully",
description: "Waiting for experiment to complete...",
title: "Dataset run triggered successfully",
description: "Waiting for dataset run to complete...",
link: {
text: "View experiment",
text: "View dataset run",
href: `/project/${projectId}/datasets/${data.datasetId}/compare?runs=${data.runId}`,
},
});
@@ -178,7 +178,7 @@ export default function Dataset() {
onClick={() => capture("dataset_run:new_form_open")}
>
<FlaskConical className="h-4 w-4" />
<span className="ml-2 hidden md:block">New experiment</span>
<span className="ml-2 hidden md:block">New dataset run</span>
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto">
@@ -143,8 +143,8 @@ export default function Dataset() {
</h4>
<p className="text-sm text-muted-foreground">
{item.data.status === DatasetStatus.ACTIVE
? "Archiving an item will exclude it from new experiment runs."
: "Unarchiving an item will include it back in new experiment runs."}
? "Archiving an item will exclude it from new dataset runs."
: "Unarchiving an item will include it back in new dataset runs."}
</p>
</div>
<Button
+1
View File
@@ -327,6 +327,7 @@ export const scoresRouter = createTRPCRouter({
inflatedParams.sessionId,
input.name,
input.configId,
input.dataType,
);
const score = !!clickhouseScore
+1 -2
View File
@@ -1,10 +1,9 @@
FROM --platform=${TARGETPLATFORM:-linux/amd64} node:20-alpine AS alpine
FROM --platform=${TARGETPLATFORM:-linux/amd64} node:24-alpine AS alpine
# It's important to update the index before installing packages to ensure you're getting the latest versions.
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk update && apk upgrade --no-cache libcrypto3 libssl3 libc6-compat busybox ssl_client
FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine AS base
RUN npm install turbo@^2.5.6 --global
ENV PNPM_HOME="/pnpm"
+3 -3
View File
@@ -1,11 +1,11 @@
{
"name": "worker",
"version": "3.101.0",
"version": "3.104.0",
"description": "",
"license": "MIT",
"private": true,
"engines": {
"node": "20"
"node": "24"
},
"scripts": {
"test": "dotenv -e ../.env -- vitest run --pool=forks --poolOptions.forks.singleFork=true",
@@ -67,7 +67,7 @@
"@types/express": "^5.0.3",
"@types/express-serve-static-core": "^5.0.7",
"@types/lodash": "^4.17.10",
"@types/node": "^20.11.29",
"@types/node": "^24.3.0",
"@types/pg": "^8.11.10",
"@types/uuid": "^9.0.8",
"@typescript-eslint/parser": "^7.12.0",
+16 -16
View File
@@ -421,26 +421,26 @@ describe("select all test suite", () => {
},
});
await prisma.datasetRunItems.create({
data: {
id: uuidv4(),
datasetItemId: datasetItem1.id,
projectId,
traceId: traceId1,
datasetRunId: runId,
},
const datasetRunItem1 = createDatasetRunItem({
id: uuidv4(),
dataset_item_id: datasetItem1.id,
project_id: projectId,
trace_id: traceId1,
dataset_run_id: runId,
dataset_id: dataset.id,
});
await prisma.datasetRunItems.create({
data: {
id: uuidv4(),
datasetItemId: datasetItem2.id,
projectId,
traceId: traceId2,
datasetRunId: runId,
},
const datasetRunItem2 = createDatasetRunItem({
id: uuidv4(),
dataset_item_id: datasetItem2.id,
project_id: projectId,
trace_id: traceId2,
dataset_run_id: runId,
dataset_id: dataset.id,
});
await createDatasetRunItemsCh([datasetRunItem1, datasetRunItem2]);
// Create clickhouse run items
await createDatasetRunItemsCh([
createDatasetRunItem({
+130 -23
View File
@@ -15,8 +15,6 @@ import {
createTracesCh,
upsertObservation,
upsertTrace,
checkTraceExists,
getTraceById,
createDatasetRunItemsCh,
createDatasetRunItem,
} from "@langfuse/shared/src/server";
@@ -42,6 +40,21 @@ import {
extractVariablesFromTracingData,
} from "../features/evaluation/evalService";
import { requiresDatabaseLookup } from "../features/evaluation/traceFilterUtils";
// Mock fetchLLMCompletion module with default passthrough behavior
vi.mock("@langfuse/shared/src/server", async () => {
const actual = await vi.importActual("@langfuse/shared/src/server");
return {
...actual,
fetchLLMCompletion: vi
.fn()
.mockImplementation(actual.fetchLLMCompletion as any),
};
});
// Import the mocked function
import { fetchLLMCompletion } from "@langfuse/shared/src/server";
let OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const hasActiveKey = Boolean(OPENAI_API_KEY);
if (!hasActiveKey) {
@@ -314,17 +327,6 @@ describe("eval service tests", () => {
})
.execute();
await kyselyPrisma.$kysely
.insertInto("dataset_run_items")
.values({
id: randomUUID(),
dataset_item_id: datasetItemId,
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
dataset_run_id: datasetRunId,
trace_id: traceId,
})
.execute();
// Create a clickhouse run item
await createDatasetRunItemsCh([
createDatasetRunItem({
@@ -1046,16 +1048,6 @@ describe("eval service tests", () => {
dataset_id: datasetId2,
})
.execute();
await kyselyPrisma.$kysely
.insertInto("dataset_run_items")
.values({
id: randomUUID(),
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
dataset_item_id: datasetItemId,
dataset_run_id: datasetRunId,
trace_id: traceId,
})
.execute();
// Create a clickhouse run item that references dataset 2 and the new trace.
await createDatasetRunItemsCh([
@@ -1738,6 +1730,121 @@ describe("eval service tests", () => {
expect(jobs[0].start_time).not.toBeNull();
expect(jobs[0].end_time).not.toBeNull();
}, 20_000);
test("handles LLM timeout gracefully", async () => {
// Set up the mock to simulate timeout for this test only
const mockFetchLLMCompletion = vi.mocked(fetchLLMCompletion);
mockFetchLLMCompletion.mockRejectedValueOnce(
new ApiError("Request timeout after 120000ms", 500),
);
const traceId = randomUUID();
await upsertTrace({
id: traceId,
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
user_id: "a",
input: JSON.stringify({ input: "This is a great prompt" }),
output: JSON.stringify({ output: "This is a great response" }),
timestamp: convertDateToClickhouseDateTime(new Date()),
created_at: convertDateToClickhouseDateTime(new Date()),
updated_at: convertDateToClickhouseDateTime(new Date()),
});
const templateId = randomUUID();
await kyselyPrisma.$kysely
.insertInto("eval_templates")
.values({
id: templateId,
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
name: "test-template",
version: 1,
prompt: "Please evaluate toxicity {{input}} {{output}}",
model: "gpt-3.5-turbo",
provider: "openai",
model_params: {},
output_schema: {
reasoning: "Please explain your reasoning",
score: "Please provide a score between 0 and 1",
},
})
.executeTakeFirst();
const jobConfiguration = await prisma.jobConfiguration.create({
data: {
id: randomUUID(),
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
filter: [
{
type: "string",
value: "a",
column: "User ID",
operator: "contains",
},
],
jobType: "EVAL",
delay: 0,
sampling: new Decimal("1"),
targetObject: "trace",
scoreName: "score",
variableMapping: JSON.parse("[]"),
evalTemplateId: templateId,
},
});
const jobExecutionId = randomUUID();
await kyselyPrisma.$kysely
.insertInto("job_executions")
.values({
id: jobExecutionId,
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
job_configuration_id: jobConfiguration.id,
status: sql`'PENDING'::"JobExecutionStatus"`,
start_time: new Date(),
job_input_trace_id: traceId,
})
.execute();
await kyselyPrisma.$kysely
.insertInto("llm_api_keys")
.values({
id: randomUUID(),
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
secret_key: encrypt(String(OPENAI_API_KEY)),
provider: "openai",
adapter: LLMAdapter.OpenAI,
custom_models: [],
display_secret_key: "123456",
})
.execute();
const payload = {
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
jobExecutionId: jobExecutionId,
delay: 1000,
};
// Test that timeout error is thrown
await expect(evaluate({ event: payload })).rejects.toThrowError(
/timeout/i,
);
const jobs = await kyselyPrisma.$kysely
.selectFrom("job_executions")
.selectAll()
.where("project_id", "=", "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a")
.execute();
expect(jobs.length).toBe(1);
expect(jobs[0].project_id).toBe("7a88fb47-b4e2-43b8-a06c-a5ce950dc53a");
expect(jobs[0].job_input_trace_id).toBe(traceId);
// Job should still be PENDING because the error will be handled by the queue processor
expect(jobs[0].status.toString()).toBe("PENDING");
// Clean up the mock after this test
mockFetchLLMCompletion.mockReset();
}, 15_000);
});
describe("test variable extraction", () => {
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.101.0";
export const VERSION = "v3.104.0";
-3
View File
@@ -266,9 +266,6 @@ 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("false"),
});
export const env: z.infer<typeof EnvSchema> =
@@ -24,8 +24,6 @@ import {
getScoresForTraces,
tableColumnsToSqlFilterAndPrefix,
getTraceIdentifiers,
executeWithDatasetRunItemsStrategy,
DatasetRunItemsOperationType,
getDatasetRunItemsCh,
} from "@langfuse/shared/src/server";
import Decimal from "decimal.js";
@@ -413,108 +411,53 @@ export const getDatabaseReadStream = async ({
}
case "dataset_run_items": {
return await executeWithDatasetRunItemsStrategy({
input: {},
operationType: DatasetRunItemsOperationType.READ,
postgresExecution: async () => {
return new DatabaseReadStream<unknown>(
async (pageSize: number, offset: number) => {
const condition = tableColumnsToSqlFilterAndPrefix(
filter ?? [],
evalDatasetFormFilterCols,
"dataset_items",
);
const items = await prisma.$queryRaw<
Array<{
id: string;
project_id: string;
dataset_item_id: string;
trace_id: string;
observation_id: string | null;
created_at: Date;
updated_at: Date;
dataset_name: string;
}>
>`
SELECT dri.*, d.name as dataset_name
FROM dataset_run_items dri
JOIN dataset_items di ON dri.dataset_item_id = di.id AND dri.project_id = di.project_id
JOIN datasets d ON di.dataset_id = d.id AND d.project_id = dri.project_id
WHERE dri.project_id = ${projectId}
AND dri.created_at < ${cutoffCreatedAt}
${condition}
ORDER BY dri.created_at DESC
LIMIT ${pageSize}
OFFSET ${offset}
`;
return items.map((item) => ({
id: item.id,
projectId: item.project_id,
datasetItemId: item.dataset_item_id,
traceId: item.trace_id,
observationId: item.observation_id,
createdAt: item.created_at,
updatedAt: item.updated_at,
datasetName: item.dataset_name,
}));
return new DatabaseReadStream<unknown>(
async (pageSize: number, offset: number) => {
const items = await getDatasetRunItemsCh({
projectId,
filter: filter
? [...filter, createdAtCutoffFilter]
: [createdAtCutoffFilter],
limit: pageSize,
orderBy: {
column: "createdAt",
order: "DESC",
},
env.BATCH_EXPORT_PAGE_SIZE,
rowLimit,
);
},
clickhouseExecution: async () => {
return new DatabaseReadStream<unknown>(
async (pageSize: number, offset: number) => {
const items = await getDatasetRunItemsCh({
projectId,
filter: filter
? [...filter, createdAtCutoffFilter]
: [createdAtCutoffFilter],
limit: pageSize,
orderBy: {
column: "createdAt",
order: "DESC",
},
offset,
clickhouseConfigs,
});
offset,
clickhouseConfigs,
});
// fetch all project dataset names
const datasets = await prisma.dataset.findMany({
where: {
projectId,
},
select: {
id: true,
name: true,
},
});
return items.map((item) => {
const datasetName = datasets.find(
(d) => d.id === item.datasetId,
)?.name;
return {
id: item.id,
projectId: item.projectId,
datasetItemId: item.datasetItemId,
traceId: item.traceId,
observationId: item.observationId,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
datasetName: datasetName ?? "Unknown",
};
});
// fetch all project dataset names
const datasets = await prisma.dataset.findMany({
where: {
projectId,
},
env.BATCH_EXPORT_PAGE_SIZE,
rowLimit,
);
select: {
id: true,
name: true,
},
});
return items.map((item) => {
const datasetName = datasets.find(
(d) => d.id === item.datasetId,
)?.name;
return {
id: item.id,
projectId: item.projectId,
datasetItemId: item.datasetItemId,
traceId: item.traceId,
observationId: item.observationId,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
datasetName: datasetName ?? "Unknown",
};
});
},
});
env.BATCH_EXPORT_PAGE_SIZE,
rowLimit,
);
}
case "dataset_items": {
+40 -53
View File
@@ -14,7 +14,7 @@ import {
IngestionQueue,
logger,
EvalExecutionQueue,
checkTraceExists,
checkTraceExistsAndGetTimestamp,
checkObservationExists,
DatasetRunItemUpsertEventType,
TraceQueueEventType,
@@ -28,8 +28,6 @@ import {
InMemoryFilterService,
recordIncrement,
getCurrentSpan,
DatasetRunItemsOperationType,
executeWithDatasetRunItemsStrategy,
getDatasetItemIdsByTraceIdCh,
mapDatasetRunItemFilterColumn,
} from "@langfuse/shared/src/server";
@@ -301,6 +299,7 @@ export const createEvalJobs = async ({
// Check whether the trace already exists in the database.
let traceExists = false;
let traceTimestamp: Date | undefined = cachedTrace?.timestamp;
// Use cached trace for in-memory filtering when possible, i.e. all fields can
// be checked in-memory.
@@ -324,7 +323,7 @@ export const createEvalJobs = async ({
});
} else {
// Fall back to database query for complex filters or when no cached trace
traceExists = await checkTraceExists({
const { exists, timestamp } = await checkTraceExistsAndGetTimestamp({
projectId: event.projectId,
traceId: event.traceId,
// Fallback to jobTimestamp if no payload timestamp is set to allow for successful retry attempts.
@@ -339,6 +338,8 @@ export const createEvalJobs = async ({
? new Date(event.exactTimestamp)
: undefined,
});
traceExists = exists;
traceTimestamp = timestamp;
recordIncrement("langfuse.evaluation-execution.trace_db_lookup", 1, {
hasCached: Boolean(cachedTrace).toString(),
requiredDatabaseLookup: requiresDatabaseLookup(traceFilter)
@@ -369,49 +370,27 @@ export const createEvalJobs = async ({
`);
datasetItem = datasetItems.shift();
} else {
datasetItem = await executeWithDatasetRunItemsStrategy({
input: {},
operationType: DatasetRunItemsOperationType.READ,
postgresExecution: async () => {
// Otherwise, try to find the dataset item id from datasetRunItems.
// Here, we can search for the traceId and projectId and should only get one result.
const datasetItems = await prisma.$queryRaw<
Array<{ id: string }>
>(Prisma.sql`
SELECT dataset_item_id as id
FROM dataset_run_items as dri
JOIN dataset_items as di ON di.id = dri.dataset_item_id AND di.project_id = ${event.projectId}
WHERE dri.project_id = ${event.projectId}
AND dri.trace_id = ${event.traceId}
${condition}
`);
return datasetItems.shift();
},
clickhouseExecution: async () => {
// If the cached items are not null, we fetched all available datasetItemIds from the DB.
// The dataset is the only allowed filter today, so it should be easy to check using our existing in memory filter.
if (cachedDatasetItemIds !== null) {
// Try to return from cache
// Note that the entity is _NOT_ a true datasetRunItem here. The mapping logic works, but we need to keep in mind
// that the `id` column is the `datasetItemId` _not_ the `datasetRunItemId`!
return cachedDatasetItemIds.find((di) =>
InMemoryFilterService.evaluateFilter(
di,
config.target_object === "dataset" ? validatedFilter : [],
mapDatasetRunItemFilterColumn,
),
);
} else {
const datasetItemIds = await getDatasetItemIdsByTraceIdCh({
projectId: event.projectId,
traceId: event.traceId,
filter:
config.target_object === "dataset" ? validatedFilter : [],
});
return datasetItemIds.shift();
}
},
});
// If the cached items are not null, we fetched all available datasetItemIds from the DB.
// The dataset is the only allowed filter today, so it should be easy to check using our existing in memory filter.
if (cachedDatasetItemIds !== null) {
// Try to find from cache
// Note that the entity is _NOT_ a true datasetRunItem here. The mapping logic works, but we need to keep in mind
// that the `id` column is the `datasetItemId` _not_ the `datasetRunItemId`!
datasetItem = cachedDatasetItemIds.find((di) =>
InMemoryFilterService.evaluateFilter(
di,
config.target_object === "dataset" ? validatedFilter : [],
mapDatasetRunItemFilterColumn,
),
);
} else {
const datasetItemIds = await getDatasetItemIdsByTraceIdCh({
projectId: event.projectId,
traceId: event.traceId,
filter: config.target_object === "dataset" ? validatedFilter : [],
});
datasetItem = datasetItemIds.shift();
}
}
}
@@ -496,6 +475,7 @@ export const createEvalJobs = async ({
projectId: event.projectId,
jobConfigurationId: config.id,
jobInputTraceId: event.traceId,
jobInputTraceTimestamp: traceTimestamp,
jobTemplateId: config.eval_template_id,
status: "PENDING",
startTime: new Date(),
@@ -626,6 +606,7 @@ export const evaluate = async ({
projectId: event.projectId,
variables: template.vars,
traceId: job.job_input_trace_id,
traceTimestamp: job.job_input_trace_timestamp ?? undefined,
datasetItemId: job.job_input_dataset_item_id ?? undefined,
variableMapping: parsedVariableMapping,
});
@@ -798,6 +779,7 @@ export async function extractVariablesFromTracingData({
variables,
traceId,
variableMapping,
traceTimestamp,
datasetItemId,
}: {
projectId: string;
@@ -805,6 +787,7 @@ export async function extractVariablesFromTracingData({
traceId: string;
// this here are variables which were inserted by users. Need to validate before DB query.
variableMapping: z.infer<typeof variableMappingList>;
traceTimestamp?: Date;
datasetItemId?: string;
}): Promise<{ var: string; value: string; environment?: string }[]> {
// Internal cache for this function call to avoid duplicate database lookups.
@@ -897,7 +880,11 @@ export async function extractVariablesFromTracingData({
const traceCacheKey = `${projectId}:${traceId}`;
let trace = traceCache.get(traceCacheKey);
if (!traceCache.has(traceCacheKey)) {
trace = await getTraceById({ traceId, projectId });
trace = await getTraceById({
traceId,
projectId,
timestamp: traceTimestamp,
});
traceCache.set(traceCacheKey, trace ?? null);
}
@@ -948,13 +935,13 @@ export async function extractVariablesFromTracingData({
const observationCacheKey = `${projectId}:${traceId}:${mapping.objectName}`;
let observation = observationCache.get(observationCacheKey);
if (!observationCache.has(observationCacheKey)) {
const observations = await getObservationForTraceIdByName(
const observations = await getObservationForTraceIdByName({
traceId,
projectId,
mapping.objectName,
undefined,
true,
);
name: mapping.objectName,
timestamp: traceTimestamp,
fetchWithInputOutput: true,
});
observation = observations.shift() || null; // We only take the first match and ignore duplicate generation-names in a trace.
observationCache.set(observationCacheKey, observation);
}
@@ -19,8 +19,6 @@ import {
generateUnifiedTraceId,
parseDatasetItemInput,
replaceVariablesInPrompt,
shouldCreateTrace,
TraceExecutionSource,
validateAndSetupExperiment,
validateDatasetItem,
} from "./utils";
@@ -113,16 +111,14 @@ async function processItem(
* LLM MODEL CALL *
********************/
if (shouldCreateTrace(TraceExecutionSource.CLICKHOUSE)) {
const llmResult = await processLLMCall(
runItemId,
newTraceId,
datasetItem,
config,
);
const llmResult = await processLLMCall(
runItemId,
newTraceId,
datasetItem,
config,
);
if (!llmResult.success) return { success: false };
}
if (!llmResult.success) return { success: false };
/********************
* ASYNC RUN ITEM EVAL *
@@ -1,186 +0,0 @@
import { z } from "zod/v4";
import {
logger,
DatasetRunItemUpsertQueue,
type ChatMessage,
PROMPT_EXPERIMENT_ENVIRONMENT,
TraceParams,
} from "@langfuse/shared/src/server";
import { kyselyPrisma, prisma } from "@langfuse/shared/src/db";
import { type ExperimentCreateEventSchema } from "@langfuse/shared/src/server";
import { InvalidRequestError, type Prisma } from "@langfuse/shared";
import { backOff } from "exponential-backoff";
import { callLLM } from "../../features/utils";
import { QueueJobs, redis } from "@langfuse/shared/src/server";
import { randomUUID } from "node:crypto";
import { DatasetStatus } from "../../../../packages/shared/dist/prisma/generated/types";
import {
generateUnifiedTraceId,
parseDatasetItemInput,
replaceVariablesInPrompt,
TraceExecutionSource,
shouldCreateTrace,
validateAndSetupExperiment,
validateDatasetItem,
} from "./utils";
export const createExperimentJobPostgres = async ({
event,
}: {
event: z.infer<typeof ExperimentCreateEventSchema>;
}) => {
logger.info("Processing experiment create job", event);
const { datasetId, projectId, runId } = event;
/********************
* INPUT VALIDATION *
********************/
const experimentConfig = await validateAndSetupExperiment(event);
/********************
* FETCH DATASET ITEMS *
********************/
const datasetItems = await prisma.datasetItem.findMany({
where: {
datasetId,
projectId,
status: DatasetStatus.ACTIVE,
},
orderBy: {
createdAt: "desc",
},
});
// validate dataset items against prompt configuration
const validatedDatasetItems = datasetItems
.filter(({ input }) =>
validateDatasetItem(input, experimentConfig.allVariables),
)
.map((datasetItem) => ({
...datasetItem,
input: parseDatasetItemInput(
datasetItem.input as Prisma.JsonObject, // this is safe because we already filtered for valid input
experimentConfig.allVariables,
),
}));
logger.info(
`Found ${validatedDatasetItems.length} validated dataset items for dataset run ${runId}`,
);
if (!validatedDatasetItems.length) {
throw new InvalidRequestError(
`No Dataset ${datasetId} item input matches expected prompt variables or placeholders format`,
);
}
for (const datasetItem of validatedDatasetItems) {
// dedupe and skip if dataset run item already exists
const existingRunItem = await kyselyPrisma.$kysely
.selectFrom("dataset_run_items")
.selectAll()
.where("project_id", "=", projectId)
.where("dataset_item_id", "=", datasetItem.id)
.where("dataset_run_id", "=", runId)
.executeTakeFirst();
if (existingRunItem) {
logger.info(
`Dataset run item ${existingRunItem.id} already exists, skipping`,
);
continue;
}
/********************
* VARIABLE EXTRACTION *
********************/
let messages: ChatMessage[] = [];
try {
messages = replaceVariablesInPrompt(
experimentConfig.validatedPrompt,
datasetItem.input, // validated format
experimentConfig.allVariables,
experimentConfig.placeholderNames,
);
} catch (error) {
// skip this dataset item if there is an error replacing variables
logger.error(
`Error replacing variables in prompt for dataset item ${datasetItem.id}`,
error,
);
continue;
}
/********************
* RUN ITEM CREATION *
********************/
const newTraceId = generateUnifiedTraceId(runId, datasetItem.id);
const runItem = await prisma.datasetRunItems.create({
data: {
datasetItemId: datasetItem.id,
traceId: newTraceId,
datasetRunId: runId,
projectId,
},
});
/********************
* LLM MODEL CALL *
********************/
if (shouldCreateTrace(TraceExecutionSource.POSTGRES)) {
const traceParams: Omit<TraceParams, "tokenCountDelegate"> = {
environment: PROMPT_EXPERIMENT_ENVIRONMENT,
traceName: `dataset-run-item-${runItem.id.slice(0, 5)}`,
traceId: newTraceId,
projectId: event.projectId,
authCheck: {
validKey: true as const,
scope: {
projectId: event.projectId,
accessLevel: "project",
} as any,
},
};
await backOff(
async () =>
await callLLM(
experimentConfig.validatedApiKey,
messages,
experimentConfig.model_params,
experimentConfig.provider,
experimentConfig.model,
traceParams,
),
{
numOfAttempts: 1, // turn off retries as Langchain is doing that for us already.
},
);
}
/********************
* ASYNC RUN ITEM EVAL *
********************/
if (redis) {
const queue = DatasetRunItemUpsertQueue.getInstance();
if (queue) {
await queue.add(QueueJobs.DatasetRunItemUpsert, {
payload: {
projectId,
datasetItemId: datasetItem.id,
traceId: newTraceId,
},
id: randomUUID(),
timestamp: new Date(),
name: QueueJobs.DatasetRunItemUpsert as const,
});
}
}
}
};
-29
View File
@@ -29,35 +29,6 @@ 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
POSTGRES = "POSTGRES",
// eslint-disable-next-line no-unused-vars
CLICKHOUSE = "CLICKHOUSE",
}
/**
* Determines whether traces should be created for a given execution source.
*
* During DRI migration, we should not create traces for both CH AND PG execution,
* as these will show up in the UI as duplicates and confuse users. Instead, we
* only create traces in the PostgreSQL execution path, as both systems use the
* same unified trace ID.
*
* We will remove the generation of unified trace IDs once the DRI migration is complete.
*
* @param source - The execution source (POSTGRES or CLICKHOUSE)
* @returns true if traces should be created for this source, false otherwise
*
*/
export const shouldCreateTrace = (source: TraceExecutionSource) => {
if (env.LANGFUSE_EXPERIMENT_DATASET_RUN_ITEMS_TRACE_SOURCE_CH === "true") {
return source === TraceExecutionSource.CLICKHOUSE;
}
return source === TraceExecutionSource.POSTGRES;
};
/**
* Generate deterministic trace ID based on dataset run and item IDs
+27 -19
View File
@@ -19,6 +19,32 @@ import { createEvalJobs, evaluate } from "../features/evaluation/evalService";
import { delayInMs } from "./utils/delays";
import { handleRetryableError } from "../features/utils";
function isExpectedError(error: unknown): boolean {
return (
error instanceof LangfuseNotFoundError ||
(error instanceof BaseError &&
error.message.includes(
QUEUE_ERROR_MESSAGES.OUTPUT_TOKENS_TOO_LONG_ERROR,
)) || // output tokens too long
(error instanceof BaseError &&
error.message.includes(QUEUE_ERROR_MESSAGES.API_KEY_ERROR)) || // api key not provided
(error instanceof BaseError &&
error.message.includes(QUEUE_ERROR_MESSAGES.NO_DEFAULT_MODEL_ERROR)) || // api key not provided
(error instanceof ApiError &&
error.httpCode >= 400 &&
error.httpCode < 500) || // do not error and retry on 4xx errors. They are visible to the user in the UI but do not alert us.
(error instanceof ApiError && error.message.includes("TypeError")) || // Zod parsing the response failed. User should update prompt to consistently return expected output structure.
(error instanceof ApiError &&
error.message.includes(QUEUE_ERROR_MESSAGES.TOO_LOW_MAX_TOKENS_ERROR)) || // When evaluator model is configured with too low max_tokens, the structured output response is invalid JSON
(error instanceof ApiError &&
error.message.includes(QUEUE_ERROR_MESSAGES.INVALID_JSON_ERROR)) || // When evaluator model is not consistently returning valid JSON on structured output calls
(error instanceof BaseError &&
error.message.includes(QUEUE_ERROR_MESSAGES.MAPPED_DATA_ERROR)) || // Trace not found.
(error instanceof ApiError &&
error.message.toLowerCase().includes(QUEUE_ERROR_MESSAGES.TIMEOUT_ERROR)) // LLM provider timeout - graceful failure
);
}
export const evalJobTraceCreatorQueueProcessor = async (
job: Job<TQueueJobTypes[QueueName.TraceUpsert]>,
) => {
@@ -115,25 +141,7 @@ export const evalJobExecutorQueueProcessor = async (
.execute();
// do not log expected errors (api failures + missing api keys not provided by the user)
if (
e instanceof LangfuseNotFoundError ||
(e instanceof BaseError &&
e.message.includes(
QUEUE_ERROR_MESSAGES.OUTPUT_TOKENS_TOO_LONG_ERROR,
)) || // output tokens too long
(e instanceof BaseError &&
e.message.includes(QUEUE_ERROR_MESSAGES.API_KEY_ERROR)) || // api key not provided
(e instanceof BaseError &&
e.message.includes(QUEUE_ERROR_MESSAGES.NO_DEFAULT_MODEL_ERROR)) || // api key not provided
(e instanceof ApiError && e.httpCode >= 400 && e.httpCode < 500) || // do not error and retry on 4xx errors. They are visible to the user in the UI but do not alert us.
(e instanceof ApiError && e.message.includes("TypeError")) || // Zod parsing the response failed. User should update prompt to consistently return expected output structure.
(e instanceof ApiError &&
e.message.includes(QUEUE_ERROR_MESSAGES.TOO_LOW_MAX_TOKENS_ERROR)) || // When evaluator model is configured with too low max_tokens, the structured output response is invalid JSON
(e instanceof ApiError &&
e.message.includes(QUEUE_ERROR_MESSAGES.INVALID_JSON_ERROR)) || // When evaluator model is not consistently returning valid JSON on structured output calls
(e instanceof BaseError &&
e.message.includes(QUEUE_ERROR_MESSAGES.MAPPED_DATA_ERROR)) // Trace not found.
) {
if (isExpectedError(e)) {
return;
}
+53 -139
View File
@@ -6,10 +6,7 @@ import {
TQueueJobTypes,
logger,
traceException,
executeWithDatasetRunItemsStrategy,
DatasetRunItemsOperationType,
} from "@langfuse/shared/src/server";
import { createExperimentJobPostgres } from "../features/experiments/experimentServicePostgres";
import { InvalidRequestError, LangfuseNotFoundError } from "@langfuse/shared";
import { kyselyPrisma } from "@langfuse/shared/src/db";
import { handleRetryableError } from "../features/utils";
@@ -19,148 +16,65 @@ import { createExperimentJobClickhouse } from "../features/experiments/experimen
export const experimentCreateQueueProcessor = async (
job: Job<TQueueJobTypes[QueueName.ExperimentCreate]>,
) => {
await executeWithDatasetRunItemsStrategy({
input: job,
operationType: DatasetRunItemsOperationType.WRITE,
postgresExecution: async (jobInput: typeof job) => {
try {
await createExperimentJobClickhouse({
event: job.data.payload,
});
return true;
} catch (e) {
// If creating any of the dataset run items associated with this experiment create job fails with a 429, we want to retry the experiment creation job unless it's older than 24h.
const wasRetried = await handleRetryableError(e, job, {
table: "dataset_runs",
idField: "runId",
queue: ExperimentCreateQueue.getInstance(),
queueName: QueueName.ExperimentCreate,
jobName: QueueJobs.ExperimentCreateJob,
delayFn: delayInMs,
});
if (wasRetried) {
return;
}
if (
e instanceof InvalidRequestError ||
e instanceof LangfuseNotFoundError
) {
logger.info(
`Failed to process experiment create job for project: ${job.data.payload.projectId}`,
e,
);
try {
logger.info("Starting to process experiment create job", {
jobId: jobInput.id,
attempt: jobInput.attemptsMade,
data: jobInput.data,
});
await createExperimentJobPostgres({
event: jobInput.data.payload,
});
const currentRun = await kyselyPrisma.$kysely
.selectFrom("dataset_runs")
.selectAll()
.where("id", "=", job.data.payload.runId)
.where("project_id", "=", job.data.payload.projectId)
.executeTakeFirst();
if (!currentRun) {
logger.info(
`Dataset run configuration is invalid for run ${job.data.payload.runId}`,
);
// attempt retrying the job as the run may be created in the meantime
throw new LangfuseNotFoundError(
`Dataset run ${job.data.payload.runId} not found`,
);
}
// error cases of invalid configuration (prompt, api key, etc) are handled on the DRI level
// return true to indicate job was processed successfully and avoid retrying
return true;
} catch (e) {
// If creating any of the dataset run items associated with this experiment create job fails with a 429, we want to retry the experiment creation job unless it's older than 24h.
const wasRetried = await handleRetryableError(e, job, {
table: "dataset_runs",
idField: "runId",
queue: ExperimentCreateQueue.getInstance(),
queueName: QueueName.ExperimentCreate,
jobName: QueueJobs.ExperimentCreateJob,
delayFn: delayInMs,
});
if (wasRetried) {
return;
}
if (
e instanceof InvalidRequestError ||
e instanceof LangfuseNotFoundError
) {
logger.info(
`Failed to process experiment create job for project: ${jobInput.data.payload.projectId}`,
e,
);
try {
const currentRun = await kyselyPrisma.$kysely
.selectFrom("dataset_runs")
.selectAll()
.where("id", "=", jobInput.data.payload.runId)
.where("project_id", "=", jobInput.data.payload.projectId)
.executeTakeFirst();
if (!currentRun || !currentRun.metadata) {
logger.info(
`Dataset run configuration is invalid for run ${jobInput.data.payload.runId}`,
);
// attempt retrying the job as the run may be created in the meantime
throw new LangfuseNotFoundError(
`Dataset run ${jobInput.data.payload.runId} not found`,
);
}
await kyselyPrisma.$kysely
.updateTable("dataset_runs")
.set({
metadata: {
...currentRun.metadata,
error: e.message,
},
})
.where("id", "=", jobInput.data.payload.runId)
.where("project_id", "=", jobInput.data.payload.projectId)
.execute();
// return true to indicate job was processed successfully and avoid retrying
return true;
} catch (e) {
logger.error("Failed to process experiment create job", e);
traceException(e);
throw e;
}
}
logger.error("Failed to process experiment create job", e);
traceException(e);
throw e;
}
},
clickhouseExecution: async (jobInput: typeof job) => {
try {
await createExperimentJobClickhouse({
event: jobInput.data.payload,
});
return true;
} catch (e) {
// If creating any of the dataset run items associated with this experiment create job fails with a 429, we want to retry the experiment creation job unless it's older than 24h.
const wasRetried = await handleRetryableError(e, job, {
table: "dataset_runs",
idField: "runId",
queue: ExperimentCreateQueue.getInstance(),
queueName: QueueName.ExperimentCreate,
jobName: QueueJobs.ExperimentCreateJob,
delayFn: delayInMs,
});
}
if (wasRetried) {
return;
}
if (
e instanceof InvalidRequestError ||
e instanceof LangfuseNotFoundError
) {
logger.info(
`Failed to process experiment create job for project: ${jobInput.data.payload.projectId}`,
e,
);
try {
const currentRun = await kyselyPrisma.$kysely
.selectFrom("dataset_runs")
.selectAll()
.where("id", "=", jobInput.data.payload.runId)
.where("project_id", "=", jobInput.data.payload.projectId)
.executeTakeFirst();
if (!currentRun) {
logger.info(
`Dataset run configuration is invalid for run ${jobInput.data.payload.runId}`,
);
// attempt retrying the job as the run may be created in the meantime
throw new LangfuseNotFoundError(
`Dataset run ${jobInput.data.payload.runId} not found`,
);
}
// error cases of invalid configuration (prompt, api key, etc) are handled on the DRI level
// return true to indicate job was processed successfully and avoid retrying
return true;
} catch (e) {
logger.error("Failed to process experiment create job", e);
traceException(e);
throw e;
}
}
logger.error("Failed to process experiment create job", e);
traceException(e);
throw e;
}
},
});
logger.error("Failed to process experiment create job", e);
traceException(e);
throw e;
}
};
+2 -13
View File
@@ -11,8 +11,6 @@ import {
StorageService,
StorageServiceFactory,
TQueueJobTypes,
executeWithDatasetRunItemsStrategy,
DatasetRunItemsOperationType,
} from "@langfuse/shared/src/server";
import { prisma } from "@langfuse/shared/src/db";
import { Prisma } from "@prisma/client";
@@ -96,17 +94,8 @@ export const projectDeleteProcessor: Processor = async (
deleteScoresByProjectId(projectId),
]);
await executeWithDatasetRunItemsStrategy({
input: {
projectId,
},
operationType: DatasetRunItemsOperationType.WRITE,
postgresExecution: async () => {},
clickhouseExecution: async () => {
// Trigger async delete of dataset run items
await deleteDatasetRunItemsByProjectId({ projectId });
},
});
// Trigger async delete of dataset run items
await deleteDatasetRunItemsByProjectId({ projectId });
logger.info(`Deleting PG data for project ${projectId} in org ${orgId}`);
@@ -463,7 +463,15 @@ export class ClickhouseWriter {
format: "JSONEachRow",
values: params.records,
clickhouse_settings: {
log_comment: JSON.stringify({ feature: "ingestion" }),
log_comment: JSON.stringify({
feature: "ingestion",
type: params.table,
operation_name: "writeToClickhouse",
projectId:
params.records.length > 0
? params.records[0].project_id
: undefined,
}),
},
})
.catch((err) => {
@@ -16,9 +16,9 @@ This requires that the following table schema is manually applied on the databas
-- Setup
-- Context: https://fiddle.clickhouse.com/d4e84b88-6bd7-455c-9a84-e9126594f92a
-- TODO: Make sure to update migrateTracesToTracesAMTs.ts if the traces_null schema changes
-- Create a Null table that serves as a trigger for all materialized views.
-- We use a Null engine here to avoid storing intermediate results and save on storage.
CREATE TABLE traces_null
@@ -496,7 +496,7 @@ This checklist documents all references and invocations to the `traces` table gr
### 3. Existence Checks
- [x] **checkTraceExists()** - `packages/shared/src/server/repositories/traces.ts:73-210`
- [x] **checkTraceExistsAndGetTimestamp()** - `packages/shared/src/server/repositories/traces.ts:73-210`
- [x] **hasAnyTrace()** - `packages/shared/src/server/repositories/traces.ts:306-356`
- [x] **hasAnyUser()** - `packages/shared/src/server/repositories/traces.ts:763-787`
@@ -508,8 +508,8 @@ This checklist documents all references and invocations to the `traces` table gr
- [x] **generateObservationsForPublicApi()** - `web/src/features/public-api/server/observations.ts:80`
- [x] **getObservationsCountForPublicApi()** - `web/src/features/public-api/server/observations.ts:108`
- [x] **getObservationsTableInternal()** - `packages/shared/src/server/repositories/observations.ts:565`
- [x] **_handleGenerateScoresForPublicApi()** - `web/src/features/public-api/server/scores.ts:101`
- [x] **_handleGetScoresCountForPublicApi()** - `web/src/features/public-api/server/scores.ts:181`
- [x] **\_handleGenerateScoresForPublicApi()** - `web/src/features/public-api/server/scores.ts:101`
- [x] **\_handleGetScoresCountForPublicApi()** - `web/src/features/public-api/server/scores.ts:181`
- [x] **getScoresUiGeneric()** - `packages/shared/src/server/repositories/scores.ts:825`
- [x] **getNumericScoreHistogram()** - `packages/shared/src/server/repositories/scores.ts:1074`
- [x] **getTracesGroupedByName()** - `packages/shared/src/server/repositories/traces.ts:489-535`
@@ -542,6 +542,7 @@ We could use an opt-in on a projectId basis.
- [x] **getTracesByIdsForAnyProject()** - `packages/shared/src/server/repositories/traces.ts:1115-1141`
### 8. Delete Operations
- [x] **deleteTraces()** - `packages/shared/src/server/repositories/traces.ts:790++`
- [x] **deleteTracesOlderThanDays()** - `packages/shared/src/server/repositories/traces.ts:814++`
- [x] **deleteTracesByProjectId()** - `packages/shared/src/server/repositories/traces.ts:841++`
+3 -3
View File
@@ -2,12 +2,12 @@
"extends": "@repo/typescript-config/base.json",
"compilerOptions": {
"esModuleInterop": true,
"module": "Node16",
"moduleResolution": "Node16",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": false,
"declarationMap": false,
"outDir": "./dist",
"target": "ES2021",
"target": "es2024",
"types": ["node"],
"downlevelIteration": true,
"resolveJsonModule": true