Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
231a8f82bc | ||
|
|
a933bf68fb | ||
|
|
21a16963e2 | ||
|
|
fe6c647c2b | ||
|
|
f1e5f35659 | ||
|
|
d39f7ba6e5 | ||
|
|
f701562396 | ||
|
|
83cbd336e2 | ||
|
|
ec33992412 | ||
|
|
4e67edaaff | ||
|
|
ac3ee18373 | ||
|
|
7f646757c5 | ||
|
|
2d82ae6e86 | ||
|
|
9aa4c11c56 | ||
|
|
63a99b39fa | ||
|
|
2066658773 | ||
|
|
43ffd1b72d | ||
|
|
e5f96790c8 | ||
|
|
0b062f7c5b | ||
|
|
8dc119365a | ||
|
|
4bdc46ac31 | ||
|
|
2308495088 | ||
|
|
3f847591f4 | ||
|
|
dff54e8ba9 | ||
|
|
27d43a7a29 | ||
|
|
fced289c1c | ||
|
|
f37b0cbe3b | ||
|
|
ca9d42c7a3 | ||
|
|
4da2fe32b2 | ||
|
|
3770613a03 | ||
|
|
f543dc2e38 | ||
|
|
db328c5d82 | ||
|
|
9a38bdcce2 |
+5
-1
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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 '[]';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "job_executions" ADD COLUMN "job_input_trace_timestamp" TIMESTAMP(3);
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -37,4 +37,5 @@ export type TableName =
|
||||
| "widgets"
|
||||
| "users"
|
||||
| "eval_configs"
|
||||
| "dataset_items"
|
||||
| "job_executions";
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
"jsx": "react",
|
||||
"moduleResolution": "NodeNext",
|
||||
"module": "NodeNext",
|
||||
"lib": ["ES2021"],
|
||||
"lib": ["es2023"],
|
||||
"outDir": "./dist",
|
||||
"types": ["node"],
|
||||
"target": "ES2020",
|
||||
"target": "es2024",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["."],
|
||||
|
||||
Generated
+393
-503
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 @@
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
<>"{dataset.data?.name}"</>
|
||||
@@ -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> =>
|
||||
|
||||
@@ -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 }>>`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -327,6 +327,7 @@ export const scoresRouter = createTRPCRouter({
|
||||
inflatedParams.sessionId,
|
||||
input.name,
|
||||
input.configId,
|
||||
input.dataType,
|
||||
);
|
||||
|
||||
const score = !!clickhouseScore
|
||||
|
||||
+1
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 @@
|
||||
export const VERSION = "v3.101.0";
|
||||
export const VERSION = "v3.104.0";
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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++`
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user