Compare commits

...
38 Commits
Author SHA1 Message Date
Marc Klingen db5c575ae0 chore: release v2.93.2
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web-sync (node20, pg12) (push) Waiting to run
CI/CD / tests-web-sync (node20, pg15) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12, mode) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15, mode) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12, mode-azure) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15, mode-azure) (push) Waiting to run
CI/CD / tests-worker (node20, pg12, mode) (push) Waiting to run
CI/CD / tests-worker (node20, pg15, mode) (push) Waiting to run
CI/CD / tests-worker (node20, pg12, mode-azure) (push) Waiting to run
CI/CD / tests-worker (node20, pg15, mode-azure) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
2024-12-03 17:53:55 +01:00
Marc Klingen 2a0f482578 fix: remove LANGFUSE_CSP_DISABLE (did not work) and disable csp headers on HF Spaces (#4545) 2024-12-03 17:51:50 +01:00
Marc Klingen c041cf371a chore: release v2.93.1
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web-sync (node20, pg12) (push) Waiting to run
CI/CD / tests-web-sync (node20, pg15) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12, mode) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15, mode) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12, mode-azure) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15, mode-azure) (push) Waiting to run
CI/CD / tests-worker (node20, pg12, mode) (push) Waiting to run
CI/CD / tests-worker (node20, pg15, mode) (push) Waiting to run
CI/CD / tests-worker (node20, pg12, mode-azure) (push) Waiting to run
CI/CD / tests-worker (node20, pg15, mode-azure) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
2024-12-03 01:25:53 +01:00
Marc Klingen c6daf09cd2 [cherry-pick][2.93.x] chore: add LANGFUSE_CSP_DISABLE to .env.prod.example (#4533) 2024-12-03 01:25:04 +01:00
Marc KlingenandGitHub 7296e2e012 [cherry-pick][2.93.x] feat: optionally disable csp headers via LANGFUSE_CSP_DISABLE=true (#4532) 2024-12-03 01:22:11 +01:00
steffen911 43bf176ef7 chore: release v2.93.0 2024-11-26 10:04:45 +01:00
Steffen SchmitzandGitHub 6b48d7771c build: adjust docker actions to handle v2 branch (#4416)
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web-sync (node20, pg12) (push) Waiting to run
CI/CD / tests-web-sync (node20, pg15) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12, mode) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15, mode) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12, mode-azure) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15, mode-azure) (push) Waiting to run
CI/CD / tests-worker (node20, pg12, mode) (push) Waiting to run
CI/CD / tests-worker (node20, pg15, mode) (push) Waiting to run
CI/CD / tests-worker (node20, pg12, mode-azure) (push) Waiting to run
CI/CD / tests-worker (node20, pg15, mode-azure) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
2024-11-26 09:59:57 +01:00
Max DeichmannandGitHub cd0d39b3c2 fix: fix bullmq api (#4425) 2024-11-25 23:18:16 +00:00
Max DeichmannandGitHub 8fcbcdd29d fix: order datasets API correctly (#4423) 2024-11-25 22:09:49 +00:00
Max DeichmannandGitHub e24f65c51d feat: add all queues to get api for bullmq api (#4422) 2024-11-25 21:53:27 +00:00
Marc KlingenandGitHub 38e6464219 chore: improve logging of cloud metering queue (#4420) 2024-11-25 21:22:13 +00:00
Steffen SchmitzandGitHub 86f5c885a1 chore: skip string parsing for metadata string values (#4415) 2024-11-25 18:04:49 +01:00
Hassieb PakzadandGitHub c0ebd06c0c feat(evaluators): add updates (#4360) 2024-11-25 16:51:46 +01:00
Steffen SchmitzandGitHub 0ce5ddcd00 feat: add azure storage provider support (#4386) 2024-11-25 14:57:40 +00:00
Steffen SchmitzandGitHub 7539af3bd9 chore: overwrite numerical type for second filters on observations table (#4412) 2024-11-25 14:39:04 +00:00
Steffen SchmitzandGitHub 60f9147873 fix: use larger numeric type for latency filters (#4385) 2024-11-25 13:53:27 +00:00
Steffen SchmitzandGitHub a594c142e0 chore: use clickhouse in evaluations queues (#4319) 2024-11-25 13:42:17 +00:00
Max DeichmannandGitHub 18905a9872 feat: add all scores to run items UI (#4409) 2024-11-25 14:31:11 +01:00
Hassieb PakzadandGitHub 27f5dd837d chore(costs): add chatgpt-4o-latest prices (#4407) 2024-11-25 13:55:58 +01:00
Max DeichmannandGitHub a921f9db94 feat: exclude env defined operations from clickhouse (#4405) 2024-11-25 12:56:12 +01:00
Marc Klingen b3cd940c73 chore(ui): remove ph badge (#4403) 2024-11-25 10:41:33 +01:00
Max DeichmannandGitHub f91cf1ca65 feat: refactor clickhouse filter builder for SDK APIs (#4402) 2024-11-24 22:45:29 +00:00
Max DeichmannandGitHub c438e89bd0 feat: support scores list endpoint from clickhouse (#4400) 2024-11-24 21:08:44 +00:00
Max DeichmannandGitHub 9b1746b7ed feat: fetch observations via api from clickhouse (#4395) 2024-11-24 09:48:17 +00:00
Steffen SchmitzandGitHub 0611a72c40 fix: allow special characters in openai tokenization (#4396)
* fix: allow special characters in openai tokenization

* chore: update test
2024-11-24 05:26:45 +00:00
Max DeichmannandGitHub 0ba66526e9 fix: do not use memory tables in clickhouse (#4394) 2024-11-23 18:21:30 +01:00
Max DeichmannandGitHub 425202dd1e fix: correctly retrieve scores for datasets (#4387) 2024-11-22 17:20:48 +00:00
Hassieb PakzadandGitHub 882ad83aad chore: upgrade langfuse-langchain (#4384) 2024-11-22 12:12:00 +01:00
Max DeichmannandGitHub 9b12783d08 fix: fix dataset run table to show all scores (#4383) 2024-11-22 10:44:23 +00:00
Marc KlingenandGitHub c6817ae32f chore: add Product Hunt Launch note (#4364) 2024-11-22 08:50:23 +01:00
marliessophieandGitHub 42711a8cc9 fix: don't show error message to user if trace is not yet available for dataset (#4382) 2024-11-22 08:27:54 +01:00
Marc KlingenandGitHub 0344ab2cf9 fix: experimentation should not use structured output (#4379) 2024-11-22 04:57:47 +01:00
Marc KlingenandGitHub df0d12af43 chore: rename default experiment tag langfuse-prompt-experiment (#4378) 2024-11-22 01:01:40 +00:00
marliessophieandGitHub d058ba8861 docs: add link to experimentation docs (#4371) 2024-11-21 22:42:36 +00:00
marliessophieandGitHub d1309905d0 chore: create dataset run items in order of dataset items timestamp (#4370)
* chore: create dataset run items in order of dataset items timestamp
2024-11-21 22:36:47 +01:00
Max DeichmannandGitHub ca6ac3912f fix: fix dataset run item api to always return traceid (#4367) 2024-11-21 20:25:30 +00:00
Max DeichmannandGitHub 9eabc32681 fix: return i/o from single object APIs (#4368) 2024-11-21 20:14:21 +00:00
marliessophieandGitHub 8798e4a499 feat(experiments): trigger experiments via UI (#4333) 2024-11-21 20:00:07 +01:00
135 changed files with 7447 additions and 2842 deletions
+92
View File
@@ -0,0 +1,92 @@
# When adding additional environment variables, the schema in "/src/env.mjs"
# should be updated accordingly.
# Prisma
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
DIRECT_URL="postgresql://postgres:postgres@localhost:5432/postgres"
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
# Clickhouse
CLICKHOUSE_MIGRATION_URL="clickhouse://localhost:9000"
CLICKHOUSE_URL="http://localhost:8123"
CLICKHOUSE_USER="clickhouse"
CLICKHOUSE_PASSWORD="clickhouse"
CLICKHOUSE_MIGRATION_CLUSTER_DISABLED="true"
# Next Auth
# You can generate a new secret on the command line with:
# openssl rand -base64 32
# https://next-auth.js.org/configuration/options#secret
# NEXTAUTH_SECRET=""
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="secret"
# Langfuse Cloud Environment
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION="DEV"
# Langfuse experimental features
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES="true"
# Salt for API key hashing
SALT="salt"
# Email
EMAIL_FROM_ADDRESS="" # Defines the email address to use as the from address.
SMTP_CONNECTION_URL="" # Defines the connection url for smtp server.
# DON'T PANIC: The Azurite Secrets are well-known and meant to be hard-coded
# S3 storage
S3_ENDPOINT=http://localhost:10000/devstoreaccount1
S3_ACCESS_KEY_ID=devstoreaccount1
S3_SECRET_ACCESS_KEY=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==
S3_BUCKET_NAME=langfuse
S3_REGION=auto
## Necessary for minio compatibility
S3_FORCE_PATH_STYLE=true
# S3 Media Upload LOCAL
LANGFUSE_S3_MEDIA_UPLOAD_ENABLED=true
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET=langfuse
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID=devstoreaccount1
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==
LANGFUSE_S3_MEDIA_UPLOAD_REGION=auto
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT=http://localhost:10000/devstoreaccount1
## Necessary for minio compatibility
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE=true
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX=media/
# S3 Event Bucket Upload
## Set to true to test uploading all events to S3
LANGFUSE_S3_EVENT_UPLOAD_ENABLED=true
LANGFUSE_S3_EVENT_UPLOAD_BUCKET=langfuse
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=devstoreaccount1
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==
LANGFUSE_S3_EVENT_UPLOAD_REGION=auto
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT=http://localhost:10000/devstoreaccount1
## Necessary for minio compatibility
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE=true
LANGFUSE_S3_EVENT_UPLOAD_PREFIX=events/
LANGFUSE_USE_AZURE_BLOB=true
# Set during docker build of application
# Used to disable environment verification at build time
# DOCKER_BUILD=1
REDIS_HOST="127.0.0.1"
REDIS_PORT=6379
REDIS_AUTH="myredissecret"
# openssl rand -hex 32 used only here
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
# speeds up local development by not executing init scripts on server startup
NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
LANGFUSE_READ_FROM_POSTGRES_ONLY=false
LANGFUSE_RETURN_FROM_CLICKHOUSE=true
LANGFUSE_READ_DASHBOARDS_FROM_CLICKHOUSE=true
LANGFUSE_READ_FROM_CLICKHOUSE_ONLY=true
LANGFUSE_ASYNC_INGESTION_PROCESSING="true"
LANGFUSE_ASYNC_CLICKHOUSE_INGESTION_PROCESSING="true"
+2 -2
View File
@@ -11,7 +11,7 @@ CLICKHOUSE_MIGRATION_URL="clickhouse://localhost:9000"
CLICKHOUSE_URL="http://localhost:8123"
CLICKHOUSE_USER="clickhouse"
CLICKHOUSE_PASSWORD="clickhouse"
CLICKHOUSE_MIGRATION_CLUSTER_DISABLED="true"
CLICKHOUSE_CLUSTER_DISABLED="true"
# Next Auth
# You can generate a new secret on the command line with:
@@ -43,7 +43,7 @@ S3_REGION=us-east-1
## Necessary for minio compatibility
S3_FORCE_PATH_STYLE=true
# # S3 Media Upload LOCAL
# S3 Media Upload LOCAL
LANGFUSE_S3_MEDIA_UPLOAD_ENABLED=true
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET=langfuse
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID=minio
+1 -1
View File
@@ -11,7 +11,7 @@ CLICKHOUSE_MIGRATION_URL="clickhouse://localhost:9000"
CLICKHOUSE_URL="http://localhost:8123"
CLICKHOUSE_USER="clickhouse"
CLICKHOUSE_PASSWORD="clickhouse"
CLICKHOUSE_MIGRATION_CLUSTER_DISABLED="true"
CLICKHOUSE_CLUSTER_ENABLED="false"
# Next Auth
# You can generate a new secret on the command line with:
+21 -16
View File
@@ -137,11 +137,12 @@ jobs:
tests-web-async:
timeout-minutes: 20
runs-on: ubuntu-latest
name: tests-web-async (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }})
name: tests-web-async (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }}, mode${{ matrix.blob-provider }})
strategy:
matrix:
node-version: [20]
postgres-version: [12, 15]
blob-provider: ["", "-azure"]
steps:
- name: Set Swap Space
uses: pierotofy/set-swap-space@master
@@ -172,11 +173,11 @@ jobs:
pnpm install
- name: Load default env
run: |
cp .env.dev.example .env
grep -v -e '^S3_BUCKET_NAME=' -e '^REDIS_HOST=' -e '^NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT=' .env.dev.example > .env
cp .env.dev${{ matrix.blob-provider }}.example .env
grep -v -e '^S3_BUCKET_NAME=' -e '^REDIS_HOST=' -e '^NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT=' .env.dev${{ matrix.blob-provider }}.example > .env
- name: Run + migrate
run: |
docker compose -f docker-compose.dev.yml up -d
docker compose -f docker-compose.dev${{ matrix.blob-provider }}.yml up -d
sleep 5 # Wait for PostgreSQL to accept connections
docker compose ps
env:
@@ -205,11 +206,12 @@ jobs:
tests-worker:
timeout-minutes: 20
runs-on: ubuntu-latest
name: tests-worker (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }})
name: tests-worker (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }}, mode${{ matrix.blob-provider }})
strategy:
matrix:
node-version: [20]
postgres-version: [12, 15]
blob-provider: ["", "-azure"]
steps:
- name: Set Swap Space
uses: pierotofy/set-swap-space@master
@@ -240,12 +242,12 @@ jobs:
which migrate
- name: Load default env
run: |
cp .env.dev.example .env
cp .env.dev.example web/.env
cp .env.dev.example worker/.env
cp .env.dev${{ matrix.blob-provider }}.example .env
cp .env.dev${{ matrix.blob-provider }}.example web/.env
cp .env.dev${{ matrix.blob-provider }}.example worker/.env
- name: Run + migrate
run: |
docker compose -f docker-compose.dev.yml up -d
docker compose -f docker-compose.dev${{ matrix.blob-provider }}.yml up -d
sleep 5 # Wait for PostgreSQL to accept connections
docker compose ps
- name: Ensure no unhealthy status
@@ -326,18 +328,18 @@ jobs:
- name: install dependencies
run: |
pnpm install
- name: Load default env
run: |
cp .env.dev.example .env
echo "LANGFUSE_ASYNC_INGESTION_PROCESSING=true" >> .env
echo "LANGFUSE_CACHE_API_KEY_ENABLED=true" >> .env
echo "LANGFUSE_CACHE_PROMPT_ENABLED=true" >> .env
- name: Install golang-migrate for Clickhouse migrations
run: |
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.16.2/migrate.linux-amd64.tar.gz | tar xvz
sudo mv migrate /usr/bin/migrate
which migrate
- name: Load default env
run: |
cp .env.dev.example .env
echo "LANGFUSE_ASYNC_CLICKHOUSE_INGESTION_PROCESSING=true" >> .env
echo "LANGFUSE_ASYNC_INGESTION_PROCESSING=true" >> .env
echo "LANGFUSE_CACHE_API_KEY_ENABLED=true" >> .env
echo "LANGFUSE_CACHE_PROMPT_ENABLED=true" >> .env
- name: Run + migrate
run: |
docker compose -f docker-compose.dev.yml up -d
@@ -439,6 +441,8 @@ jobs:
images: |
ghcr.io/langfuse/langfuse # GitHub
langfuse/langfuse # Docker Hub
flavor: |
latest=false
tags: |
type=ref,event=branch
type=ref,event=pr
@@ -446,6 +450,7 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v3') }}
- name: Build and push Docker image (web)
uses: docker/build-push-action@v4
with:
+1 -1
View File
@@ -3,7 +3,7 @@ on:
push:
# Pattern matched against refs/tags
tags:
- "v[0-9]+.[0-9]+.[0-9]+" # Semantic version tags
- "v3.[0-9]+.[0-9]+" # Semantic version tags
jobs:
release:
+1
View File
@@ -37,6 +37,7 @@ yarn-error.log*
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
.env*
!.env.dev.example
!.env.dev-azure.example
!.env.local.example
!.env.prod.example
!.env.dev.legacy.example
+63
View File
@@ -0,0 +1,63 @@
services:
clickhouse:
image: clickhouse/clickhouse-server
user: "101:101"
container_name: clickhouse
hostname: clickhouse
environment:
CLICKHOUSE_DB: default
CLICKHOUSE_USER: clickhouse
CLICKHOUSE_PASSWORD: clickhouse
volumes:
- langfuse_clickhouse_data:/var/lib/clickhouse
- langfuse_clickhouse_logs:/var/log/clickhouse-server
ports:
- "8123:8123"
- "9000:9000"
depends_on:
- postgres
azurite:
image: mcr.microsoft.com/azure-storage/azurite
container_name: azurite
command: azurite-blob --blobHost 0.0.0.0
ports:
- "10000:10000"
volumes:
- langfuse_azurite_data:/data
redis:
image: redis:7.2.4
restart: always
command: >
--requirepass ${REDIS_AUTH:-myredissecret}
ports:
- 6379:6379
postgres:
image: postgres:${POSTGRES_VERSION:-latest}
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 3s
timeout: 3s
retries: 10
command: ["postgres", "-c", "log_statement=all"]
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=postgres
ports:
- 5432:5432
volumes:
- langfuse_postgres_data:/var/lib/postgresql/data
volumes:
langfuse_postgres_data:
driver: local
langfuse_clickhouse_data:
driver: local
langfuse_clickhouse_logs:
driver: local
langfuse_azurite_data:
driver: local
+1 -1
View File
@@ -25,7 +25,7 @@ services:
CLICKHOUSE_URL: ${CLICKHOUSE_URL:-http://clickhouse:8123}
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-clickhouse}
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-clickhouse}
CLICKHOUSE_MIGRATION_CLUSTER_DISABLED: ${CLICKHOUSE_MIGRATION_CLUSTER_DISABLED:-true}
CLICKHOUSE_CLUSTER_ENABLED: ${CLICKHOUSE_CLUSTER_ENABLED:-false}
LANGFUSE_S3_EVENT_UPLOAD_ENABLED: ${LANGFUSE_S3_EVENT_UPLOAD_ENABLED:-true}
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: ${LANGFUSE_S3_EVENT_UPLOAD_BUCKET:-langfuse}
LANGFUSE_S3_EVENT_UPLOAD_REGION: ${LANGFUSE_S3_EVENT_UPLOAD_REGION:-us-east-1}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "2.92.0",
"version": "2.93.2",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
+1 -1
View File
@@ -19,7 +19,7 @@ then
fi
# Construct the database URL
if [ "$CLICKHOUSE_MIGRATION_CLUSTER_DISABLED" != "true" ] ; then
if [ "$CLICKHOUSE_CLUSTER_ENABLED" == "true" ] ; then
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-cluster-name=default&x-migrations-table-engine=ReplicatedMergeTree"
else
+1 -1
View File
@@ -19,7 +19,7 @@ then
fi
# Construct the database URL
if [ "$CLICKHOUSE_MIGRATION_CLUSTER_DISABLED" != "true" ] ; then
if [ "$CLICKHOUSE_CLUSTER_ENABLED" == "true" ] ; then
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-cluster-name=default&x-migrations-table-engine=ReplicatedMergeTree"
else
+2 -1
View File
@@ -59,6 +59,7 @@
"@aws-sdk/client-s3": "^3.675.0",
"@aws-sdk/lib-storage": "^3.675.0",
"@aws-sdk/s3-request-presigner": "^3.679.0",
"@azure/storage-blob": "^12.26.0",
"@clickhouse/client": "^1.4.0",
"@langchain/anthropic": "^0.3.8",
"@langchain/aws": "^0.1.2",
@@ -78,7 +79,7 @@
"ioredis": "^5.4.1",
"kysely": "^0.27.4",
"langchain": "^0.3.6",
"langfuse-langchain": "3.30.1",
"langfuse-langchain": "3.30.3",
"lodash": "^4.17.21",
"next-auth": "^4.24.7",
"nodemailer": "^6.9.15",
@@ -0,0 +1,25 @@
INSERT INTO models (
id,
project_id,
model_name,
match_pattern,
start_date,
input_price,
output_price,
total_price,
unit,
tokenizer_id,
tokenizer_config
)
VALUES
('cm3x0p8ev000008kyd96800c8', NULL, 'chatgpt-4o-latest', '(?i)^(chatgpt-4o-latest)$', NULL, 0.000005, 0.000015, NULL, 'TOKENS', 'openai', '{ "tokensPerMessage": 3, "tokensPerName": 1, "tokenizerModel": "gpt-4o" }');
INSERT INTO prices (
id,
model_id,
usage_type,
price
)
VALUES
('cm3x0psrz000108kydpxg9o2k', 'cm3x0p8ev000008kyd96800c8', 'input', 0.000005),
('cm3x0pyt7000208ky8737gdla', 'cm3x0p8ev000008kyd96800c8', 'output', 0.000015);
+2
View File
@@ -58,6 +58,8 @@ const EnvSchema = z.object({
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: z
.enum(["true", "false"])
.default("false"),
LANGFUSE_USE_AZURE_BLOB: z.enum(["true", "false"]).default("false"),
STRIPE_SECRET_KEY: z.string().optional(),
});
export const env = EnvSchema.parse(removeEmptyEnvVariables(process.env));
+1
View File
@@ -11,6 +11,7 @@ export * from "./server/auth/apiKeys";
export * from "./observationsTable";
export * from "./utils/zod";
export * from "./utils/json";
export * from "./utils/stringChecks";
export * from "./utils/objects";
export * from "./utils/typeChecks";
export * from "./features/entitlements/plans";
+4 -1
View File
@@ -1,4 +1,4 @@
export * from "./services/S3StorageService";
export * from "./services/StorageService";
export * from "./services/email/organizationInvitation/sendMembershipInvitationEmail";
export * from "./services/email/batchExportSuccess/sendBatchExportSuccessEmail";
export * from "./services/email/passwordReset/sendResetPasswordVerificationRequest";
@@ -21,10 +21,13 @@ export * from "../server/ingestion/types";
export * from "../server/ingestion/validateAndInflateScore";
export * from "./redis/redis";
export * from "./redis/traceUpsert";
export * from "./redis/CloudUsageMeteringQueue";
export * from "./redis/getQueue";
export * from "./redis/datasetRunItemUpsert";
export * from "./redis/batchExport";
export * from "./redis/legacyIngestion";
export * from "./redis/ingestionQueue";
export * from "./redis/experimentCreateQueue";
export * from "./auth/types";
export * from "./ingestion/legacy/index";
export * from "./queues";
@@ -3,13 +3,7 @@ import z from "zod";
import { ForbiddenError, UnauthorizedError } from "../../../errors";
import { eventTypes, ingestionApiSchema, IngestionEventType } from "../types";
import { getProcessorForEvent } from "./EventProcessor";
import { TraceUpsertEventType } from "../../queues";
import {
convertTraceUpsertEventsToRedisEvents,
TraceUpsertQueue,
} from "../../redis/traceUpsert";
import { ApiAccessScope } from "../../auth/types";
import { redis } from "../../redis/redis";
import { backOff } from "exponential-backoff";
import { Model } from "../../..";
import { logger } from "../../logger";
@@ -163,45 +157,5 @@ export function cleanEvent(obj: unknown): unknown {
}
}
export const isNotNullOrUndefined = <T>(
val?: T | null,
): val is Exclude<T, null | undefined> => !isUndefinedOrNull(val);
export const isUndefinedOrNull = <T>(val?: T | null): val is undefined | null =>
val === undefined || val === null;
export const addTracesToTraceUpsertQueue = async (
batchResults: BatchResult[],
projectId: string,
): Promise<void> => {
const traceEvents: TraceUpsertEventType[] = batchResults
.filter((result) => result.type === eventTypes.TRACE_CREATE) // we only have create, no update.
.map((result) =>
result.result &&
typeof result.result === "object" &&
"id" in result.result
? // ingestion API only gets traces for one projectId
{
traceId: result.result.id as string,
projectId,
}
: null,
)
.filter(isNotNullOrUndefined);
try {
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION && redis) {
logger.debug(`Sending ${traceEvents.length} events to worker via Redis`);
const queue = TraceUpsertQueue.getInstance();
if (!queue) {
logger.error("TraceUpsertQueue not initialized");
return;
}
await queue.addBulk(convertTraceUpsertEventsToRedisEvents(traceEvents));
}
} catch (error) {
logger.error("Error sending events to worker", error);
}
};
@@ -22,8 +22,11 @@ import { LegacyIngestionEventType, QueueJobs } from "../queues";
import { IngestionQueue } from "../redis/ingestionQueue";
import { LegacyIngestionQueue } from "../redis/legacyIngestion";
import { redis } from "../redis/redis";
import { S3StorageService } from "../services/S3StorageService";
import { addTracesToTraceUpsertQueue, handleBatch } from "./legacy";
import { handleBatch } from "./legacy";
import {
StorageService,
StorageServiceFactory,
} from "../services/StorageService";
import { getProcessorForEvent } from "./legacy/EventProcessor";
import { eventTypes, ingestionEvent, IngestionEventType } from "./types";
@@ -32,11 +35,11 @@ export type TokenCountDelegate = (p: {
text: unknown;
}) => number | undefined;
let s3StorageServiceClient: S3StorageService;
let s3StorageServiceClient: StorageService;
const getS3StorageServiceClient = (bucketName: string): S3StorageService => {
const getS3StorageServiceClient = (bucketName: string): StorageService => {
if (!s3StorageServiceClient) {
s3StorageServiceClient = new S3StorageService({
s3StorageServiceClient = StorageServiceFactory.getInstance({
bucketName,
accessKeyId: env.LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID,
secretAccessKey: env.LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY,
@@ -286,8 +289,6 @@ export const processEventBatch = async (
*******************/
const result = await handleBatch(sortedBatch, authCheck, tokenCountDelegate);
await addTracesToTraceUpsertQueue(result.results, authCheck.scope.projectId);
// in case we did not return early, we return the result here
return aggregateBatchResult(
[...validationErrors, ...authenticationErrors, ...result.errors],
@@ -31,7 +31,7 @@ import type { BaseCallbackHandler } from "@langchain/core/callbacks/base";
type ProcessTracedEvents = () => Promise<void>;
type TraceParams = {
export type TraceParams = {
traceName: string;
traceId: string;
projectId: string;
@@ -106,6 +106,7 @@ export async function fetchLLMCompletion(
if (traceParams) {
const handler = new CallbackHandler({
_projectId: traceParams.projectId,
_isLocalEventExportEnabled: true,
tags: traceParams.tags,
});
@@ -196,15 +197,17 @@ export async function fetchLLMCompletion(
throw new Error("This model provider is not supported.");
}
const runConfig = {
callbacks: finalCallbacks,
runId: traceParams?.traceId,
runName: traceParams?.traceName,
};
if (params.structuredOutputSchema) {
return {
completion: await (chatModel as ChatOpenAI) // Typecast necessary due to https://github.com/langchain-ai/langchainjs/issues/6795
.withStructuredOutput(params.structuredOutputSchema)
.invoke(finalMessages, {
callbacks: finalCallbacks,
runId: traceParams?.traceId,
runName: traceParams?.traceName,
}),
.invoke(finalMessages, runConfig),
processTracedEvents,
};
}
@@ -238,6 +241,7 @@ export async function fetchLLMCompletion(
.pipe(new StringOutputParser())
.invoke(
finalMessages.filter((message) => message._getType() !== "system"),
runConfig,
),
processTracedEvents,
};
@@ -247,7 +251,7 @@ export async function fetchLLMCompletion(
return {
completion: await chatModel
.pipe(new BytesOutputParser())
.stream(finalMessages),
.stream(finalMessages, runConfig),
processTracedEvents,
};
}
@@ -255,7 +259,7 @@ export async function fetchLLMCompletion(
return {
completion: await chatModel
.pipe(new StringOutputParser())
.invoke(finalMessages),
.invoke(finalMessages, runConfig),
processTracedEvents,
};
}
+25
View File
@@ -26,6 +26,20 @@ export enum ChatMessageRole {
export const ChatMessageDefaultRoleSchema = z.nativeEnum(ChatMessageRole);
const ChatMessageSchema = z.object({
role: z.union([ChatMessageDefaultRoleSchema, z.string()]), // Users may ingest any string as role via API/SDK
content: z.string(),
});
export const ChatMessageListSchema = z.array(ChatMessageSchema);
export const TextPromptSchema = z.string().min(1, "Enter a prompt");
export const PromptContentSchema = z.union([
ChatMessageListSchema,
TextPromptSchema,
]);
export type PromptContent = z.infer<typeof PromptContentSchema>;
export type ModelParams = {
provider: string;
adapter: LLMAdapter;
@@ -49,6 +63,17 @@ export const ZodModelConfig = z.object({
top_p: z.coerce.number().optional(),
});
// Experiment config
export const ExperimentMetadataSchema = z
.object({
prompt_id: z.string(),
provider: z.string(),
model: z.string(),
model_params: ZodModelConfig,
})
.strict();
export type ExperimentMetadata = z.infer<typeof ExperimentMetadataSchema>;
// NOTE: Update docs page when changing this! https://langfuse.com/docs/playground#openai-playground--anthropic-playground
export const openAIModels = [
"gpt-4o",
@@ -1,10 +1,13 @@
import { filterOperators } from "../../../interfaces/filters";
import { clickhouseCompliantRandomCharacters } from "../../repositories";
export type ClickhouseOperator =
| (typeof filterOperators)[keyof typeof filterOperators][number]
| "!=";
export interface Filter {
apply(): ClickhouseFilter;
clickhouseTable: string;
operator: (typeof filterOperators)[keyof typeof filterOperators][number];
operator: ClickhouseOperator;
field: string;
}
type ClickhouseFilter = {
@@ -69,28 +72,32 @@ export class NumberFilter implements Filter {
public clickhouseTable: string;
public field: string;
public value: number;
public operator: (typeof filterOperators)["number"][number];
public operator: (typeof filterOperators)["number"][number] | "!=";
public clickhouseTypeOverwrite?: string;
protected tablePrefix?: string;
constructor(opts: {
clickhouseTable: string;
field: string;
operator: (typeof filterOperators)["number"][number];
operator: (typeof filterOperators)["number"][number] | "!=";
value: number;
tablePrefix?: string;
clickhouseTypeOverwrite?: string;
}) {
this.clickhouseTable = opts.clickhouseTable;
this.field = opts.field;
this.value = opts.value;
this.operator = opts.operator;
this.tablePrefix = opts.tablePrefix;
this.clickhouseTypeOverwrite = opts.clickhouseTypeOverwrite;
}
apply(): ClickhouseFilter {
const uid = clickhouseCompliantRandomCharacters();
const varName = `numberFilter${uid}`;
const type = this.clickhouseTypeOverwrite ?? "Decimal64(12)";
return {
query: `${this.tablePrefix ? this.tablePrefix + "." : ""}${this.field} ${this.operator} {${varName}: Decimal64(12)}`,
query: `${this.tablePrefix ? this.tablePrefix + "." : ""}${this.field} ${this.operator} {${varName}: ${type}}`,
params: { [varName]: this.value.toString() },
};
}
@@ -256,7 +263,7 @@ export class ArrayOptionsFilter implements Filter {
query = `hasAny({${varName}: Array(String)}, ${this.tablePrefix ? this.tablePrefix + "." : ""}${this.field}) = False`;
break;
case "all of":
query = `arrayAll(x -> has({${varName}: Array(String)}, x), ${this.tablePrefix ? this.tablePrefix + "." : ""}${this.field}) = True`;
query = `hasAll(${this.tablePrefix ? this.tablePrefix + "." : ""}${this.field}, {${varName}: Array(String)}) = True`;
break;
default:
throw new Error(`Unsupported operator: ${this.operator}`);
@@ -300,13 +307,13 @@ export class NumberObjectFilter implements Filter {
public field: string;
public key: string;
public value: number;
public operator: (typeof filterOperators)["numberObject"][number];
public operator: (typeof filterOperators)["numberObject"][number] | "!=";
protected tablePrefix?: string;
constructor(opts: {
clickhouseTable: string;
field: string;
operator: (typeof filterOperators)["numberObject"][number];
operator: (typeof filterOperators)["numberObject"][number] | "!=";
key: string;
value: number;
tablePrefix?: string;
@@ -364,7 +371,7 @@ export class BooleanFilter implements Filter {
export class FilterList {
private filters: Filter[];
constructor(filters: Filter[]) {
constructor(filters: Filter[] = []) {
this.filters = filters;
}
@@ -376,6 +383,10 @@ export class FilterList {
return this.filters.find(predicate);
}
length() {
return this.filters.length;
}
public apply(): ClickhouseFilter {
if (this.filters.length === 0) {
return {
@@ -67,6 +67,7 @@ export const createFilterFromFilterState = (
operator: frontEndFilter.operator,
value: frontEndFilter.value,
tablePrefix: column.queryPrefix,
clickhouseTypeOverwrite: column.clickhouseTypeOverwrite,
});
case "arrayOptions":
return new ArrayOptionsFilter({
@@ -7,3 +7,16 @@ export {
type FullObservationsWithScores,
type IOAndMetadataOmittedObservations,
} from "./createGenerationsQuery";
export {
FilterList,
StringFilter,
DateTimeFilter,
StringOptionsFilter,
NumberFilter,
ArrayOptionsFilter,
BooleanFilter,
NumberObjectFilter,
StringObjectFilter,
NullFilter,
type ClickhouseOperator,
} from "./clickhouse-sql/clickhouse-filter";
+23
View File
@@ -7,6 +7,7 @@ export enum EventName {
EvaluationExecution = "EvaluationExecution",
LegacyIngestion = "LegacyIngestion",
CloudUsageMetering = "CloudUsageMetering",
ExperimentCreate = "ExperimentCreate",
}
export const LegacyIngestionEventFull = z.object({
@@ -78,6 +79,13 @@ export const EvalExecutionEvent = z.object({
delay: z.number().nullish(),
});
export const ExperimentCreateEventSchema = z.object({
projectId: z.string(),
datasetId: z.string(),
runId: z.string(),
description: z.string().optional(),
});
export type BatchExportJobType = z.infer<typeof BatchExportJobSchema>;
export type TraceUpsertEventType = z.infer<typeof TraceUpsertEventSchema>;
export type DatasetRunItemUpsertEventType = z.infer<
@@ -86,6 +94,9 @@ export type DatasetRunItemUpsertEventType = z.infer<
export type EvalExecutionEventType = z.infer<typeof EvalExecutionEvent>;
export type LegacyIngestionEventType = z.infer<typeof LegacyIngestionEvent>;
export type IngestionEventQueueType = z.infer<typeof IngestionEvent>;
export type ExperimentCreateEventType = z.infer<
typeof ExperimentCreateEventSchema
>;
export const EventBodySchema = z.union([
z.object({
@@ -100,6 +111,10 @@ export const EventBodySchema = z.union([
name: z.literal(EventName.BatchExport),
payload: BatchExportJobSchema,
}),
z.object({
name: z.literal(EventName.ExperimentCreate),
payload: ExperimentCreateEventSchema,
}),
]);
export type EventBodyType = z.infer<typeof EventBodySchema>;
@@ -111,6 +126,7 @@ export enum QueueName {
IngestionQueue = "ingestion-queue", // Process single events with S3-merge
LegacyIngestionQueue = "legacy-ingestion-queue", // Used for batch processing of Ingestion
CloudUsageMeteringQueue = "cloud-usage-metering-queue",
ExperimentCreate = "experiment-create-queue",
}
export enum QueueJobs {
@@ -122,6 +138,7 @@ export enum QueueJobs {
LegacyIngestionJob = "legacy-ingestion-job",
CloudUsageMeteringJob = "cloud-usage-metering-job",
IngestionJob = "ingestion-job",
ExperimentCreateJob = "experiment-create-job",
}
export type TQueueJobTypes = {
@@ -161,4 +178,10 @@ export type TQueueJobTypes = {
payload: IngestionEventQueueType;
name: QueueJobs.IngestionJob;
};
[QueueName.ExperimentCreate]: {
timestamp: Date;
id: string;
payload: ExperimentCreateEventType;
name: QueueJobs.ExperimentCreateJob;
};
};
@@ -0,0 +1,61 @@
import { Queue } from "bullmq";
import { env } from "../..";
import { logger } from "@azure/storage-blob";
import { QueueName, QueueJobs } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
export class CloudUsageMeteringQueue {
private static instance: Queue | null = null;
public static getInstance(): Queue | null {
if (!env.STRIPE_SECRET_KEY) {
return null;
}
if (CloudUsageMeteringQueue.instance) {
return CloudUsageMeteringQueue.instance;
}
const newRedis = createNewRedisInstance({
enableOfflineQueue: false,
...redisQueueRetryOptions,
});
CloudUsageMeteringQueue.instance = newRedis
? new Queue(QueueName.CloudUsageMeteringQueue, {
connection: newRedis,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
attempts: 5,
backoff: {
type: "exponential",
delay: 5000,
},
},
})
: null;
CloudUsageMeteringQueue.instance?.on("error", (err) => {
logger.error("CloudUsageMeteringQueue error", err);
});
if (CloudUsageMeteringQueue.instance) {
CloudUsageMeteringQueue.instance.add(
QueueJobs.CloudUsageMeteringJob,
{},
{
repeat: { pattern: "5 * * * *" },
},
);
CloudUsageMeteringQueue.instance.add(
QueueJobs.CloudUsageMeteringJob,
{},
{},
);
}
return CloudUsageMeteringQueue.instance;
}
}
@@ -27,7 +27,8 @@ export class DatasetRunItemUpsertQueue {
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 10_000,
attempts: 2,
attempts: 5,
delay: 30_000, // 30 seconds
backoff: {
type: "exponential",
delay: 5000,
@@ -26,7 +26,7 @@ export class EvalExecutionQueue {
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 10_000,
attempts: 2,
attempts: 10,
backoff: {
type: "exponential",
delay: 5000,
@@ -0,0 +1,45 @@
import { Queue } from "bullmq";
import { logger } from "../logger";
import { TQueueJobTypes, QueueName } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
export class ExperimentCreateQueue {
private static instance: Queue<
TQueueJobTypes[QueueName.ExperimentCreate]
> | null = null;
public static getInstance(): Queue<
TQueueJobTypes[QueueName.ExperimentCreate]
> | null {
if (ExperimentCreateQueue.instance) return ExperimentCreateQueue.instance;
const newRedis = createNewRedisInstance({
enableOfflineQueue: false,
...redisQueueRetryOptions,
});
ExperimentCreateQueue.instance = newRedis
? new Queue<TQueueJobTypes[QueueName.ExperimentCreate]>(
QueueName.ExperimentCreate,
{
connection: newRedis,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 10_000,
attempts: 2,
backoff: {
type: "exponential",
delay: 5000,
},
},
},
)
: null;
ExperimentCreateQueue.instance?.on("error", (err) => {
logger.error("ExperimentCreateQueue error", err);
});
return ExperimentCreateQueue.instance;
}
}
@@ -0,0 +1,34 @@
import { Queue } from "bullmq";
import { QueueName } from "../queues";
import { BatchExportQueue } from "./batchExport";
import { CloudUsageMeteringQueue } from "./CloudUsageMeteringQueue";
import { DatasetRunItemUpsertQueue } from "./datasetRunItemUpsert";
import { EvalExecutionQueue } from "./evalExecutionQueue";
import { ExperimentCreateQueue } from "./experimentCreateQueue";
import { IngestionQueue } from "./ingestionQueue";
import { LegacyIngestionQueue } from "./legacyIngestion";
import { TraceUpsertQueue } from "./traceUpsert";
export function getQueue(queueName: QueueName): Queue | null {
switch (queueName) {
case QueueName.LegacyIngestionQueue:
return LegacyIngestionQueue.getInstance();
case QueueName.BatchExport:
return BatchExportQueue.getInstance();
case QueueName.CloudUsageMeteringQueue:
return CloudUsageMeteringQueue.getInstance();
case QueueName.DatasetRunItemUpsert:
return DatasetRunItemUpsertQueue.getInstance();
case QueueName.EvaluationExecution:
return EvalExecutionQueue.getInstance();
case QueueName.ExperimentCreate:
return ExperimentCreateQueue.getInstance();
case QueueName.TraceUpsert:
return TraceUpsertQueue.getInstance();
case QueueName.IngestionQueue:
return IngestionQueue.getInstance();
default:
const exhaustiveCheckDefault: never = queueName;
throw new Error(`Queue ${queueName} not found`);
}
}
@@ -32,6 +32,7 @@ export class TraceUpsertQueue {
removeOnComplete: 100, // Important: If not true, new jobs for that ID would be ignored as jobs in the complete set are still considered as part of the queue
removeOnFail: 100_000,
attempts: 5,
delay: 10_000, // 10 seconds
backoff: {
type: "exponential",
delay: 5000,
@@ -48,43 +49,3 @@ export class TraceUpsertQueue {
return TraceUpsertQueue.instance;
}
}
export function convertTraceUpsertEventsToRedisEvents(
events: TraceUpsertEventType[],
) {
const uniqueTracesPerProject = events.reduce((acc, event) => {
if (!acc.get(event.projectId)) {
acc.set(event.projectId, new Set());
}
acc.get(event.projectId)?.add(event.traceId);
return acc;
}, new Map<string, Set<string>>());
return [...uniqueTracesPerProject.entries()]
.map((tracesPerProject) => {
const [projectId, traceIds] = tracesPerProject;
return [...traceIds].map((traceId) => ({
name: QueueJobs.TraceUpsert,
data: {
payload: {
projectId,
traceId,
},
id: randomUUID(),
timestamp: new Date(),
name: QueueJobs.TraceUpsert as const,
},
opts: {
removeOnFail: 1_000,
removeOnComplete: true,
attempts: 5,
backoff: {
type: "exponential",
delay: 1000,
},
},
}));
})
.flat();
}
@@ -5,16 +5,19 @@ import {
} from "../clickhouse/client";
import { logger } from "../logger";
import { instrumentAsync } from "../instrumentation";
import { S3StorageService } from "../services/S3StorageService";
import {
StorageService,
StorageServiceFactory,
} from "../services/StorageService";
import { randomUUID } from "crypto";
import { getClickhouseEntityType } from "../clickhouse/schemaUtils";
import { NodeClickHouseClientConfigOptions } from "@clickhouse/client/dist/config";
let s3StorageServiceClient: S3StorageService;
let s3StorageServiceClient: StorageService;
const getS3StorageServiceClient = (bucketName: string): S3StorageService => {
const getS3StorageServiceClient = (bucketName: string): StorageService => {
if (!s3StorageServiceClient) {
s3StorageServiceClient = new S3StorageService({
s3StorageServiceClient = StorageServiceFactory.getInstance({
bucketName,
accessKeyId: env.LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID,
secretAccessKey: env.LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY,
@@ -29,7 +32,7 @@ const getS3StorageServiceClient = (bucketName: string): S3StorageService => {
export async function upsertClickhouse<
T extends Record<string, unknown>,
>(opts: {
table: "scores" | "traces"; // TODO: Modify eventType logic to support more tables going forward
table: "scores" | "traces" | "observations";
records: T[];
eventBodyMapper: (body: T) => Record<string, unknown>;
}): Promise<void> {
@@ -37,10 +40,6 @@ export async function upsertClickhouse<
// https://opentelemetry.io/docs/specs/semconv/database/database-spans/
span.setAttribute("ch.query.table", opts.table);
// drop trailing s and pretend it's always a create.
// Only applicable to scores and traces.
const eventType = `${opts.table.slice(0, -1)}-create`;
// If event upload is enabled, we store all rows in S3 to have a backup
if (env.LANGFUSE_S3_EVENT_UPLOAD_ENABLED === "true") {
if (env.LANGFUSE_S3_EVENT_UPLOAD_BUCKET === undefined) {
@@ -51,6 +50,13 @@ export async function upsertClickhouse<
);
await Promise.all(
opts.records.map((record) => {
// drop trailing s and pretend it's always a create.
// Only applicable to scores and traces.
let eventType = `${opts.table.slice(0, -1)}-create`;
if (opts.table === "observations") {
// @ts-ignore - If it's an observation we now that `type` is a string
eventType = `${record["type"].toLowerCase()}-create`;
}
s3Client.uploadJson(
`${env.LANGFUSE_S3_EVENT_UPLOAD_PREFIX}${record.project_id}/${getClickhouseEntityType(eventType)}/${record.id}/${randomUUID()}.json`,
[
@@ -129,8 +129,8 @@ export const scoreRecordBaseSchema = z.object({
project_id: z.string(),
trace_id: z.string(),
observation_id: z.string().nullish(),
name: z.string().nullish(),
value: z.union([z.number(), z.string()]).nullish(),
name: z.string(),
value: z.number().nullish(),
source: z.string(),
comment: z.string().nullish(),
author_user_id: z.string().nullish(),
@@ -2,6 +2,7 @@ import {
commandClickhouse,
parseClickhouseUTCDateTimeFormat,
queryClickhouse,
upsertClickhouse,
} from "./clickhouse";
import { ObservationLevel } from "@prisma/client";
import { logger } from "../logger";
@@ -38,6 +39,58 @@ import {
TRACE_TO_OBSERVATIONS_INTERVAL,
} from "./constants";
export const checkObservationExists = async (
projectId: string,
id: string,
startTime: Date | undefined,
): Promise<boolean> => {
const query = `
SELECT id, project_id
FROM observations o
WHERE project_id = {projectId: String}
AND id = {id: String}
${startTime ? `AND start_time >= {startTime: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}` : ""}
ORDER BY event_ts DESC
LIMIT 1 BY id, project_id
`;
const rows = await queryClickhouse<{ id: string; project_id: string }>({
query,
params: {
id,
projectId,
...(startTime
? { startTime: convertDateToClickhouseDateTime(startTime) }
: {}),
},
});
return rows.length > 0;
};
/**
* Accepts a trace in a Clickhouse-ready format.
* id, project_id, and timestamp must always be provided.
*/
export const upsertObservation = async (
observation: Partial<ObservationRecordReadType>,
) => {
if (
!["id", "project_id", "start_time", "type"].every(
(key) => key in observation,
)
) {
throw new Error(
"Identifier fields must be provided to upsert Observation.",
);
}
await upsertClickhouse({
table: "observations",
records: [observation as ObservationRecordReadType],
eventBodyMapper: convertObservation,
});
};
export const getObservationsViewForTrace = async (
traceId: string,
projectId: string,
@@ -94,6 +147,65 @@ export const getObservationsViewForTrace = async (
return records.map(convertObservationToView);
};
export const getObservationForTraceIdByName = async (
traceId: string,
projectId: string,
name: string,
timestamp?: Date,
fetchWithInputOutput: boolean = false,
) => {
const query = `
SELECT
id,
trace_id,
project_id,
type,
parent_observation_id,
start_time,
end_time,
name,
metadata,
level,
status_message,
version,
${fetchWithInputOutput ? "input, output," : ""}
provided_model_name,
internal_model_id,
model_parameters,
provided_usage_details,
usage_details,
provided_cost_details,
cost_details,
total_cost,
completion_start_time,
prompt_id,
prompt_name,
prompt_version,
created_at,
updated_at,
event_ts
FROM observations
WHERE trace_id = {traceId: String}
AND project_id = {projectId: String}
AND name = {name: String}
${timestamp ? `AND start_time >= {traceTimestamp: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
ORDER BY event_ts DESC
LIMIT 1 BY id, project_id`;
const records = await queryClickhouse<ObservationRecordReadType>({
query,
params: {
traceId,
projectId,
name,
...(timestamp
? { traceTimestamp: convertDateToClickhouseDateTime(timestamp) }
: {}),
},
});
return records.map(convertObservationToView);
};
export const getObservationById = async (
id: string,
projectId: string,
@@ -3,11 +3,13 @@ import {
ObservationView,
ObservationType,
ObservationLevel,
Prisma,
} from "@prisma/client";
import Decimal from "decimal.js";
import { jsonSchema } from "../../utils/zod";
import { parseClickhouseUTCDateTimeFormat } from "./clickhouse";
import { ObservationRecordReadType } from "./definitions";
import { parseJsonPrioritised } from "../../utils/json";
import { jsonSchema } from "../../utils/zod";
export const convertObservationToView = (
record: ObservationRecordReadType,
@@ -56,8 +58,12 @@ export const convertObservation = (
level: record.level as ObservationLevel,
statusMessage: record.status_message ?? null,
version: record.version ?? null,
input: jsonSchema.nullish().parse(record.input) ?? null,
output: jsonSchema.nullish().parse(record.output) ?? null,
input: (record.input
? jsonSchema.parse(parseJsonPrioritised(record.input))
: null) as Prisma.JsonValue | null,
output: (record.output
? jsonSchema.parse(parseJsonPrioritised(record.output))
: null) as Prisma.JsonValue | null,
modelParameters: record.model_parameters
? JSON.parse(record.model_parameters)
: null,
@@ -24,28 +24,7 @@ import {
} from "./scores_converters";
import { SCORE_TO_TRACE_OBSERVATIONS_INTERVAL } from "./constants";
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
export type FetchScoresReturnType = {
id: string;
timestamp: string;
project_id: string;
trace_id: string;
observation_id: string | null;
name: string;
value: number;
source: string;
comment: string | null;
author_user_id: string | null;
config_id: string | null;
data_type: string;
string_value: string | null;
queue_id: string | null;
created_at: string;
updated_at: string;
event_ts: string;
is_deleted: number;
projectId: string;
};
import { ScoreRecordReadType } from "./definitions";
export const searchExistingAnnotationScore = async (
projectId: string,
@@ -74,7 +53,7 @@ export const searchExistingAnnotationScore = async (
LIMIT 1
`;
const rows = await queryClickhouse<FetchScoresReturnType>({
const rows = await queryClickhouse<ScoreRecordReadType>({
query,
params: {
projectId,
@@ -103,7 +82,7 @@ export const getScoreById = async (
LIMIT 1
`;
const rows = await queryClickhouse<FetchScoresReturnType>({
const rows = await queryClickhouse<ScoreRecordReadType>({
query,
params: {
projectId,
@@ -118,13 +97,13 @@ export const getScoreById = async (
* Accepts a score in a Clickhouse-ready format.
* id, project_id, name, and timestamp must always be provided.
*/
export const upsertScore = async (score: Partial<FetchScoresReturnType>) => {
export const upsertScore = async (score: Partial<ScoreRecordReadType>) => {
if (!["id", "project_id", "name", "timestamp"].every((key) => key in score)) {
throw new Error("Identifier fields must be provided to upsert Score.");
}
await upsertClickhouse({
table: "scores",
records: [score as FetchScoresReturnType],
records: [score as ScoreRecordReadType],
eventBodyMapper: convertToScore,
});
};
@@ -148,7 +127,7 @@ export const getScoresForTraces = async (
${limit && offset ? `limit {limit: Int32} offset {offset: Int32}` : ""}
`;
const rows = await queryClickhouse<FetchScoresReturnType>({
const rows = await queryClickhouse<ScoreRecordReadType>({
query: query,
params: {
projectId,
@@ -181,7 +160,7 @@ export const getScoresForObservations = async (
${limit !== undefined && offset !== undefined ? `limit {limit: Int32} offset {offset: Int32}` : ""}
`;
const rows = await queryClickhouse<FetchScoresReturnType>({
const rows = await queryClickhouse<ScoreRecordReadType>({
query: query,
params: {
projectId: projectId,
@@ -1,5 +1,5 @@
import { ScoreSource, ScoreDataType } from "@prisma/client";
import { FetchScoresReturnType } from "./scores";
import { ScoreRecordReadType } from "./definitions";
export type ScoreAggregation = {
id: string;
@@ -11,22 +11,22 @@ export type ScoreAggregation = {
comment: string | null;
};
export const convertToScore = (row: FetchScoresReturnType) => {
export const convertToScore = (row: ScoreRecordReadType) => {
return {
id: row.id,
timestamp: new Date(row.timestamp),
projectId: row.project_id,
traceId: row.trace_id,
observationId: row.observation_id,
observationId: row.observation_id ?? null,
name: row.name,
value: row.value,
value: row.value ?? null,
source: row.source as ScoreSource,
comment: row.comment,
authorUserId: row.author_user_id,
configId: row.config_id,
comment: row.comment ?? null,
authorUserId: row.author_user_id ?? null,
configId: row.config_id ?? null,
dataType: row.data_type as ScoreDataType,
stringValue: row.string_value,
queueId: row.queue_id,
stringValue: row.string_value ?? null,
queueId: row.queue_id ?? null,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
};
@@ -12,6 +12,7 @@ import { FilterState } from "../../types";
import {
DateTimeFilter,
FilterList,
StringFilter,
} from "../queries/clickhouse-sql/clickhouse-filter";
import { TraceRecordReadType } from "./definitions";
import { tracesTableUiColumnDefinitions } from "../../tableDefinitions/mapTracesTable";
@@ -25,6 +26,48 @@ import { clickhouseSearchCondition } from "../queries/clickhouse-sql/search";
import { TRACE_TO_OBSERVATIONS_INTERVAL } from "./constants";
import { FetchTracesTableProps } from "../services/traces-ui-table-service";
export const checkTraceExists = async (
projectId: string,
traceId: string,
timestamp: Date | undefined,
filter: FilterState,
): Promise<boolean> => {
const { tracesFilter } = getProjectIdDefaultFilter(projectId, {
tracesPrefix: "t",
});
tracesFilter.push(
...createFilterFromFilterState(filter, tracesTableUiColumnDefinitions),
new StringFilter({
clickhouseTable: "t",
field: "id",
operator: "=",
value: traceId,
}),
);
const tracesFilterRes = tracesFilter.apply();
const query = `
SELECT id, project_id
FROM traces t FINAL
WHERE ${tracesFilterRes.query}
${timestamp ? `AND timestamp >= {timestamp: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
`;
const rows = await queryClickhouse<{ id: string; project_id: string }>({
query,
params: {
...tracesFilterRes.params,
...(timestamp
? { timestamp: convertDateToClickhouseDateTime(timestamp) }
: {}),
},
});
return rows.length > 0;
};
/**
* Accepts a trace in a Clickhouse-ready format.
* id, project_id, and timestamp must always be provided.
@@ -81,7 +81,7 @@ export const convertToDomain = (row: TracesTableReturnType) => {
version: row.version ?? null,
userId: row.user_id ?? null,
sessionId: row.session_id ?? null,
latencyMilliseconds: Number(row.latency_milliseconds),
latency: Number(row.latency),
usageDetails: row.usage_details,
costDetails: row.cost_details,
level: row.level,
@@ -1,172 +0,0 @@
import type { Readable } from "stream";
import {
GetObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { logger } from "../logger";
type UploadFile = {
fileName: string;
fileType: string;
data: Readable | string;
expiresInSeconds: number;
};
export class S3StorageService {
private client: S3Client;
private bucketName: string;
constructor(params: {
accessKeyId: string | undefined;
secretAccessKey: string | undefined;
bucketName: string;
endpoint: string | undefined;
region: string | undefined;
forcePathStyle: boolean;
}) {
// Use accessKeyId and secretAccessKey if provided or fallback to default credentials
const { accessKeyId, secretAccessKey } = params;
const credentials =
accessKeyId !== undefined && secretAccessKey !== undefined
? {
accessKeyId,
secretAccessKey,
}
: undefined;
this.client = new S3Client({
credentials,
endpoint: params.endpoint,
region: params.region,
forcePathStyle: params.forcePathStyle,
});
this.bucketName = params.bucketName;
}
public async uploadFile({
fileName,
fileType,
data,
expiresInSeconds,
}: UploadFile): Promise<{ signedUrl: string }> {
try {
await new Upload({
client: this.client,
params: {
Bucket: this.bucketName,
Key: fileName,
Body: data,
ContentType: fileType,
},
}).done();
const signedUrl = await this.getSignedUrl(fileName, expiresInSeconds);
return { signedUrl };
} catch (err) {
logger.error(`Failed to upload file to ${fileName}`, err);
throw new Error("Failed to upload to S3 or generate signed URL");
}
}
public async uploadJson(path: string, body: Record<string, unknown>[]) {
const putCommand = new PutObjectCommand({
Bucket: this.bucketName,
Key: path,
Body: JSON.stringify(body),
ContentType: "application/json",
});
try {
await this.client.send(putCommand);
} catch (err) {
logger.error(`Failed to upload JSON to S3 ${path}`, err);
throw Error("Failed to upload JSON to S3");
}
}
public async download(path: string): Promise<string> {
const getCommand = new GetObjectCommand({
Bucket: this.bucketName,
Key: path,
});
try {
const response = await this.client.send(getCommand);
return (await response.Body?.transformToString()) ?? "";
} catch (err) {
logger.error(`Failed to download file from S3 ${path}`, err);
throw Error("Failed to download file from S3");
}
}
public async listFiles(prefix: string): Promise<string[]> {
const listCommand = new ListObjectsV2Command({
Bucket: this.bucketName,
Prefix: prefix,
});
try {
const response = await this.client.send(listCommand);
return (
response.Contents?.flatMap((file) => (file.Key ? [file.Key] : [])) ?? []
);
} catch (err) {
logger.error(`Failed to list files from S3 ${prefix}`, err);
throw Error("Failed to list files from S3");
}
}
public async getSignedUrl(
fileName: string,
ttlSeconds: number,
asAttachment: boolean = true,
): Promise<string> {
try {
return await getSignedUrl(
this.client,
new GetObjectCommand({
Bucket: this.bucketName,
Key: fileName,
ResponseContentDisposition: asAttachment
? `attachment; filename="${fileName}"`
: undefined,
}),
{ expiresIn: ttlSeconds },
);
} catch (err) {
logger.error(`Failed to generate presigned URL for ${fileName}`, err);
throw Error("Failed to generate signed URL");
}
}
public async getSignedUploadUrl(params: {
path: string;
ttlSeconds: number;
sha256Hash: string;
contentType: string;
contentLength: number;
}): Promise<string> {
const { path, ttlSeconds, contentType, contentLength, sha256Hash } = params;
return await getSignedUrl(
this.client,
new PutObjectCommand({
Bucket: this.bucketName,
Key: path,
ContentType: contentType,
ChecksumSHA256: sha256Hash,
ContentLength: contentLength,
}),
{
expiresIn: ttlSeconds,
signableHeaders: new Set(["content-type", "content-length"]),
unhoistableHeaders: new Set(["x-amz-checksum-sha256"]),
},
);
}
}
@@ -0,0 +1,432 @@
import { Readable } from "stream";
import {
GetObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import {
BlobSASPermissions,
BlobServiceClient,
ContainerClient,
StorageSharedKeyCredential,
} from "@azure/storage-blob";
import { logger } from "../logger";
import { env } from "../../env";
type UploadFile = {
fileName: string;
fileType: string;
data: Readable | string;
expiresInSeconds: number;
};
export interface StorageService {
uploadFile(params: UploadFile): Promise<{ signedUrl: string }>;
uploadJson(path: string, body: Record<string, unknown>[]): Promise<void>;
download(path: string): Promise<string>;
listFiles(prefix: string): Promise<string[]>;
getSignedUrl(
fileName: string,
ttlSeconds: number,
asAttachment?: boolean,
): Promise<string>;
getSignedUploadUrl(params: {
path: string;
ttlSeconds: number;
sha256Hash: string;
contentType: string;
contentLength: number;
}): Promise<string>;
}
export class StorageServiceFactory {
public static getInstance(params: {
accessKeyId: string | undefined;
secretAccessKey: string | undefined;
bucketName: string;
endpoint: string | undefined;
region: string | undefined;
forcePathStyle: boolean;
}): StorageService {
if (env.LANGFUSE_USE_AZURE_BLOB === "true") {
return new AzureBlobStorageService(params);
}
return new S3StorageService(params);
}
}
class AzureBlobStorageService implements StorageService {
private client: ContainerClient;
private container: string;
constructor(params: {
accessKeyId: string | undefined;
secretAccessKey: string | undefined;
bucketName: string;
endpoint: string | undefined;
region: string | undefined;
forcePathStyle: boolean;
}) {
const { accessKeyId, secretAccessKey, endpoint } = params;
if (!accessKeyId || !secretAccessKey || !endpoint) {
throw new Error(
`Endpoint, account and account key must be configured to use Azure Blob Storage`,
);
}
const sharedKeyCredential = new StorageSharedKeyCredential(
accessKeyId,
secretAccessKey,
);
const blobServiceClient = new BlobServiceClient(
endpoint,
sharedKeyCredential,
);
this.container = params.bucketName;
this.client = blobServiceClient.getContainerClient(this.container);
}
private async createContainerIfNotExists(): Promise<void> {
try {
await this.client.createIfNotExists();
} catch (err) {
logger.error(
`Failed to create Azure Blob Storage container ${this.container}`,
err,
);
throw Error("Failed to create Azure Blob Storage container ");
}
}
public async uploadFile(params: UploadFile): Promise<{ signedUrl: string }> {
const { fileName, data, expiresInSeconds } = params;
try {
await this.createContainerIfNotExists();
const blockBlobClient = this.client.getBlockBlobClient(fileName);
if (typeof data === "string") {
await blockBlobClient.upload(data, data.length);
} else if (data instanceof Readable) {
let offset = 0;
const blockIds = [];
for await (const chunk of data) {
const blockId = Buffer.from(`block-${offset}`).toString("base64");
const bufferChunk = Buffer.isBuffer(chunk)
? chunk
: Buffer.from(chunk);
await blockBlobClient.stageBlock(
blockId,
bufferChunk,
bufferChunk.length,
);
blockIds.push(blockId);
offset += bufferChunk.length;
}
await blockBlobClient.commitBlockList(blockIds);
} else {
throw new Error("Unsupported data type. Must be Readable or string.");
}
return {
signedUrl: await this.getSignedUrl(fileName, expiresInSeconds, false),
};
} catch (err) {
logger.error(
`Failed to upload file to Azure Blob Storage ${fileName}`,
err,
);
throw Error("Failed to upload file to Azure Blob Storage");
}
}
public async uploadJson(
path: string,
body: Record<string, unknown>[],
): Promise<void> {
await this.createContainerIfNotExists();
const blockBlobClient = this.client.getBlockBlobClient(path);
const content = JSON.stringify(body);
try {
await blockBlobClient.upload(content, content.length);
} catch (err) {
logger.error(`Failed to upload JSON to Azure Blob Storage ${path}`, err);
throw Error("Failed to upload JSON to Azure Blob Storage");
}
}
private async streamToString(
readableStream: NodeJS.ReadableStream,
): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: string[] = [];
readableStream.on("data", (data) => {
chunks.push(data.toString());
});
readableStream.on("end", () => {
resolve(chunks.join(""));
});
readableStream.on("error", reject);
});
}
public async download(path: string): Promise<string> {
try {
await this.createContainerIfNotExists();
const blobClient = this.client.getBlobClient(path);
const downloadResponse = await blobClient.download();
if (!downloadResponse.readableStreamBody) {
throw Error("No stream body available");
}
return this.streamToString(downloadResponse.readableStreamBody);
} catch (err) {
logger.error(
`Failed to download file from Azure Blob Storage ${path}`,
err,
);
throw Error("Failed to download file from Azure Blob Storage");
}
}
public async listFiles(prefix: string): Promise<string[]> {
try {
await this.createContainerIfNotExists();
const result = await this.client.listBlobsFlat({ prefix });
const files = [];
for await (const blob of result) {
if (blob.name.startsWith(prefix)) {
files.push(blob.name);
}
}
return files;
} catch (err) {
logger.error(
`Failed to list files from Azure Blob Storage ${prefix}`,
err,
);
throw Error("Failed to list files from Azure Blob Storage");
}
}
public async getSignedUrl(
fileName: string,
ttlSeconds: number,
asAttachment?: boolean,
): Promise<string> {
try {
await this.createContainerIfNotExists();
const blockBlobClient = this.client.getBlockBlobClient(fileName);
return blockBlobClient.generateSasUrl({
permissions: BlobSASPermissions.parse("r"),
expiresOn: new Date(Date.now() + ttlSeconds * 1000),
contentDisposition: asAttachment
? `attachment; filename="${fileName}"`
: undefined,
});
} catch (err) {
logger.error(
`Failed to generate presigned URL for Azure Blob Storage ${fileName}`,
err,
);
throw Error("Failed to generate presigned URL for Azure Blob Storage");
}
}
public async getSignedUploadUrl(params: {
path: string;
ttlSeconds: number;
sha256Hash: string;
contentType: string;
contentLength: number;
}): Promise<string> {
const { path, ttlSeconds, contentType } = params;
try {
await this.createContainerIfNotExists();
const blockBlobClient = this.client.getBlockBlobClient(path);
return blockBlobClient.generateSasUrl({
permissions: BlobSASPermissions.parse("w"),
expiresOn: new Date(Date.now() + ttlSeconds * 1000),
contentType: contentType,
});
} catch (err) {
logger.error(
`Failed to generate presigned upload URL for Azure Blob Storage ${path}`,
err,
);
throw Error(
"Failed to generate presigned upload URL for Azure Blob Storage",
);
}
}
}
class S3StorageService implements StorageService {
private client: S3Client;
private bucketName: string;
constructor(params: {
accessKeyId: string | undefined;
secretAccessKey: string | undefined;
bucketName: string;
endpoint: string | undefined;
region: string | undefined;
forcePathStyle: boolean;
}) {
// Use accessKeyId and secretAccessKey if provided or fallback to default credentials
const { accessKeyId, secretAccessKey } = params;
const credentials =
accessKeyId !== undefined && secretAccessKey !== undefined
? {
accessKeyId,
secretAccessKey,
}
: undefined;
this.client = new S3Client({
credentials,
endpoint: params.endpoint,
region: params.region,
forcePathStyle: params.forcePathStyle,
});
this.bucketName = params.bucketName;
}
public async uploadFile({
fileName,
fileType,
data,
expiresInSeconds,
}: UploadFile): Promise<{ signedUrl: string }> {
try {
await new Upload({
client: this.client,
params: {
Bucket: this.bucketName,
Key: fileName,
Body: data,
ContentType: fileType,
},
}).done();
const signedUrl = await this.getSignedUrl(fileName, expiresInSeconds);
return { signedUrl };
} catch (err) {
logger.error(`Failed to upload file to ${fileName}`, err);
throw new Error("Failed to upload to S3 or generate signed URL");
}
}
public async uploadJson(path: string, body: Record<string, unknown>[]) {
const putCommand = new PutObjectCommand({
Bucket: this.bucketName,
Key: path,
Body: JSON.stringify(body),
ContentType: "application/json",
});
try {
await this.client.send(putCommand);
} catch (err) {
logger.error(`Failed to upload JSON to S3 ${path}`, err);
throw Error("Failed to upload JSON to S3");
}
}
public async download(path: string): Promise<string> {
const getCommand = new GetObjectCommand({
Bucket: this.bucketName,
Key: path,
});
try {
const response = await this.client.send(getCommand);
return (await response.Body?.transformToString()) ?? "";
} catch (err) {
logger.error(`Failed to download file from S3 ${path}`, err);
throw Error("Failed to download file from S3");
}
}
public async listFiles(prefix: string): Promise<string[]> {
const listCommand = new ListObjectsV2Command({
Bucket: this.bucketName,
Prefix: prefix,
});
try {
const response = await this.client.send(listCommand);
return (
response.Contents?.flatMap((file) => (file.Key ? [file.Key] : [])) ?? []
);
} catch (err) {
logger.error(`Failed to list files from S3 ${prefix}`, err);
throw Error("Failed to list files from S3");
}
}
public async getSignedUrl(
fileName: string,
ttlSeconds: number,
asAttachment: boolean = true,
): Promise<string> {
try {
return await getSignedUrl(
this.client,
new GetObjectCommand({
Bucket: this.bucketName,
Key: fileName,
ResponseContentDisposition: asAttachment
? `attachment; filename="${fileName}"`
: undefined,
}),
{ expiresIn: ttlSeconds },
);
} catch (err) {
logger.error(`Failed to generate presigned URL for ${fileName}`, err);
throw Error("Failed to generate signed URL");
}
}
public async getSignedUploadUrl(params: {
path: string;
ttlSeconds: number;
sha256Hash: string;
contentType: string;
contentLength: number;
}): Promise<string> {
const { path, ttlSeconds, contentType, contentLength, sha256Hash } = params;
return await getSignedUrl(
this.client,
new PutObjectCommand({
Bucket: this.bucketName,
Key: path,
ContentType: contentType,
ChecksumSHA256: sha256Hash,
ContentLength: contentLength,
}),
{
expiresIn: ttlSeconds,
signableHeaders: new Set(["content-type", "content-length"]),
unhoistableHeaders: new Set(["x-amz-checksum-sha256"]),
},
);
}
}
@@ -33,7 +33,7 @@ export type TracesTableReturnType = Pick<
> & {
level: ObservationLevel;
observation_count: number | null;
latency_milliseconds: string | null;
latency: string | null;
usage_details: Record<string, number>;
cost_details: Record<string, number>;
scores_avg: Array<{ name: string; avg_value: number }>;
@@ -89,7 +89,7 @@ export const getTracesTable = async (
t.version,
t.user_id,
t.session_id,
os.latency_milliseconds,
os.latency_milliseconds / 1000 as latency,
os.cost_details as cost_details,
os.usage_details as usage_details,
os.level as level,
@@ -69,6 +69,8 @@ export const observationsTableUiColumnDefinitions: UiColumnMapping[] = [
clickhouseTableName: "observations",
clickhouseSelect:
"if(isNull(completion_start_time), NULL, date_diff('seconds', start_time, completion_start_time))",
// If we use the default of Decimal64(12), we cannot filter for more than ~40min due to an overflow
clickhouseTypeOverwrite: "Decimal64(3)",
},
{
uiTableName: "Latency (s)",
@@ -76,6 +78,8 @@ export const observationsTableUiColumnDefinitions: UiColumnMapping[] = [
clickhouseTableName: "observations",
clickhouseSelect:
"if(isNull(end_time), NULL, date_diff('seconds', start_time, end_time))",
// If we use the default of Decimal64(12), we cannot filter for more than ~40min due to an overflow
clickhouseTypeOverwrite: "Decimal64(3)",
},
{
uiTableName: "Tokens per second",
@@ -106,7 +106,9 @@ export const tracesTableUiColumnDefinitions: UiColumnMapping[] = [
uiTableName: "Latency (s)",
uiTableId: "latency",
clickhouseTableName: "traces",
clickhouseSelect: "latency_milliseconds",
clickhouseSelect: "latency_milliseconds / 1000",
// If we use the default of Decimal64(12), we cannot filter for more than ~40min due to an overflow
clickhouseTypeOverwrite: "Decimal64(3)",
},
{
uiTableName: "Input Cost ($)",
@@ -138,16 +138,16 @@ export const tracesTableCols: ColumnDefinition[] = [
},
];
// Used only for dataset evaluator form, not on any table
export const datasetOnlyCols: ColumnDefinition[] = [
{
name: "Dataset",
id: "datasetId",
type: "stringOptions",
internal: 'di."dataset_id"',
options: [], // to be filled in at runtime
},
];
export const datasetCol: ColumnDefinition = {
name: "Dataset",
id: "datasetId",
type: "stringOptions",
internal: 'di."dataset_id"',
options: [], // to be filled in at runtime
};
// Used only for dataset evaluator, not on dataset table
export const datasetOnlyCols: ColumnDefinition[] = [datasetCol];
export const evalTraceTableCols: ColumnDefinition[] = tracesOnlyCols;
export const evalDatasetFormFilterCols: ColumnDefinition[] = datasetOnlyCols;
@@ -3,6 +3,7 @@ export type UiColumnMapping = {
uiTableId: string;
clickhouseTableName: string;
clickhouseSelect: string;
clickhouseTypeOverwrite?: string;
queryPrefix?: string;
};
+23
View File
@@ -0,0 +1,23 @@
export function getIsCharOrUnderscore(value: string): boolean {
const charOrUnderscore = /^[A-Za-z_]+$/;
return charOrUnderscore.test(value);
}
export function extractVariables(mustacheString: string): string[] {
const mustacheRegex = /\{\{(.*?)\}\}/g;
const uniqueVariables = new Set<string>();
for (const match of mustacheString.matchAll(mustacheRegex)) {
uniqueVariables.add(match[1]);
}
for (const variable of uniqueVariables) {
// if validated fails, remove from set
if (!getIsCharOrUnderscore(variable)) {
uniqueVariables.delete(variable);
}
}
return Array.from(uniqueVariables);
}
+542 -273
View File
File diff suppressed because it is too large Load Diff
+28 -16
View File
@@ -97,6 +97,7 @@ const nextConfig = {
...(env.SENTRY_CSP_REPORT_URI ? [reportToHeader] : []),
],
},
// CSP header
{
source: "/:path((?!api).*)*",
headers: [
@@ -105,26 +106,37 @@ const nextConfig = {
value: cspHeader.replace(/\n/g, ""),
},
],
// Disable CSP on Hugging Face to allow for embedded use of Langfuse
missing: [
{
type: "host",
value: "huggingface.co",
},
{
type: "host",
value: ".*\\.hf\\.space$", // *.hf.space
},
],
},
// Required to check authentication status from langfuse.com
...(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined
? [
{
source: "/api/auth/session",
headers: [
{
key: "Access-Control-Allow-Origin",
value: "https://langfuse.com",
},
{ key: "Access-Control-Allow-Credentials", value: "true" },
{ key: "Access-Control-Allow-Methods", value: "GET,POST" },
{
key: "Access-Control-Allow-Headers",
value: "Content-Type, Authorization",
},
],
},
]
{
source: "/api/auth/session",
headers: [
{
key: "Access-Control-Allow-Origin",
value: "https://langfuse.com",
},
{ key: "Access-Control-Allow-Credentials", value: "true" },
{ key: "Access-Control-Allow-Methods", value: "GET,POST" },
{
key: "Access-Control-Allow-Headers",
value: "Content-Type, Authorization",
},
],
},
]
: []),
// all files in /public/generated are public and can be accessed from any origin, e.g. to render an API reference based on our openapi schema
{
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "2.92.0",
"version": "2.93.2",
"private": true,
"license": "MIT",
"engines": {
+3 -3
View File
@@ -163,12 +163,12 @@ describe("Ingestion Pipeline", () => {
// failure due to missing openai key in the pipeline. Expected
expect(evalExecution.status).toBe(JobExecutionStatus.ERROR);
},
40000,
1000,
50000,
10000,
);
expect(response.status).toBe(207);
}, 50000);
}, 60000);
it("rate limit ingestion", async () => {
// update the org in the database and set the rate limit to 1 for ingestion
@@ -13,6 +13,7 @@ import {
import {
createDatasetRunsTable,
fetchDatasetItems,
getRunItemsByRunIdOrItemId,
} from "@/src/features/datasets/server/service";
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
@@ -71,6 +72,8 @@ describe("Fetch datasets for UI presentation", () => {
const traceId3 = v4();
const traceId4 = v4();
const scoreId = v4();
const scoreId2 = v4();
const scoreId3 = v4();
const scoreName = v4();
await prisma.datasetRunItems.create({
@@ -163,7 +166,28 @@ describe("Fetch datasets for UI presentation", () => {
project_id: projectId,
name: scoreName,
});
await createScores([score]);
const score2 = createScore({
id: scoreId2,
observation_id: null,
trace_id: traceId,
project_id: projectId,
name: scoreName,
value: 1,
comment: "some other comment",
});
const observationId2 = v4(); // this one is not related to a run
const anotherScoreName = v4();
const score3 = createScore({
id: scoreId3,
observation_id: observationId2,
trace_id: traceId,
project_id: projectId,
name: anotherScoreName,
value: 1,
comment: "some other comment for non run related score",
});
await createScores([score, score2, score3]);
const runs = await createDatasetRunsTable({
projectId,
@@ -190,16 +214,21 @@ describe("Fetch datasets for UI presentation", () => {
expect(firstRun.avgLatency).toBeGreaterThanOrEqual(10800);
expect(firstRun.avgTotalCost.toString()).toStrictEqual("275");
const expectedObject = JSON.stringify({
const expectedObject = {
[`${scoreName.replaceAll("-", "_")}-API-NUMERIC`]: {
type: "NUMERIC",
values: [100.5],
average: 100.5,
comment: "comment",
values: expect.arrayContaining([1, 100.5]),
average: 50.75,
},
});
[`${anotherScoreName.replaceAll("-", "_")}-API-NUMERIC`]: {
type: "NUMERIC",
values: expect.arrayContaining([1]),
average: 1,
comment: "some other comment for non run related score",
},
};
expect(JSON.stringify(firstRun.scores)).toEqual(expectedObject);
expect(firstRun.scores).toEqual(expectedObject);
const secondRun = runs.find((run) => run.run_id === datasetRun2Id);
@@ -217,6 +246,245 @@ describe("Fetch datasets for UI presentation", () => {
expect(JSON.stringify(secondRun.scores)).toEqual(JSON.stringify({}));
});
it("should fetch dataset run items for UI", async () => {
const datasetId = v4();
await prisma.dataset.create({
data: {
id: datasetId,
name: v4(),
projectId: projectId,
},
});
const datasetRunId = v4();
await prisma.datasetRuns.create({
data: {
id: datasetRunId,
name: v4(),
datasetId,
metadata: {},
projectId,
},
});
const datasetItemId = v4();
await prisma.datasetItem.create({
data: {
id: datasetItemId,
datasetId,
metadata: {},
projectId,
},
});
const datasetRunItemId = v4();
const traceId = v4();
await prisma.datasetRunItems.create({
data: {
id: datasetRunItemId,
datasetRunId: datasetRunId,
traceId: traceId,
projectId,
datasetItemId,
},
});
const traceId2 = v4();
const observationId = v4();
const datasetRunItemId2 = v4();
await prisma.datasetRunItems.create({
data: {
id: datasetRunItemId2,
datasetRunId: datasetRunId,
traceId: traceId2,
projectId,
datasetItemId,
observationId,
},
});
const trace1 = createTrace({
id: traceId,
project_id: projectId,
});
const trace2 = createTrace({
id: traceId2,
project_id: projectId,
});
await createTraces([trace1, trace2]);
const observation = createObservation({
id: observationId,
trace_id: traceId2,
project_id: projectId,
start_time: new Date().getTime() - 1000 * 60 * 60, // minus 1 min
end_time: new Date().getTime(),
});
const observation2 = createObservation({
trace_id: traceId,
});
await createObservations([observation]);
const score = createScore({
observation_id: observation2.id,
trace_id: traceId2,
project_id: projectId,
});
await createScores([score]);
const runs = await getRunItemsByRunIdOrItemId(
projectId,
// fetch directly from the db to have realistic data.
await prisma.datasetRunItems.findMany({
where: {
id: {
in: [datasetRunItemId, datasetRunItemId2],
},
},
}),
);
expect(runs).toHaveLength(2);
const firstRun = runs.find((run) => run.id === datasetRunItemId);
expect(firstRun).toBeDefined();
if (!firstRun) {
throw new Error("first run is not defined");
}
expect(firstRun.id).toEqual(datasetRunItemId);
expect(firstRun.datasetItemId).toEqual(datasetItemId);
expect(firstRun.observation).toBeUndefined();
expect(firstRun.trace).toBeDefined();
expect(firstRun.trace?.id).toEqual(traceId);
const secondRun = runs.find((run) => run.id === datasetRunItemId2);
expect(secondRun).toBeDefined();
if (!secondRun) {
throw new Error("secondRun is not defined");
}
expect(secondRun.id).toEqual(datasetRunItemId2);
expect(secondRun.datasetItemId).toEqual(datasetItemId);
expect(secondRun.trace?.id).toEqual(traceId2);
expect(secondRun.observation?.id).toEqual(observationId);
const expectedObject = {
[`${score.name.replaceAll("-", "_")}-API-NUMERIC`]: {
type: "NUMERIC",
values: expect.arrayContaining([100.5]),
average: 100.5,
comment: "comment",
},
};
expect(secondRun.scores).toEqual(expectedObject);
});
it("should fetch dataset run items for UI with missing tracing data", async () => {
const datasetId = v4();
await prisma.dataset.create({
data: {
id: datasetId,
name: v4(),
projectId: projectId,
},
});
const datasetRunId = v4();
await prisma.datasetRuns.create({
data: {
id: datasetRunId,
name: v4(),
datasetId,
metadata: {},
projectId,
},
});
const datasetItemId = v4();
await prisma.datasetItem.create({
data: {
id: datasetItemId,
datasetId,
metadata: {},
projectId,
},
});
const datasetRunItemId = v4();
const traceId = v4();
await prisma.datasetRunItems.create({
data: {
id: datasetRunItemId,
datasetRunId: datasetRunId,
traceId: traceId,
projectId,
datasetItemId,
},
});
const traceId2 = v4();
const observationId = v4();
const datasetRunItemId2 = v4();
await prisma.datasetRunItems.create({
data: {
id: datasetRunItemId2,
datasetRunId: datasetRunId,
traceId: traceId2,
projectId,
datasetItemId,
observationId,
},
});
const runs = await getRunItemsByRunIdOrItemId(
projectId,
// fetch directly from the db to have realistic data.
await prisma.datasetRunItems.findMany({
where: {
id: {
in: [datasetRunItemId, datasetRunItemId2],
},
},
}),
);
expect(runs).toHaveLength(2);
const firstRun = runs.find((run) => run.id === datasetRunItemId);
expect(firstRun).toBeDefined();
if (!firstRun) {
throw new Error("first run is not defined");
}
expect(firstRun.id).toEqual(datasetRunItemId);
expect(firstRun.datasetItemId).toEqual(datasetItemId);
expect(firstRun.observation).toBeUndefined();
expect(firstRun.trace).toBeDefined();
expect(firstRun.trace?.id).toEqual(traceId);
const secondRun = runs.find((run) => run.id === datasetRunItemId2);
expect(secondRun).toBeDefined();
if (!secondRun) {
throw new Error("secondRun is not defined");
}
expect(secondRun.id).toEqual(datasetRunItemId2);
expect(secondRun.datasetItemId).toEqual(datasetItemId);
expect(secondRun.trace?.id).toEqual(traceId2);
expect(secondRun.observation?.id).toEqual(observationId);
});
it("should fetch dataset items correctly", async () => {
// Create test data in the database
@@ -1,8 +1,10 @@
import { createObservation } from "@/src/__tests__/fixtures/tracing-factory";
import { createObservations } from "@/src/__tests__/server/repositories/clickhouse-helpers";
import { createObservation as createObservationObject } from "@/src/__tests__/fixtures/tracing-factory";
import { createObservations as createObservationsInClickhouse } from "@/src/__tests__/server/repositories/clickhouse-helpers";
import { makeZodVerifiedAPICall } from "@/src/__tests__/test-utils";
import { GetObservationV1Response } from "@/src/features/public-api/types/observations";
import { v4 } from "uuid";
import { v4 as uuidv4 } from "uuid";
import { GetObservationsV1Response } from "@/src/features/public-api/types/observations";
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
@@ -12,15 +14,17 @@ describe("/api/public/observations API Endpoint", () => {
const observationId = v4();
const traceId = v4();
const observation = createObservation({
const observation = createObservationObject({
id: observationId,
project_id: projectId,
trace_id: traceId,
internal_model_id: "b9854a5c92dc496b997d99d21",
provided_model_name: "gpt-4o-2024-05-13",
input: "input",
output: "output",
});
await createObservations([observation]);
await createObservationsInClickhouse([observation]);
const getEventRes = await makeZodVerifiedAPICall(
GetObservationV1Response,
@@ -33,7 +37,217 @@ describe("/api/public/observations API Endpoint", () => {
type: observation.type,
modelId: observation.internal_model_id,
inputPrice: 0.000005,
input: observation.input,
output: observation.output,
});
});
});
});
describe("/api/public/observations API Endpoint", () => {
it("should fetch all observations", async () => {
const traceId = uuidv4();
const observation = createObservationObject({
id: uuidv4(),
trace_id: traceId,
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
internal_model_id: "clrkwk4cb000408l576jl7koo",
provided_model_name: "gpt-3.5-turbo",
input: JSON.stringify({ key: "input" }),
output: JSON.stringify({ key: "output" }),
usage_details: {
input: 10,
output: 20,
total: 30,
},
version: "2.0.0",
type: "GENERATION",
});
await createObservationsInClickhouse([observation]);
const fetchedObservations = await makeZodVerifiedAPICall(
GetObservationsV1Response,
"GET",
"/api/public/observations?traceId=" + traceId,
undefined,
);
expect(fetchedObservations.status).toBe(200);
expect(fetchedObservations.body.data.length).toBe(1);
expect(fetchedObservations.body.data[0]?.traceId).toBe(traceId);
expect(fetchedObservations.body.data[0]?.input).toEqual({ key: "input" });
expect(fetchedObservations.body.data[0]?.output).toEqual({ key: "output" });
expect(fetchedObservations.body.data[0]?.model).toEqual("gpt-3.5-turbo");
expect(fetchedObservations.body.data[0]?.modelId).toEqual(
"clrkwk4cb000408l576jl7koo",
);
expect(
fetchedObservations.body.data[0]?.calculatedInputCost,
).toBeGreaterThan(0);
expect(
fetchedObservations.body.data[0]?.calculatedOutputCost,
).toBeGreaterThan(0);
expect(
fetchedObservations.body.data[0]?.calculatedTotalCost,
).toBeGreaterThan(0);
});
it("should fetch all observations, filtered by generations", async () => {
const traceId = uuidv4();
const generationObservation = createObservationObject({
id: uuidv4(),
trace_id: traceId,
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
internal_model_id: "model-1",
provided_model_name: "gpt-3.5-turbo",
input: JSON.stringify({ key: "input" }),
output: JSON.stringify({ key: "output" }),
usage_details: {
input: 10,
output: 20,
total: 30,
},
version: "2.0.0",
type: "GENERATION",
});
const spanObservation = createObservationObject({
id: uuidv4(),
trace_id: traceId,
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
input: JSON.stringify({ key: "input" }),
output: JSON.stringify({ key: "output" }),
version: "2.0.0",
type: "SPAN",
});
await createObservationsInClickhouse([
generationObservation,
spanObservation,
]);
const fetchedObservations = await makeZodVerifiedAPICall(
GetObservationsV1Response,
"GET",
"/api/public/observations?type=GENERATION&traceId=" + traceId,
undefined,
);
console.log(fetchedObservations.body);
expect(fetchedObservations.status).toBe(200);
expect(fetchedObservations.body.data.length).toBe(1);
expect(fetchedObservations.body.data[0]?.traceId).toBe(traceId);
expect(fetchedObservations.body.data[0]?.input).toEqual({ key: "input" });
expect(fetchedObservations.body.data[0]?.output).toEqual({ key: "output" });
expect(fetchedObservations.body.data[0]?.type).toEqual("GENERATION");
});
it("GET /observations with timestamp filters and pagination", async () => {
const traceId = v4();
const obs1 = createObservationObject({
id: "observation-2021-01-01",
trace_id: traceId,
name: "generation-name",
start_time: new Date("2021-01-01T00:00:00.000Z").getTime(),
end_time: new Date("2021-01-01T00:00:00.000Z").getTime(),
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
type: "GENERATION",
});
const obs2 = createObservationObject({
id: "observation-2021-02-01",
trace_id: traceId,
name: "generation-name",
start_time: new Date("2021-02-01T00:00:00.000Z").getTime(),
end_time: new Date("2021-02-01T00:00:00.000Z").getTime(),
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
type: "SPAN",
});
const obs3 = createObservationObject({
id: "observation-2021-03-01",
trace_id: traceId,
name: "generation-name",
start_time: new Date("2021-03-01T00:00:00.000Z").getTime(),
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
type: "EVENT",
});
const obs4 = createObservationObject({
id: "observation-2021-04-01",
trace_id: traceId,
name: "generation-name",
start_time: new Date("2021-04-01T00:00:00.000Z").getTime(),
end_time: new Date("2021-04-01T00:00:00.000Z").getTime(),
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
type: "GENERATION",
});
await createObservationsInClickhouse([obs1, obs2, obs3, obs4]);
const fromTimestamp = "2021-02-01T00:00:00.000Z";
const toTimestamp = "2021-04-01T00:00:00.000Z";
// Test with both fromTimestamp and toTimestamp
let fetchedObservations = await makeZodVerifiedAPICall(
GetObservationsV1Response,
"GET",
`/api/public/observations?fromStartTime=${fromTimestamp}&toStartTime=${toTimestamp}&traceId=${traceId}`,
undefined,
);
expect(fetchedObservations.body.data.length).toBe(2);
expect(fetchedObservations.body.data[0]?.id).toBe("observation-2021-03-01");
expect(fetchedObservations.body.data[1]?.id).toBe("observation-2021-02-01");
expect(fetchedObservations.body.meta.totalItems).toBe(2);
// Test with only fromTimestamp
fetchedObservations = await makeZodVerifiedAPICall(
GetObservationsV1Response,
"GET",
`/api/public/observations?fromStartTime=${fromTimestamp}&traceId=${traceId}`,
undefined,
);
expect(fetchedObservations.body.data.length).toBe(3);
expect(fetchedObservations.body.data[0]?.id).toBe("observation-2021-04-01");
expect(fetchedObservations.body.data[1]?.id).toBe("observation-2021-03-01");
expect(fetchedObservations.body.data[2]?.id).toBe("observation-2021-02-01");
expect(fetchedObservations.body.meta.totalItems).toBe(3);
// Test with only toTimestamp
fetchedObservations = await makeZodVerifiedAPICall(
GetObservationsV1Response,
"GET",
`/api/public/observations?toStartTime=${toTimestamp}&traceId=${traceId}`,
undefined,
);
expect(fetchedObservations.body.data.length).toBe(3);
expect(fetchedObservations.body.data[0]?.id).toBe("observation-2021-03-01");
expect(fetchedObservations.body.data[1]?.id).toBe("observation-2021-02-01");
expect(fetchedObservations.body.data[2]?.id).toBe("observation-2021-01-01");
expect(fetchedObservations.body.meta.totalItems).toBe(3);
// test pagination only
fetchedObservations = await makeZodVerifiedAPICall(
GetObservationsV1Response,
"GET",
`/api/public/observations?limit=1&page=2&traceId=${traceId}`,
undefined,
);
expect(fetchedObservations.body.data.length).toBe(1);
expect(fetchedObservations.body.data[0]?.id).toBe("observation-2021-03-01");
expect(fetchedObservations.body.meta).toMatchObject({
totalItems: 4,
totalPages: 4,
page: 2,
limit: 1,
});
});
});
@@ -1,13 +1,27 @@
import { createScores } from "@/src/__tests__/server/repositories/clickhouse-helpers";
import { makeZodVerifiedAPICall } from "@/src/__tests__/test-utils";
import { GetScoreResponse } from "@langfuse/shared";
import {
createObservation,
createScore,
createTrace,
} from "@/src/__tests__/fixtures/tracing-factory";
import {
createObservations,
createScores,
createTraces,
} from "@/src/__tests__/server/repositories/clickhouse-helpers";
import {
createOrgProjectAndApiKey,
makeZodVerifiedAPICall,
} from "@/src/__tests__/test-utils";
import { GetScoreResponse, GetScoresResponse } from "@langfuse/shared";
import { prisma } from "@langfuse/shared/src/db";
import { v4 } from "uuid";
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
import { z } from "zod";
describe("/api/public/scores API Endpoint", () => {
describe("GET /api/public/scores/:scoreId", () => {
it("should GET a score", async () => {
const { projectId: projectId, auth } = await createOrgProjectAndApiKey();
const scoreId = v4();
const traceId = v4();
const score = {
@@ -33,6 +47,8 @@ describe("/api/public/scores API Endpoint", () => {
GetScoreResponse,
"GET",
`/api/public/scores/${scoreId}`,
undefined,
auth,
);
expect(getScore.status).toBe(200);
@@ -49,3 +65,653 @@ describe("/api/public/scores API Endpoint", () => {
});
});
});
describe("/api/public/scores API Endpoint", () => {
it("should create score for a trace", async () => {
const traceId = v4();
const { projectId: projectId, auth } = await createOrgProjectAndApiKey();
const trace = createTrace({
id: traceId,
project_id: projectId,
});
await createTraces([trace]);
const scoreId = v4();
const score = createScore({
id: scoreId,
project_id: projectId,
trace_id: traceId,
name: "score-name",
value: 100.5,
source: "API",
comment: "comment",
observation_id: null,
});
await createScores([score]);
const fetchedScore = await makeZodVerifiedAPICall(
GetScoreResponse,
"GET",
`/api/public/scores/${scoreId}`,
undefined,
auth,
);
expect(fetchedScore.body?.id).toBe(scoreId);
expect(fetchedScore.body?.traceId).toBe(traceId);
expect(fetchedScore.body?.name).toBe("score-name");
expect(fetchedScore.body?.value).toBe(100.5);
expect(fetchedScore.body?.observationId).toBeNull();
expect(fetchedScore.body?.comment).toBe("comment");
expect(fetchedScore.body?.source).toBe("API");
expect(fetchedScore.body?.projectId).toBe(projectId);
});
it("should GET score with minimal score data and minimal trace data", async () => {
const { projectId, auth } = await createOrgProjectAndApiKey();
const minimalTraceId = v4();
const trace = createTrace({
id: minimalTraceId,
project_id: projectId,
});
await createTraces([trace]);
const minimalScoreId = v4();
const score = createScore({
id: minimalScoreId,
project_id: projectId,
trace_id: minimalTraceId,
name: "score-name",
value: 100.5,
source: "API",
comment: null,
observation_id: null,
});
await createScores([score]);
const fetchedScore = await makeZodVerifiedAPICall(
GetScoreResponse,
"GET",
`/api/public/scores/${minimalScoreId}`,
undefined,
auth,
);
expect(fetchedScore.status).toBe(200);
});
describe("should Filter scores", () => {
let configId = "";
const userId = "user-name";
const traceTags = ["prod", "test"];
const traceTags_2 = ["staging", "dev"];
const scoreName = "score-name";
const queryUserName = `userId=${userId}&name=${scoreName}`;
const traceId = v4();
const traceId_2 = v4();
const traceId_3 = v4();
const generationId = v4();
const scoreId_1 = v4();
const scoreId_2 = v4();
const scoreId_3 = v4();
const scoreId_4 = v4();
const scoreId_5 = v4();
let authentication: string;
let newProjectId: string;
beforeEach(async () => {
const { projectId, auth } = await createOrgProjectAndApiKey();
authentication = auth;
newProjectId = projectId;
const trace = createTrace({
id: traceId,
project_id: newProjectId,
user_id: userId,
tags: traceTags,
});
const trace_2 = createTrace({
id: traceId_2,
project_id: newProjectId,
user_id: userId,
tags: traceTags_2,
});
const trace_3 = createTrace({
id: traceId_3,
project_id: newProjectId,
user_id: userId,
tags: ["staging"],
});
await createTraces([trace, trace_2, trace_3]);
const generation = createObservation({
id: generationId,
project_id: newProjectId,
type: "GENERATION",
});
await createObservations([generation]);
const config = await prisma.scoreConfig.create({
data: {
name: scoreName,
dataType: "NUMERIC",
maxValue: 100,
projectId: newProjectId,
},
});
configId = config.id;
const score1 = createScore({
id: scoreId_1,
project_id: newProjectId,
trace_id: traceId,
name: scoreName,
value: 10.5,
data_type: "NUMERIC",
observation_id: generationId,
config_id: config.id,
comment: "comment",
});
const score2 = createScore({
id: scoreId_2,
project_id: newProjectId,
trace_id: traceId,
name: scoreName,
value: 50.5,
data_type: "NUMERIC",
observation_id: generationId,
comment: "comment",
});
const score3 = createScore({
id: scoreId_3,
project_id: newProjectId,
trace_id: traceId,
name: scoreName,
value: 100.8,
data_type: "NUMERIC",
observation_id: generationId,
comment: "comment",
});
const score4 = createScore({
id: scoreId_4,
project_id: newProjectId,
trace_id: traceId_2,
name: "other-score-name",
value: 0,
string_value: "best",
data_type: "CATEGORICAL",
comment: "comment",
});
const score5 = createScore({
id: scoreId_5,
project_id: newProjectId,
trace_id: traceId_3,
name: "other-score-name",
value: 0,
data_type: "CATEGORICAL",
string_value: "test",
comment: "comment",
});
await createScores([score1, score2, score3, score4, score5]);
});
it("get all scores", async () => {
const getAllScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?${queryUserName}`,
undefined,
authentication,
);
expect(getAllScore.status).toBe(200);
expect(getAllScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 3,
totalPages: 1,
});
for (const val of getAllScore.body.data) {
expect(val).toMatchObject({
traceId: traceId,
observationId: generationId,
});
}
});
it("get all scores for config", async () => {
const getAllScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?configId=${configId}`,
undefined,
authentication,
);
expect(getAllScore.status).toBe(200);
expect(getAllScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 1,
totalPages: 1,
});
for (const val of getAllScore.body.data) {
expect(val).toMatchObject({
traceId: traceId,
observationId: generationId,
configId: configId,
});
}
});
it("get all scores for numeric data type", async () => {
const getAllScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?dataType=NUMERIC`,
undefined,
authentication,
);
expect(getAllScore.status).toBe(200);
expect(getAllScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 3,
totalPages: 1,
});
for (const val of getAllScore.body.data) {
expect(val).toMatchObject({
traceId: traceId,
observationId: generationId,
dataType: "NUMERIC",
});
}
});
it("get all scores for trace tag 'prod'", async () => {
const getAllScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?traceTags=prod`,
undefined,
authentication,
);
expect(getAllScore.status).toBe(200);
expect(getAllScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 3,
totalPages: 1,
});
for (const val of getAllScore.body.data) {
expect(val).toMatchObject({
traceId: traceId,
trace: { tags: ["prod", "test"], userId: "user-name" },
});
}
});
it("get all scores for trace tags 'staging' and 'dev'", async () => {
const getAllScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?traceTags=${["staging", "dev"]}`,
undefined,
authentication,
);
expect(getAllScore.status).toBe(200);
expect(getAllScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 1,
totalPages: 1,
});
for (const val of getAllScore.body.data) {
expect(val).toMatchObject({
traceId: traceId_2,
trace: {
tags: expect.arrayContaining(["dev", "staging"]),
userId: "user-name",
},
});
}
});
describe("should Filter scores by queueId", () => {
describe("queueId filtering", () => {
let queueId: string;
beforeEach(async () => {
queueId = v4();
const score = createScore({
id: v4(),
project_id: newProjectId,
trace_id: traceId,
name: "score-name",
value: 100.5,
source: "ANNOTATION",
comment: "comment",
observation_id: generationId,
queue_id: queueId,
});
const score2 = createScore({
id: v4(),
project_id: newProjectId,
trace_id: traceId,
name: "score-name",
value: 75.0,
source: "ANNOTATION",
comment: "comment",
observation_id: generationId,
queue_id: queueId,
});
await createScores([score, score2]);
});
it("get all scores for queueId", async () => {
const getAllScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?queueId=${queueId}`,
undefined,
authentication,
);
expect(getAllScore.status).toBe(200);
expect(getAllScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 2,
totalPages: 1,
});
for (const val of getAllScore.body.data) {
expect(val).toMatchObject({
traceId: traceId,
observationId: generationId,
queueId: queueId,
source: "ANNOTATION",
});
}
});
});
});
it("test only operator", async () => {
const getScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?${queryUserName}&operator=<`,
undefined,
authentication,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 3,
totalPages: 1,
});
});
it("test only value", async () => {
const getScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?${queryUserName}&value=0.8`,
undefined,
authentication,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 3,
totalPages: 1,
});
});
it("test operator <", async () => {
const getScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?${queryUserName}&operator=<&value=50`,
undefined,
authentication,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 1,
totalPages: 1,
});
expect(getScore.body.data).toMatchObject([
{
id: scoreId_1,
name: scoreName,
value: 10.5,
},
]);
});
it("test operator >", async () => {
const getScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?${queryUserName}&operator=>&value=100`,
undefined,
authentication,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 1,
totalPages: 1,
});
expect(getScore.body.data).toMatchObject([
{
id: scoreId_3,
name: scoreName,
value: 100.8,
},
]);
});
it("test operator <=", async () => {
const getScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?${queryUserName}&operator=<=&value=50.5`,
undefined,
authentication,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 2,
totalPages: 1,
});
expect(getScore.body.data).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: scoreId_2,
name: scoreName,
value: 50.5,
}),
expect.objectContaining({
id: scoreId_1,
name: scoreName,
value: 10.5,
}),
]),
);
});
it("test operator >=", async () => {
const getScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?${queryUserName}&operator=>=&value=50.5`,
undefined,
authentication,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 2,
totalPages: 1,
});
expect(getScore.body.data).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: scoreId_3,
name: scoreName,
value: 100.8,
}),
expect.objectContaining({
id: scoreId_2,
name: scoreName,
value: 50.5,
}),
]),
);
});
it("test operator !=", async () => {
const getScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?${queryUserName}&operator=!=&value=50.5`,
undefined,
authentication,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 2,
totalPages: 1,
});
expect(getScore.body.data).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: scoreId_3,
name: scoreName,
value: 100.8,
}),
expect.objectContaining({
id: scoreId_1,
name: scoreName,
value: 10.5,
}),
]),
);
});
it("test operator =", async () => {
const getScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?${queryUserName}&operator==&value=50.5`,
undefined,
authentication,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 1,
totalPages: 1,
});
expect(getScore.body.data).toMatchObject([
{
id: scoreId_2,
name: scoreName,
value: 50.5,
},
]);
});
it("test invalid operator", async () => {
try {
await makeZodVerifiedAPICall(
z.object({
message: z.string(),
error: z.array(z.object({})),
}),
"GET",
`/api/public/scores?${queryUserName}&operator=op&value=50.5`,
undefined,
authentication,
);
} catch (error) {
expect((error as Error).message).toBe(
`API call did not return 200, returned status 400, body {\"message\":\"Invalid request data\",\"error\":[{\"received\":\"op\",\"code\":\"invalid_enum_value\",\"options\":[\"<\",\">\",\"<=\",\">=\",\"!=\",\"=\"],\"path\":[\"operator\"],\"message\":\"Invalid enum value. Expected '<' | '>' | '<=' | '>=' | '!=' | '=', received 'op'\"}]}`,
);
}
});
it("test invalid value", async () => {
try {
await makeZodVerifiedAPICall(
z.object({
message: z.string(),
error: z.array(z.object({})),
}),
"GET",
`/api/public/scores?${queryUserName}&operator=<&value=myvalue`,
undefined,
authentication,
);
} catch (error) {
expect((error as Error).message).toBe(
'API call did not return 200, returned status 400, body {"message":"Invalid request data","error":[{"code":"invalid_type","expected":"number","received":"nan","path":["value"],"message":"Expected number, received nan"}]}',
);
}
});
it("should filter scores by score IDs", async () => {
const getScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?scoreIds=${scoreId_1},${scoreId_2}`,
undefined,
authentication,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 2,
totalPages: 1,
});
expect(getScore.body.data).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: scoreId_2,
name: scoreName,
value: 50.5,
}),
expect.objectContaining({
id: scoreId_1,
name: scoreName,
value: 10.5,
}),
]),
);
});
});
});
@@ -27,13 +27,17 @@ describe("/api/public/traces API Endpoint", () => {
name: "observation-name",
end_time: new Date().getTime(),
start_time: new Date().getTime() - 1000,
input: "input",
output: "output",
}),
createObservation({
trace_id: createdTrace.id,
project_id: createdTrace.project_id,
name: "observation-name",
name: "observation-name-2",
end_time: new Date().getTime(),
start_time: new Date().getTime() - 100000,
input: "input-2",
output: "output-2",
}),
];
@@ -54,5 +58,19 @@ describe("/api/public/traces API Endpoint", () => {
expect(trace.body.latency).toBe(100);
expect(trace.body.observations.length).toBe(2);
expect(trace.body.scores.length).toBe(0);
expect(trace.body.observations).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "observation-name-2",
input: "input-2",
output: "output-2",
}),
expect.objectContaining({
name: "observation-name",
input: "input",
output: "output",
}),
]),
);
});
});
@@ -43,7 +43,7 @@ describe("Traces table API test", () => {
expect(tableRows[0].userId).toEqual(trace.user_id);
expect(tableRows[0].sessionId).toEqual(trace.session_id);
expect(tableRows[0].public).toEqual(trace.public);
expect(tableRows[0].latencyMilliseconds).toBeGreaterThanOrEqual(0);
expect(tableRows[0].latency).toBeGreaterThanOrEqual(0);
expect(tableRows[0].usageDetails).toEqual({});
expect(tableRows[0].costDetails).toEqual({});
expect(tableRows[0].level).toBeDefined();
@@ -82,7 +82,7 @@ describe("Traces table API test", () => {
expect(tableRows[0].userId).toEqual(trace.user_id);
expect(tableRows[0].sessionId).toEqual(trace.session_id);
expect(tableRows[0].public).toEqual(trace.public);
expect(tableRows[0].latencyMilliseconds).toBeGreaterThanOrEqual(0);
expect(tableRows[0].latency).toBeGreaterThanOrEqual(0);
expect(tableRows[0].usageDetails).toEqual({
input: (obs1.usage_details.input + obs2.usage_details.input).toString(),
output: (
@@ -163,6 +163,19 @@ describe("Traces table API test", () => {
],
expected: [],
},
{
traceInput: {},
observationInput: [],
filterstate: [
{
column: "Latency (s)",
operator: ">" as const,
value: 5_000_000, // Verify that we can pass large values
type: "number" as const,
},
],
expected: [],
},
].forEach(async (testConfig: TestCase) => {
it(`should get a correct trace with filters ${JSON.stringify(testConfig)}`, async () => {
const project_id = v4();
@@ -234,10 +247,8 @@ describe("Traces table API test", () => {
if (expectedTrace.public !== undefined) {
expect(tableRows[index].public).toEqual(expectedTrace.public);
}
if (expectedTrace.latency_milliseconds !== undefined) {
expect(tableRows[index].latencyMilliseconds).toEqual(
expectedTrace.latency_milliseconds,
);
if (expectedTrace.latency !== undefined) {
expect(tableRows[index].latency).toEqual(expectedTrace.latency);
}
if (expectedTrace.usage_details !== undefined) {
expect(tableRows[index].usageDetails).toEqual(
+6 -1
View File
@@ -167,7 +167,12 @@ describe("Media Upload API", () => {
);
result.getDownloadUrlResponse = getDownloadUrlResponse;
if (getDownloadUrlResponse.status !== 200) {
if (
!(
getDownloadUrlResponse.status === 200 ||
getDownloadUrlResponse.status === 201
)
) {
return result;
}
@@ -1,313 +0,0 @@
/** @jest-environment node */
import { v4 as uuidv4 } from "uuid";
import {
makeZodVerifiedAPICall,
pruneDatabase,
} from "@/src/__tests__/test-utils";
import { ModelUsageUnit } from "@langfuse/shared";
import { prisma } from "@langfuse/shared/src/db";
import { GetObservationsV1Response } from "@/src/features/public-api/types/observations";
describe("/api/public/observations API Endpoint", () => {
beforeEach(async () => await pruneDatabase());
afterEach(async () => await pruneDatabase());
it("should fetch all observations", async () => {
await pruneDatabase();
const traceId = uuidv4();
await prisma.trace.create({
data: {
id: traceId,
name: "trace-name",
userId: "user-1",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
},
});
const model = await prisma.model.create({
data: {
id: "model-1",
modelName: "gpt-3.5-turbo",
inputPrice: "0.0000010",
outputPrice: "0.0000020",
totalPrice: "0.1",
matchPattern: "(.*)(gpt-)(35|3.5)(-turbo)?(.*)",
projectId: null,
unit: ModelUsageUnit.Tokens,
},
});
const prompt = await prisma.prompt.create({
data: {
name: "prompt-name",
prompt: "prompt-one",
isActive: false,
version: 1,
project: {
connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" },
},
createdBy: "user-1",
},
});
await prisma.observation.create({
data: {
id: uuidv4(),
traceId: traceId,
name: "generation-name",
startTime: new Date("2021-01-01T00:00:00.000Z"),
endTime: new Date("2021-01-01T00:00:00.000Z"),
model: "gpt-3.5-turbo",
modelParameters: { key: "value" },
input: { key: "input" },
output: { key: "output" },
promptTokens: 10,
completionTokens: 20,
totalTokens: 30,
version: "2.0.0",
type: "GENERATION",
project: {
connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" },
},
internalModel: "gpt-3.5-turbo",
unit: ModelUsageUnit.Tokens,
promptId: prompt.id,
},
});
const fetchedObservations = await makeZodVerifiedAPICall(
GetObservationsV1Response,
"GET",
"/api/public/observations",
undefined,
);
expect(fetchedObservations.status).toBe(200);
expect(fetchedObservations.body.data.length).toBe(1);
expect(fetchedObservations.body.data[0]?.traceId).toBe(traceId);
expect(fetchedObservations.body.data[0]?.input).toEqual({ key: "input" });
expect(fetchedObservations.body.data[0]?.output).toEqual({ key: "output" });
expect(fetchedObservations.body.data[0]?.model).toEqual("gpt-3.5-turbo");
expect(fetchedObservations.body.data[0]?.modelId).toEqual(model.id);
expect(
fetchedObservations.body.data[0]?.calculatedInputCost,
).toBeGreaterThan(0);
expect(
fetchedObservations.body.data[0]?.calculatedOutputCost,
).toBeGreaterThan(0);
expect(
fetchedObservations.body.data[0]?.calculatedTotalCost,
).toBeGreaterThan(0);
expect(fetchedObservations.body.data[0]?.promptId).toBe(prompt.id);
expect(fetchedObservations.body.data[0]?.promptName).toBe(prompt.name);
expect(fetchedObservations.body.data[0]?.promptVersion).toBe(
prompt.version,
);
});
it("should fetch all observations, filtered by generations", async () => {
await pruneDatabase();
const traceId = uuidv4();
await prisma.trace.create({
data: {
id: traceId,
name: "trace-name",
userId: "user-1",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
},
});
await prisma.model.create({
data: {
id: "model-1",
modelName: "gpt-3.5-turbo",
inputPrice: "0.0000010",
outputPrice: "0.0000020",
totalPrice: "0.1",
matchPattern: "(.*)(gpt-)(35|3.5)(-turbo)?(.*)",
projectId: null,
unit: ModelUsageUnit.Tokens,
},
});
await prisma.observation.create({
data: {
id: uuidv4(),
traceId: traceId,
name: "generation-name",
startTime: new Date("2021-01-01T00:00:00.000Z"),
endTime: new Date("2021-01-01T00:00:00.000Z"),
model: "gpt-3.5-turbo",
internalModel: "gpt-3.5-turbo",
modelParameters: { key: "value" },
input: { key: "input" },
output: { key: "output" },
promptTokens: 10,
completionTokens: 20,
totalTokens: 30,
version: "2.0.0",
unit: ModelUsageUnit.Tokens,
type: "GENERATION",
project: {
connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" },
},
},
});
await prisma.observation.create({
data: {
id: uuidv4(),
traceId: traceId,
name: "generation-name",
startTime: new Date("2021-01-01T00:00:00.000Z"),
endTime: new Date("2021-01-01T00:00:00.000Z"),
modelParameters: { key: "value" },
input: { key: "input" },
output: { key: "output" },
version: "2.0.0",
type: "SPAN",
project: {
connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" },
},
},
});
const fetchedObservations = await makeZodVerifiedAPICall(
GetObservationsV1Response,
"GET",
"/api/public/observations?type=GENERATION",
undefined,
);
console.log(fetchedObservations.body);
expect(fetchedObservations.status).toBe(200);
expect(fetchedObservations.body.data.length).toBe(1);
expect(fetchedObservations.body.data[0]?.traceId).toBe(traceId);
expect(fetchedObservations.body.data[0]?.input).toEqual({ key: "input" });
expect(fetchedObservations.body.data[0]?.output).toEqual({ key: "output" });
expect(fetchedObservations.body.data[0]?.type).toEqual("GENERATION");
});
});
it("GET /observations with timestamp filters and pagination", async () => {
await prisma.trace.create({
data: {
id: "trace-id",
name: "trace-name",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
},
});
await prisma.observation.createMany({
data: [
{
id: "observation-2021-01-01",
traceId: "trace-id",
name: "generation-name",
startTime: new Date("2021-01-01T00:00:00.000Z"),
endTime: new Date("2021-01-01T00:00:00.000Z"),
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
type: "GENERATION",
},
{
id: "observation-2021-02-01",
traceId: "trace-id",
name: "generation-name",
startTime: new Date("2021-02-01T00:00:00.000Z"),
endTime: new Date("2021-02-01T00:00:00.000Z"),
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
type: "SPAN",
},
{
id: "observation-2021-03-01",
traceId: "trace-id",
name: "generation-name",
startTime: new Date("2021-03-01T00:00:00.000Z"),
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
type: "EVENT",
},
{
id: "observation-2021-04-01",
traceId: "trace-id",
name: "generation-name",
startTime: new Date("2021-04-01T00:00:00.000Z"),
endTime: new Date("2021-04-01T00:00:00.000Z"),
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
type: "GENERATION",
},
],
});
const fromTimestamp = "2021-02-01T00:00:00.000Z";
const toTimestamp = "2021-04-01T00:00:00.000Z";
// Test with both fromTimestamp and toTimestamp
let fetchedObservations = await makeZodVerifiedAPICall(
GetObservationsV1Response,
"GET",
`/api/public/observations?fromStartTime=${fromTimestamp}&toStartTime=${toTimestamp}`,
undefined,
);
expect(fetchedObservations.body.data.length).toBe(2);
expect(fetchedObservations.body.data[0]?.id).toBe("observation-2021-03-01");
expect(fetchedObservations.body.data[1]?.id).toBe("observation-2021-02-01");
expect(fetchedObservations.body.meta.totalItems).toBe(2);
// Test with only fromTimestamp
fetchedObservations = await makeZodVerifiedAPICall(
GetObservationsV1Response,
"GET",
`/api/public/observations?fromStartTime=${fromTimestamp}`,
undefined,
);
expect(fetchedObservations.body.data.length).toBe(3);
expect(fetchedObservations.body.data[0]?.id).toBe("observation-2021-04-01");
expect(fetchedObservations.body.data[1]?.id).toBe("observation-2021-03-01");
expect(fetchedObservations.body.data[2]?.id).toBe("observation-2021-02-01");
expect(fetchedObservations.body.meta.totalItems).toBe(3);
// Test with only toTimestamp
fetchedObservations = await makeZodVerifiedAPICall(
GetObservationsV1Response,
"GET",
`/api/public/observations?toStartTime=${toTimestamp}`,
undefined,
);
expect(fetchedObservations.body.data.length).toBe(3);
expect(fetchedObservations.body.data[0]?.id).toBe("observation-2021-03-01");
expect(fetchedObservations.body.data[1]?.id).toBe("observation-2021-02-01");
expect(fetchedObservations.body.data[2]?.id).toBe("observation-2021-01-01");
expect(fetchedObservations.body.meta.totalItems).toBe(3);
// test pagination only
fetchedObservations = await makeZodVerifiedAPICall(
GetObservationsV1Response,
"GET",
`/api/public/observations?limit=1&page=2`,
undefined,
);
expect(fetchedObservations.body.data.length).toBe(1);
expect(fetchedObservations.body.data[0]?.id).toBe("observation-2021-03-01");
expect(fetchedObservations.body.meta).toMatchObject({
totalItems: 4,
totalPages: 4,
page: 2,
limit: 1,
});
});
+1 -608
View File
@@ -7,12 +7,7 @@ import {
pruneDatabase,
} from "@/src/__tests__/test-utils";
import { v4 as uuidv4 } from "uuid";
import {
DeleteScoreResponse,
GetScoreResponse,
GetScoresResponse,
} from "@langfuse/shared";
import { z } from "zod";
import { DeleteScoreResponse, GetScoreResponse } from "@langfuse/shared";
import { PostTracesV1Response } from "@/src/features/public-api/types/traces";
const traceId = "de98afa2-89dc-47e9-9924-33f1490fdaf4";
@@ -111,39 +106,6 @@ describe("/api/public/scores API Endpoint", () => {
expect(fetchedScore.body?.observationId).toBeNull();
});
it("should GET score with minimal score data and minimal trace data", async () => {
const minimalTraceId = uuidv4();
await makeAPICall("POST", "/api/public/traces", {
id: minimalTraceId,
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
});
const dbTrace = await prisma.trace.findMany({
where: {
id: minimalTraceId,
},
});
expect(dbTrace.length).toBeGreaterThan(0);
expect(dbTrace[0]?.id).toBe(minimalTraceId);
const minimalScoreId = uuidv4();
await makeAPICall("POST", "/api/public/scores", {
id: minimalScoreId,
name: "score-name",
value: 100,
traceId: minimalTraceId,
});
const fetchedScore = await makeZodVerifiedAPICall(
GetScoreResponse,
"GET",
`/api/public/scores/${minimalScoreId}`,
);
expect(fetchedScore.status).toBe(200);
});
it("should create score for a generation", async () => {
await pruneDatabase();
@@ -826,573 +788,4 @@ describe("/api/public/scores API Endpoint", () => {
});
expect(deletedScore).toBeNull();
});
describe("should Filter scores", () => {
let configId = "";
const userId = "user-name";
const traceTags = ["prod", "test"];
const traceTags_2 = ["staging", "dev"];
const scoreName = "score-name";
const queryUserName = `userId=${userId}&name=${scoreName}`;
const traceId = uuidv4();
const traceId_2 = uuidv4();
const traceId_3 = uuidv4();
const generationId = uuidv4();
const scoreId_1 = uuidv4();
const scoreId_2 = uuidv4();
const scoreId_3 = uuidv4();
const scoreId_4 = uuidv4();
const scoreId_5 = uuidv4();
beforeAll(async () => {
should_prune_db = false;
await pruneDatabase();
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
id: traceId,
userId: userId,
tags: traceTags,
},
);
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
id: traceId_2,
userId: userId,
tags: traceTags_2,
},
);
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
id: traceId_3,
userId: userId,
tags: ["staging"],
},
);
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/generations",
{
id: generationId,
},
);
await makeAPICall("POST", "/api/public/score-configs", {
name: scoreName,
dataType: "NUMERIC",
maxValue: 100,
});
const config = await prisma.scoreConfig.findFirst({
where: {
name: scoreName,
},
});
configId = config?.id ?? "";
await makeAPICall("POST", "/api/public/scores", {
id: scoreId_1,
observationId: generationId,
name: scoreName,
value: 10.5,
traceId: traceId,
comment: "comment",
configId,
});
await makeAPICall("POST", "/api/public/scores", {
id: scoreId_2,
observationId: generationId,
name: scoreName,
value: 50.5,
traceId: traceId,
comment: "comment",
});
await makeAPICall("POST", "/api/public/scores", {
id: scoreId_3,
observationId: generationId,
name: scoreName,
value: 100.8,
traceId: traceId,
comment: "comment",
});
await makeAPICall("POST", "/api/public/scores", {
id: scoreId_4,
name: "other-score-name",
value: "best",
traceId: traceId_2,
comment: "comment",
});
await makeAPICall("POST", "/api/public/scores", {
id: scoreId_5,
name: "other-score-name",
value: "test",
traceId: traceId_3,
comment: "comment",
});
});
afterAll(async () => {
await pruneDatabase();
});
it("get all scores", async () => {
const getAllScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?${queryUserName}`,
);
expect(getAllScore.status).toBe(200);
expect(getAllScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 3,
totalPages: 1,
});
for (const val of getAllScore.body.data) {
expect(val).toMatchObject({
traceId: traceId,
observationId: generationId,
});
}
});
it("get all scores for config", async () => {
const getAllScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?configId=${configId}`,
);
expect(getAllScore.status).toBe(200);
expect(getAllScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 1,
totalPages: 1,
});
for (const val of getAllScore.body.data) {
expect(val).toMatchObject({
traceId: traceId,
observationId: generationId,
configId: configId,
});
}
});
it("get all scores for numeric data type", async () => {
const getAllScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?dataType=${"NUMERIC"}`,
);
expect(getAllScore.status).toBe(200);
expect(getAllScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 3,
totalPages: 1,
});
for (const val of getAllScore.body.data) {
expect(val).toMatchObject({
traceId: traceId,
observationId: generationId,
dataType: "NUMERIC",
});
}
});
it("get all scores for trace tag 'prod'", async () => {
const getAllScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?traceTags=${"prod"}`,
);
expect(getAllScore.status).toBe(200);
expect(getAllScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 3,
totalPages: 1,
});
for (const val of getAllScore.body.data) {
expect(val).toMatchObject({
traceId: traceId,
trace: { tags: ["prod", "test"], userId: "user-name" },
});
}
});
it("get all scores for trace tags 'staging' and 'dev'", async () => {
const getAllScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?traceTags=${["staging", "dev"]}`,
);
expect(getAllScore.status).toBe(200);
expect(getAllScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 1,
totalPages: 1,
});
for (const val of getAllScore.body.data) {
expect(val).toMatchObject({
traceId: traceId_2,
trace: { tags: ["dev", "staging"], userId: "user-name" },
});
}
});
describe("should Filter scores by queueId", () => {
describe("queueId filtering", () => {
let queueId: string;
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
beforeEach(async () => {
queueId = uuidv4();
await Promise.all([
prisma.score.create({
data: {
observationId: generationId,
name: "annotation-score-1",
value: 100.5,
traceId: traceId,
comment: "comment 1",
queueId,
source: "ANNOTATION",
project: { connect: { id: projectId } },
dataType: "NUMERIC",
},
}),
prisma.score.create({
data: {
observationId: generationId,
name: "annotation-score-2",
value: 75.0,
traceId: traceId,
comment: "comment 2",
queueId,
source: "ANNOTATION",
project: { connect: { id: projectId } },
dataType: "NUMERIC",
},
}),
]);
});
afterEach(async () => {
await prisma.score.deleteMany({
where: { queueId, projectId },
});
});
it("get all scores for queueId", async () => {
const getAllScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?queueId=${queueId}`,
);
expect(getAllScore.status).toBe(200);
expect(getAllScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 2,
totalPages: 1,
});
for (const val of getAllScore.body.data) {
expect(val).toMatchObject({
traceId: traceId,
observationId: generationId,
queueId: queueId,
source: "ANNOTATION",
});
}
});
});
});
it("test only operator", async () => {
const getScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?${queryUserName}&operator=<`,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 3,
totalPages: 1,
});
});
it("test only value", async () => {
const getScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?${queryUserName}&value=0.8`,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 3,
totalPages: 1,
});
});
it("test operator <", async () => {
const getScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?${queryUserName}&operator=<&value=50`,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 1,
totalPages: 1,
});
expect(getScore.body.data).toMatchObject([
{
id: scoreId_1,
name: scoreName,
value: 10.5,
},
]);
});
it("test operator >", async () => {
const getScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?${queryUserName}&operator=>&value=100`,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 1,
totalPages: 1,
});
expect(getScore.body.data).toMatchObject([
{
id: scoreId_3,
name: scoreName,
value: 100.8,
},
]);
});
it("test operator <=", async () => {
const getScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?${queryUserName}&operator=<=&value=50.5`,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 2,
totalPages: 1,
});
expect(getScore.body.data).toMatchObject([
{
id: scoreId_2,
name: scoreName,
value: 50.5,
},
{
id: scoreId_1,
name: scoreName,
value: 10.5,
},
]);
});
it("test operator >=", async () => {
const getScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?${queryUserName}&operator=>=&value=50.5`,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 2,
totalPages: 1,
});
expect(getScore.body.data).toMatchObject([
{
id: scoreId_3,
name: scoreName,
value: 100.8,
},
{
id: scoreId_2,
name: scoreName,
value: 50.5,
},
]);
});
it("test operator !=", async () => {
const getScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?${queryUserName}&operator=!=&value=50.5`,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 2,
totalPages: 1,
});
expect(getScore.body.data).toMatchObject([
{
id: scoreId_3,
name: scoreName,
value: 100.8,
},
{
id: scoreId_1,
name: scoreName,
value: 10.5,
},
]);
});
it("test operator =", async () => {
const getScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?${queryUserName}&operator==&value=50.5`,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 1,
totalPages: 1,
});
expect(getScore.body.data).toMatchObject([
{
id: scoreId_2,
name: scoreName,
value: 50.5,
},
]);
});
it("should GET ALL scores with minimal score data and minimal trace data", async () => {
const minimalTraceId = uuidv4();
await makeAPICall("POST", "/api/public/traces", {
id: minimalTraceId,
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
});
const dbTrace = await prisma.trace.findMany({
where: {
id: minimalTraceId,
},
});
expect(dbTrace.length).toBeGreaterThan(0);
expect(dbTrace[0]?.id).toBe(minimalTraceId);
const createScore = await makeAPICall("POST", "/api/public/scores", {
name: "score-name",
value: 100,
traceId: minimalTraceId,
});
expect(createScore.status).toBe(200);
const fetchedScores = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores`,
);
expect(fetchedScores.status).toBe(200);
expect(fetchedScores.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 6,
totalPages: 1,
});
expect(fetchedScores.body.data.length).toBe(6);
});
it("test invalid operator", async () => {
try {
await makeZodVerifiedAPICall(
z.object({
message: z.string(),
error: z.array(z.object({})),
}),
"GET",
`/api/public/scores?${queryUserName}&operator=op&value=50.5`,
);
} catch (error) {
expect((error as Error).message).toBe(
`API call did not return 200, returned status 400, body {\"message\":\"Invalid request data\",\"error\":[{\"received\":\"op\",\"code\":\"invalid_enum_value\",\"options\":[\"<\",\">\",\"<=\",\">=\",\"!=\",\"=\"],\"path\":[\"operator\"],\"message\":\"Invalid enum value. Expected '<' | '>' | '<=' | '>=' | '!=' | '=', received 'op'\"}]}`,
);
}
});
it("test invalid value", async () => {
try {
await makeZodVerifiedAPICall(
z.object({
message: z.string(),
error: z.array(z.object({})),
}),
"GET",
`/api/public/scores?${queryUserName}&operator=<&value=myvalue`,
);
} catch (error) {
expect((error as Error).message).toBe(
'API call did not return 200, returned status 400, body {"message":"Invalid request data","error":[{"code":"invalid_type","expected":"number","received":"nan","path":["value"],"message":"Expected number, received nan"}]}',
);
}
});
it("should filter scores by score IDs", async () => {
const getScore = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores?scoreIds=${scoreId_1},${scoreId_2}`,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 2,
totalPages: 1,
});
expect(getScore.body.data).toMatchObject([
{
id: scoreId_2,
name: scoreName,
value: 50.5,
},
{
id: scoreId_1,
name: scoreName,
value: 10.5,
},
]);
});
});
});
+42 -2
View File
@@ -1,6 +1,11 @@
import { env } from "@/src/env.mjs";
import { prisma } from "@langfuse/shared/src/db";
import { clickhouseClient } from "@langfuse/shared/src/server";
import {
clickhouseClient,
getDisplaySecretKey,
hashSecretKey,
} from "@langfuse/shared/src/server";
import { v4 } from "uuid";
import { type z } from "zod";
export const pruneDatabase = async () => {
@@ -38,7 +43,10 @@ export const pruneDatabase = async () => {
});
};
function createBasicAuthHeader(username: string, password: string): string {
export function createBasicAuthHeader(
username: string,
password: string,
): string {
const base64Credentials = Buffer.from(`${username}:${password}`).toString(
"base64",
);
@@ -130,3 +138,35 @@ export async function makeZodVerifiedAPICallSilent<T extends z.ZodTypeAny>(
return { body: resBody, status };
}
export const createOrgProjectAndApiKey = async () => {
const projectId = v4();
const org = await prisma.organization.create({
data: {
id: v4(),
name: v4(),
},
});
await prisma.project.create({
data: {
id: projectId,
name: v4(),
orgId: org.id,
},
});
const publicKey = v4();
const secretKey = v4();
const auth = createBasicAuthHeader(publicKey, secretKey);
await prisma.apiKey.create({
data: {
id: v4(),
projectId: projectId,
publicKey: publicKey,
hashedSecretKey: await hashSecretKey(secretKey),
displaySecretKey: getDisplaySecretKey(secretKey),
},
});
return { projectId, publicKey, secretKey, auth };
};
+10
View File
@@ -53,6 +53,15 @@ describe("Token Count Functions", () => {
expect(result).toBeUndefined();
});
it("should return token count for strings with special characters", () => {
const result = tokenCount({
model: generateModel("gpt-4-1106-preview", "openai"),
text: "Hello <|endoftext|> World!",
});
expect(result).toBeDefined();
expect(result).toBeGreaterThan(0);
});
it("check extensive openai chat message", () => {
const result = tokenCount({
model: generateModel("gpt-3.5-turbo", "openai"),
@@ -130,6 +139,7 @@ describe("Token Count Functions", () => {
});
expect(result).toBeUndefined();
});
it("should return undefined for undefined text input", () => {
const result = tokenCount({
model: generateModel("gpt-4", "openai"),
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.9 KiB

+9 -2
View File
@@ -23,6 +23,13 @@ type SidebarNotification = {
};
const notifications: SidebarNotification[] = [
{
id: "lw2-5",
title: "Launch Week 2 Day 5",
description: "Introducing Prompt Experiments to test prompts on datasets",
link: "https://langfuse.com/changelog/2024-11-22-prompt-experimentation",
linkTitle: "Changelog",
},
{
id: "lw2-4",
title: "Launch Week 2 Day 4",
@@ -105,7 +112,7 @@ export function SidebarNotifications() {
const currentNotification = notifications[currentNotificationIndex];
return (
<Card className="relative max-h-44 overflow-hidden rounded-md bg-opacity-50 shadow-none group-data-[collapsible=icon]:hidden">
<Card className="relative max-h-60 overflow-hidden rounded-md bg-opacity-50 shadow-none group-data-[collapsible=icon]:hidden">
<Button
variant="ghost"
size="sm"
@@ -148,7 +155,7 @@ export function SidebarNotifications() {
});
}}
>
{currentNotification.linkTitle ?? "Learn more"}
{currentNotification.linkTitle ?? "Learn more"} &rarr;
</Link>
</Button>
)}
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v2.92.0";
export const VERSION = "v2.93.2";
@@ -11,7 +11,6 @@ import {
PopoverTrigger,
} from "@/src/components/ui/popover";
import { useState } from "react";
import { Trash } from "lucide-react";
import TableLink from "@/src/components/table/table-link";
import EvalLogTable from "@/src/ee/features/evals/components/eval-log";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
@@ -20,12 +19,23 @@ import { TableWithMetadataWrapper } from "@/src/components/table/TableWithMetada
import { StatusBadge } from "@/src/components/layouts/status-badge";
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
import { CardDescription } from "@/src/components/ui/card";
import { EvaluatorStatus } from "@/src/ee/features/evals/types";
import { Switch } from "@/src/components/ui/switch";
import { Edit } from "lucide-react";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/src/components/ui/dialog";
export const EvaluatorDetail = () => {
const router = useRouter();
const projectId = router.query.projectId as string;
const evaluatorId = router.query.evaluatorId as string;
const [isEditOpen, setIsEditOpen] = useState(false);
// get the current template by id
const evaluator = api.evals.configById.useQuery({
projectId: projectId,
@@ -63,79 +73,102 @@ export const EvaluatorDetail = () => {
return (
<FullScreenPage>
<>
<Header
title={evaluator.data?.id ?? "Loading..."}
breadcrumb={[
{
name: "Evaluators",
href: `/project/${router.query.projectId as string}/evals`,
},
{ name: evaluator.data?.id },
]}
actionButtons={
<>
<DeactivateEvaluator
projectId={projectId}
evaluator={evaluator.data ?? undefined}
isLoading={evaluator.isLoading}
<Header
title={evaluator.data ? `Evaluator ${evaluator.data.id}` : "Loading..."}
breadcrumb={[
{
name: "Evaluators",
href: `/project/${router.query.projectId as string}/evals`,
},
{ name: evaluator.data?.id },
]}
actionButtons={
<>
<StatusBadge
type={evaluator.data?.status.toLowerCase()}
isLive
className="max-h-8"
/>
<DeactivateEvaluator
projectId={projectId}
evaluator={evaluator.data ?? undefined}
isLoading={evaluator.isLoading}
/>
{evaluator.data && (
<DetailPageNav
key="nav"
currentId={encodeURIComponent(evaluator.data.id)}
path={(entry) =>
`/project/${projectId}/evals/${encodeURIComponent(entry.id)}`
}
listKey="evals"
/>
{evaluator.data && (
<DetailPageNav
key="nav"
currentId={encodeURIComponent(evaluator.data.id)}
path={(entry) =>
`/project/${projectId}/evals/${encodeURIComponent(entry.id)}`
}
listKey="evals"
)}
</>
}
/>
{existingEvaluator && (
<TableWithMetadataWrapper
tableComponent={
<EvalLogTable
projectId={projectId}
jobConfigurationId={existingEvaluator.id}
/>
}
cardTitleChildren={
<div className="flex w-full flex-row items-center justify-between">
<span>Evaluator configuration</span>
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="flex items-center gap-2">
<Edit className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-screen-xl">
<DialogTitle>Edit Evaluator</DialogTitle>
<div className="max-h-[80vh] overflow-y-auto">
<EvaluatorForm
key={existingEvaluator.id}
projectId={projectId}
evalTemplates={allTemplates.data?.templates}
existingEvaluator={existingEvaluator}
shouldWrapVariables={true}
mode="edit"
onFormSuccess={() => {
setIsEditOpen(false);
// Force a reload as the form state is not properly updated
void router.reload();
}}
/>
</div>
</DialogContent>
</Dialog>
</div>
}
cardContentChildren={
<>
<CardDescription className="flex items-center justify-between text-sm">
<span className="text-sm font-medium">Eval Template</span>
<TableLink
path={`/project/${projectId}/evals/templates/${existingEvaluator.evalTemplateId}`}
value={`${existingEvaluator.evalTemplate.name} (v${existingEvaluator.evalTemplate.version})`}
className="flex min-h-6 items-center"
/>
)}
</CardDescription>
<div className="flex w-full flex-col items-start justify-between space-y-2 pb-4">
<EvaluatorForm
key={existingEvaluator.id}
projectId={projectId}
evalTemplates={allTemplates.data?.templates}
existingEvaluator={existingEvaluator}
disabled={true}
shouldWrapVariables={true}
/>
</div>
</>
}
/>
{existingEvaluator && (
<TableWithMetadataWrapper
tableComponent={
<EvalLogTable
projectId={projectId}
jobConfigurationId={existingEvaluator.id}
/>
}
cardTitleChildren={
<div className="flex w-full flex-row items-center justify-between">
<span>Evaluator</span>
<StatusBadge
type={evaluator.data?.status.toLowerCase()}
isLive
className="max-h-8"
/>
</div>
}
cardContentChildren={
<>
<CardDescription className="flex items-center justify-between text-sm">
<span className="text-sm font-medium">Eval Template</span>
<TableLink
path={`/project/${projectId}/evals/templates/${existingEvaluator.evalTemplateId}`}
value={`${existingEvaluator.evalTemplate.name} (v${existingEvaluator.evalTemplate.version})`}
className="flex min-h-6 items-center"
/>
</CardDescription>
<div className="flex w-full flex-col items-start justify-between space-y-2 pb-4">
<EvaluatorForm
key={existingEvaluator.id}
projectId={projectId}
evalTemplates={allTemplates.data?.templates}
existingEvaluator={existingEvaluator}
disabled={true}
shouldWrapVariables={true}
/>
</div>
</>
}
/>
)}
</>
)}
</FullScreenPage>
);
};
@@ -143,7 +176,6 @@ export const EvaluatorDetail = () => {
export function DeactivateEvaluator({
projectId,
evaluator,
isLoading,
}: {
projectId: string;
evaluator?: RouterOutputs["evals"]["configById"];
@@ -153,6 +185,7 @@ export function DeactivateEvaluator({
const hasAccess = useHasProjectAccess({ projectId, scope: "evalJob:CUD" });
const [isOpen, setIsOpen] = useState(false);
const capture = usePostHogClientCapture();
const isActive = evaluator?.status === EvaluatorStatus.ACTIVE;
const mutEvaluator = api.evals.updateEvalJob.useMutation({
onSuccess: () => {
@@ -165,41 +198,50 @@ export function DeactivateEvaluator({
console.error("Project ID is missing");
return;
}
const prevStatus = evaluator?.status;
mutEvaluator.mutateAsync({
projectId,
evalConfigId: evaluator?.id ?? "",
updatedStatus: "INACTIVE",
config: {
status: isActive ? EvaluatorStatus.INACTIVE : EvaluatorStatus.ACTIVE,
},
});
capture("eval_config:delete");
capture(
prevStatus === EvaluatorStatus.ACTIVE
? "eval_config:deactivate"
: "eval_config:activate",
);
setIsOpen(false);
};
return (
<Popover open={isOpen} onOpenChange={() => setIsOpen(!isOpen)}>
<PopoverTrigger asChild>
<Button
variant="outline"
size={"icon"}
disabled={!hasAccess || evaluator?.status !== "ACTIVE"}
loading={isLoading}
>
<Trash className="h-5 w-5" />
</Button>
<div className="flex items-center">
<Switch
disabled={!hasAccess}
checked={isActive}
className={isActive ? "data-[state=checked]:bg-dark-green" : ""}
/>
</div>
</PopoverTrigger>
<PopoverContent>
<h2 className="text-md mb-3 font-semibold">Please confirm</h2>
<p className="mb-3 text-sm">
This action permanently deactivates the evaluator. No more traces will
be evaluated based on this evaluator.
{evaluator?.status === "ACTIVE"
? "This action will deactivate the evaluator. No more traces will be evaluated based on this evaluator."
: "This action will activate the evaluator. New traces will be evaluated based on this evaluator."}
</p>
<div className="flex justify-end space-x-4">
<Button
type="button"
variant="destructive"
variant={evaluator?.status === "ACTIVE" ? "destructive" : "default"}
loading={mutEvaluator.isLoading}
onClick={onClick}
>
Deactivate evaluator
{evaluator?.status === "ACTIVE" ? "Deactivate" : "Activate"}
</Button>
</div>
</PopoverContent>
@@ -71,7 +71,7 @@ const formSchema = z.object({
target: z.string(),
filter: z.array(singleFilter).nullable(), // re-using the filter type from the tables
mapping: z.array(wipVariableMapping),
sampling: z.coerce.number().gte(0).lte(1),
sampling: z.coerce.number().gt(0).lte(1),
delay: z.coerce.number().optional().default(10),
});
@@ -87,6 +87,7 @@ export const EvaluatorForm = (props: {
disabled?: boolean;
existingEvaluator?: JobConfiguration & { evalTemplate: EvalTemplate };
onFormSuccess?: () => void;
mode?: "create" | "edit";
shouldWrapVariables?: boolean;
}) => {
const [open, setOpen] = useState(false);
@@ -131,7 +132,7 @@ export const EvaluatorForm = (props: {
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
disabled={props.disabled}
disabled={props.disabled || props.mode === "edit"}
variant="outline"
role="combobox"
aria-expanded={open}
@@ -193,7 +194,11 @@ export const EvaluatorForm = (props: {
<Popover>
<PopoverTrigger asChild>
<Button
disabled={props.disabled || !selectedTemplateName}
disabled={
props.disabled ||
!selectedTemplateName ||
props.mode === "edit"
}
variant="outline"
role="combobox"
className="w-1/3 justify-between px-2 font-normal"
@@ -276,6 +281,7 @@ export const EvaluatorForm = (props: {
}
onFormSuccess={props.onFormSuccess}
shouldWrapVariables={props.shouldWrapVariables}
mode={props.mode}
/>
) : null}
</>
@@ -289,6 +295,7 @@ export const InnerEvalConfigForm = (props: {
existingEvaluator?: JobConfiguration;
onFormSuccess?: () => void;
shouldWrapVariables?: boolean;
mode?: "create" | "edit";
}) => {
const [formError, setFormError] = useState<string | null>(null);
const capture = usePostHogClientCapture();
@@ -393,6 +400,10 @@ export const InnerEvalConfigForm = (props: {
onSuccess: () => utils.models.invalidate(),
onError: (error) => setFormError(error.message),
});
const updateJobMutation = api.evals.updateEvalJob.useMutation({
onSuccess: () => utils.evals.invalidate(),
onError: (error) => setFormError(error.message),
});
const [availableVariables, setAvailableVariables] = useState<
typeof availableTraceEvalVariables | typeof availableDatasetEvalVariables
>(
@@ -402,7 +413,11 @@ export const InnerEvalConfigForm = (props: {
);
function onSubmit(values: z.infer<typeof formSchema>) {
capture("eval_config:new_form_submit");
capture(
props.mode === "edit"
? "eval_config:update"
: "eval_config:new_form_submit",
);
const validatedFilter = z.array(singleFilter).safeParse(values.filter);
@@ -427,21 +442,42 @@ export const InnerEvalConfigForm = (props: {
return;
}
createJobMutation
.mutateAsync({
projectId: props.projectId,
evalTemplateId: props.evalTemplate.id,
scoreName: values.scoreName,
target: values.target,
filter: validatedFilter.data,
mapping: validatedVarMapping.data,
sampling: values.sampling,
delay: values.delay * 1000, // multiply by 1k to convert to ms
})
const delay = values.delay * 1000; // convert to ms
const sampling = values.sampling;
const mapping = validatedVarMapping.data;
const filter = validatedFilter.data;
const scoreName = values.scoreName;
(props.mode === "edit" && props.existingEvaluator
? updateJobMutation.mutateAsync({
projectId: props.projectId,
evalConfigId: props.existingEvaluator.id,
config: {
delay,
filter,
variableMapping: mapping,
sampling,
scoreName,
},
})
: createJobMutation.mutateAsync({
projectId: props.projectId,
target: values.target,
evalTemplateId: props.evalTemplate.id,
scoreName,
filter,
mapping,
sampling,
delay,
})
)
.then(() => {
props.onFormSuccess?.();
form.reset();
void router.push(`/project/${props.projectId}/evals`);
props.onFormSuccess?.();
if (props.mode !== "edit") {
void router.push(`/project/${props.projectId}/evals`);
}
})
.catch((error) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
@@ -507,10 +543,16 @@ export const InnerEvalConfigForm = (props: {
}}
>
<TabsList>
<TabsTrigger value="trace" disabled={props.disabled}>
<TabsTrigger
value="trace"
disabled={props.disabled || props.mode === "edit"}
>
Trace
</TabsTrigger>
<TabsTrigger value="dataset" disabled={props.disabled}>
<TabsTrigger
value="dataset"
disabled={props.disabled || props.mode === "edit"}
>
Dataset
</TabsTrigger>
</TabsList>
@@ -825,7 +867,7 @@ export const InnerEvalConfigForm = (props: {
{!props.disabled ? (
<Button
type="submit"
loading={createJobMutation.isLoading}
loading={createJobMutation.isLoading || updateJobMutation.isLoading}
className="mt-3"
>
Save
@@ -15,7 +15,7 @@ import {
import { Textarea } from "@/src/components/ui/textarea";
import { api } from "@/src/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { extractVariables, getIsCharOrUnderscore } from "@/src/utils/string";
import { extractVariables, getIsCharOrUnderscore } from "@langfuse/shared";
import router from "next/router";
import { type EvalTemplate } from "@langfuse/shared";
import { ModelParameters } from "@/src/components/ModelParameters";
@@ -1,7 +1,10 @@
import { env } from "@/src/env.mjs";
import { DatasetRunItemUpsertQueue } from "../../../../../../packages/shared/dist/src/server/redis/datasetRunItemUpsert";
import { randomUUID } from "crypto";
import { QueueJobs, redis } from "@langfuse/shared/src/server";
import {
QueueJobs,
DatasetRunItemUpsertQueue,
redis,
} from "@langfuse/shared/src/server";
export const addDatasetRunItemsToEvalQueue = async ({
projectId,
@@ -18,30 +21,17 @@ export const addDatasetRunItemsToEvalQueue = async ({
const queue = DatasetRunItemUpsertQueue.getInstance();
if (queue) {
await queue.add(
QueueJobs.DatasetRunItemUpsert,
{
payload: {
projectId,
datasetItemId: datasetItemId,
traceId,
observationId: observationId ?? undefined,
},
id: randomUUID(),
timestamp: new Date(),
name: QueueJobs.DatasetRunItemUpsert as const,
await queue.add(QueueJobs.DatasetRunItemUpsert, {
payload: {
projectId,
datasetItemId: datasetItemId,
traceId,
observationId: observationId ?? undefined,
},
{
attempts: 5, // retry 3 times
backoff: {
type: "exponential",
delay: 1000,
},
delay: 30000, // 10 seconds
removeOnComplete: true,
removeOnFail: 1_000,
},
);
id: randomUUID(),
timestamp: new Date(),
name: QueueJobs.DatasetRunItemUpsert as const,
});
}
}
};
+92 -16
View File
@@ -12,6 +12,9 @@ import {
singleFilter,
variableMapping,
ChatMessageRole,
type JobConfiguration,
JobConfigState,
JobType,
Prisma,
} from "@langfuse/shared";
import { decrypt } from "@langfuse/shared/encryption";
@@ -23,6 +26,47 @@ import {
} from "@langfuse/shared/src/server";
import { TRPCError } from "@trpc/server";
import { EvalReferencedEvaluators } from "@/src/ee/features/evals/types";
import { EvaluatorStatus } from "../types";
import { traceException } from "@langfuse/shared/src/server";
const APIEvaluatorSchema = z.object({
id: z.string(),
projectId: z.string(),
evalTemplateId: z.string(),
scoreName: z.string(),
targetObject: z.string(),
filter: z.array(singleFilter).nullable(), // re-using the filter type from the tables
variableMapping: z.array(variableMapping),
sampling: z.instanceof(Prisma.Decimal),
delay: z.number(),
status: z.nativeEnum(JobConfigState),
jobType: z.nativeEnum(JobType),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
type APIEvaluator = z.infer<typeof APIEvaluatorSchema>;
/**
* Use this function when pulling a list of evaluators from the database before using in the application to ensure type safety.
* All evaluators are expected to pass the validation. If an evaluator fails validation, it will be logged to Otel.
* @param evaluators
* @returns list of validated evaluators
*/
const filterAndValidateDbEvaluatorList = (
evaluators: JobConfiguration[],
onParseError?: (error: z.ZodError) => void,
): APIEvaluator[] =>
evaluators.reduce((acc, ts) => {
const result = APIEvaluatorSchema.safeParse(ts);
if (result.success) {
acc.push(result.data);
} else {
console.error("Evaluator parsing error: ", result.error);
onParseError?.(result.error);
}
return acc;
}, [] as APIEvaluator[]);
export const CreateEvalTemplate = z.object({
name: z.string().min(1),
@@ -42,6 +86,26 @@ export const CreateEvalTemplate = z.object({
.default(EvalReferencedEvaluators.PERSIST),
});
const CreateEvalJobSchema = z.object({
projectId: z.string(),
evalTemplateId: z.string(),
scoreName: z.string().min(1),
target: z.string(),
filter: z.array(singleFilter).nullable(), // re-using the filter type from the tables
mapping: z.array(variableMapping),
sampling: z.number().gt(0).lte(1),
delay: z.number().gte(0).default(DEFAULT_TRACE_JOB_DELAY), // 10 seconds default
});
const UpdateEvalJobSchema = z.object({
scoreName: z.string().min(1).optional(),
filter: z.array(singleFilter).optional(),
variableMapping: z.array(variableMapping).optional(),
sampling: z.number().gt(0).lte(1).optional(),
delay: z.number().gte(0).optional(),
status: z.nativeEnum(EvaluatorStatus).optional(),
});
export const evalRouter = createTRPCRouter({
allConfigs: protectedProjectProcedure
.input(
@@ -314,6 +378,31 @@ export const evalRouter = createTRPCRouter({
}
}),
jobConfigsByTarget: protectedProjectProcedure
.input(z.object({ projectId: z.string(), targetObject: z.string() }))
.query(async ({ input, ctx }) => {
throwIfNoEntitlement({
entitlement: "model-based-evaluations",
projectId: input.projectId,
sessionUser: ctx.session.user,
});
throwIfNoProjectAccess({
session: ctx.session,
projectId: input.projectId,
scope: "evalJob:read",
});
const evaluators = await ctx.prisma.jobConfiguration.findMany({
where: {
projectId: input.projectId,
targetObject: input.targetObject,
status: "ACTIVE",
},
});
return filterAndValidateDbEvaluatorList(evaluators, traceException);
}),
jobConfigsByTemplateName: protectedProjectProcedure
.input(z.object({ projectId: z.string(), evalTemplateName: z.string() }))
.query(async ({ input, ctx }) => {
@@ -360,18 +449,7 @@ export const evalRouter = createTRPCRouter({
}),
createJob: protectedProjectProcedure
.input(
z.object({
projectId: z.string(),
evalTemplateId: z.string(),
scoreName: z.string().min(1),
target: z.string(),
filter: z.array(singleFilter).nullable(), // re-using the filter type from the tables
mapping: z.array(variableMapping),
sampling: z.number().gte(0).lte(1),
delay: z.number().gte(0).default(DEFAULT_TRACE_JOB_DELAY), // 10 seconds default
}),
)
.input(CreateEvalJobSchema)
.mutation(async ({ input, ctx }) => {
try {
throwIfNoEntitlement({
@@ -545,7 +623,7 @@ export const evalRouter = createTRPCRouter({
z.object({
projectId: z.string(),
evalConfigId: z.string(),
updatedStatus: z.enum(["ACTIVE", "INACTIVE"]),
config: UpdateEvalJobSchema,
}),
)
.mutation(async ({ input, ctx }) => {
@@ -565,9 +643,7 @@ export const evalRouter = createTRPCRouter({
id: input.evalConfigId,
projectId: input.projectId,
},
data: {
status: input.updatedStatus,
},
data: input.config,
});
await auditLog({
+7
View File
@@ -1,4 +1,11 @@
import { z } from "zod";
import { JobConfigState } from "@langfuse/shared";
export enum EvalReferencedEvaluators {
UPDATE = "update",
PERSIST = "persist",
}
export const EvaluatorStatus = JobConfigState;
export const EvaluatorStatusSchema = z.nativeEnum(EvaluatorStatus);
export type EvaluatorStatusType = z.infer<typeof EvaluatorStatusSchema>;
@@ -0,0 +1,742 @@
import React, { useEffect, useMemo, useState } from "react";
import { Button } from "@/src/components/ui/button";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/src/components/ui/form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Form } from "@/src/components/ui/form";
import { Textarea } from "@/src/components/ui/textarea";
import { ModelParameters } from "@/src/components/ModelParameters";
import {
CommandEmpty,
CommandGroup,
CommandInput,
CommandList,
Command,
CommandItem,
} from "@/src/components/ui/command";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/src/components/ui/select";
import { z, type ZodSchema } from "zod";
import { cn } from "@/src/utils/tailwind";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/src/components/ui/popover";
import {
ChevronDown,
CheckIcon,
Info,
CircleCheck,
Loader2,
} from "lucide-react";
import { api } from "@/src/utils/api";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/src/components/ui/card";
import { showErrorToast } from "@/src/features/notifications/showErrorToast";
import { useModelParams } from "@/src/ee/features/playground/page/hooks/useModelParams";
import { getFinalModelParams } from "@/src/ee/utils/getFinalModelParams";
import {
type ColumnDefinition,
datasetCol,
extractVariables,
type FilterCondition,
stringOptionsFilter,
ZodModelConfig,
} from "@langfuse/shared";
import { MultiSelectKeyValues } from "@/src/features/scores/components/multi-select-key-values";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { PromptType } from "@/src/features/prompts/server/utils/validation";
import { Skeleton } from "@/src/components/ui/skeleton";
import { Input } from "@/src/components/ui/input";
import { EvaluatorStatus } from "@/src/ee/features/evals/types";
const CreateExperimentData = z.object({
name: z.string().min(1, "Please enter a name").optional(),
promptId: z.string().min(1, "Please select a prompt"),
datasetId: z.string().min(1, "Please select a dataset"),
description: z.string().max(1000).optional(),
modelConfig: z.object({
provider: z.string().min(1, "Please select a provider"),
model: z.string().min(1, "Please select a model"),
modelParams: ZodModelConfig,
}),
});
export type CreateExperiment = z.infer<typeof CreateExperimentData>;
const isDatasetTarget = <T extends ZodSchema>(
filters: FilterCondition[] | null,
condition: {
column: ColumnDefinition;
schema: T;
isValid: (filter: z.infer<T>) => boolean;
},
): boolean => {
if (!filters) return true;
const { column, schema, isValid } = condition;
const datasetFilters = filters.filter(
(filter) =>
(filter.column === column.id || column.name) &&
schema.safeParse(filter).success,
);
return datasetFilters.every((filter): boolean => isValid(filter));
};
export const CreateExperimentsForm = ({
projectId,
setFormOpen,
defaultValues = {},
promptDefault,
handleExperimentSettled,
handleExperimentSuccess,
}: {
projectId: string;
setFormOpen: (open: boolean) => void;
defaultValues?: Partial<CreateExperiment>;
promptDefault?: {
name: string;
version: number;
};
handleExperimentSuccess?: (data?: {
success: boolean;
datasetId: string;
runId: string;
runName: string;
}) => Promise<void>;
handleExperimentSettled?: (data?: {
success: boolean;
datasetId: string;
runId: string;
runName: string;
}) => Promise<void>;
}) => {
const [open, setOpen] = useState(false);
const [evaluatorOptions, setEvaluatorOptions] = useState<
{ key: string; value: string }[]
>([]);
const [selectedEvaluators, setSelectedEvaluators] = useState<
{ key: string; value: string }[]
>([]);
const [selectedPromptName, setSelectedPromptName] = useState<string>(
promptDefault?.name ?? "",
);
const [selectedPromptVersion, setSelectedPromptVersion] = useState<
number | null
>(promptDefault?.version ?? null);
const {
modelParams,
updateModelParamValue,
setModelParamEnabled,
availableModels,
availableProviders,
} = useModelParams();
const form = useForm<CreateExperiment>({
resolver: zodResolver(CreateExperimentData),
defaultValues: {
promptId: "",
datasetId: "",
modelConfig: {},
...defaultValues,
},
});
const hasExperimentWriteAccess = useHasProjectAccess({
projectId,
scope: "experiments:CUD",
});
const hasEvalReadAccess = useHasProjectAccess({
projectId,
scope: "evalJob:read",
});
const hasEvalWriteAccess = useHasProjectAccess({
projectId,
scope: "evalJob:CUD",
});
const promptMeta = api.prompts.allPromptMeta.useQuery({
projectId,
});
const datasets = api.datasets.allDatasetMeta.useQuery(
{ projectId },
{
trpc: {
context: {
skipBatch: true,
},
},
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Infinity,
},
);
const promptId = form.watch("promptId");
const datasetId = form.watch("datasetId");
const evaluators = api.evals.jobConfigsByTarget.useQuery(
{ projectId, targetObject: "dataset" },
{
enabled: hasEvalReadAccess && !!datasetId,
trpc: {
context: {
skipBatch: true,
},
},
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
const expectedColumns = useMemo(() => {
const prompt = promptMeta.data?.find((p) => p.id === promptId);
if (!prompt) return [];
return extractVariables(
prompt.type === PromptType.Text
? (prompt?.prompt?.toString() ?? "")
: JSON.stringify(prompt?.prompt),
);
}, [promptId, promptMeta.data]);
useEffect(() => {
if (evaluators.data) {
const isValidFilter = (filter: z.infer<typeof stringOptionsFilter>) => {
const filterIncludesId = filter.value.includes(datasetId);
if (filter.operator === "any of") {
return filterIncludesId;
} else {
return !filterIncludesId;
}
};
const initialEvaluators = evaluators.data.reduce<
{ key: string; value: string }[]
>((acc, evaluator) => {
if (
isDatasetTarget(evaluator.filter, {
column: datasetCol,
schema: stringOptionsFilter,
isValid: isValidFilter,
})
) {
acc.push({
key: evaluator.id,
value: evaluator.scoreName,
});
}
return acc;
}, []);
setEvaluatorOptions(initialEvaluators);
setSelectedEvaluators(initialEvaluators);
}
}, [evaluators.data, datasetId]);
const validationResult = api.experiments.validateConfig.useQuery(
{
projectId,
promptId: promptId as string,
datasetId: datasetId as string,
},
{
enabled: Boolean(promptId && datasetId),
},
);
const experimentMutation = api.experiments.createExperiment.useMutation({
onSuccess: handleExperimentSuccess ?? (() => {}),
onError: (error) => {
showErrorToast(
error.message || "Failed to trigger experiment run",
"Please try again.",
);
},
onSettled: handleExperimentSettled ?? (() => {}),
});
const archiveEvaluatorMutation = api.evals.updateEvalJob.useMutation();
// Watch model config changes and update form
useEffect(() => {
form.setValue("modelConfig", {
provider: modelParams.provider.value,
model: modelParams.model.value,
modelParams: getFinalModelParams(modelParams),
});
}, [modelParams, form]);
const onSubmit = async (data: CreateExperiment) => {
const experiment = {
...data,
projectId,
};
await experimentMutation.mutateAsync(experiment);
form.reset();
setFormOpen(false);
};
const handleOnValueChange = (
values: { key: string; value: string }[],
changedValueId?: string,
) => {
if (!changedValueId) return;
const evaluator = evaluators.data?.find((e) => e.id === changedValueId);
if (!evaluator) return;
if (evaluator.status === "INACTIVE") {
const confirmed = window.confirm(
`Are you sure you want to activate "${evaluator.scoreName}"? You can always always archive the evaluator.`,
);
if (!confirmed) {
return;
}
} else {
const confirmed = window.confirm(
`Are you sure you want to archive "${evaluator.scoreName}"? You can always always re-activate the evaluator.`,
);
if (!confirmed) {
return;
}
}
archiveEvaluatorMutation.mutate({
projectId,
evalConfigId: changedValueId,
config: {
status:
evaluator.status === EvaluatorStatus.INACTIVE
? EvaluatorStatus.ACTIVE
: EvaluatorStatus.INACTIVE,
},
});
setSelectedEvaluators(values);
};
const promptsByName = useMemo(
() =>
promptMeta.data?.reduce<
Record<string, Array<{ version: number; id: string }>>
>((acc, prompt) => {
if (!acc[prompt.name]) {
acc[prompt.name] = [];
}
acc[prompt.name].push({ version: prompt.version, id: prompt.id });
return acc;
}, {}),
[promptMeta.data],
);
if (!hasExperimentWriteAccess) {
return null;
}
if (
!promptMeta.data ||
!datasets.data ||
(hasEvalReadAccess && !!datasetId && !evaluators.data)
) {
return <Skeleton className="min-h-[70dvh] w-full" />;
}
return (
<Form {...form}>
<form className="space-y-6" onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Experiment name (optional)</FormLabel>
<FormControl>
<Input {...field} type="string" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description (optional)</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="Add description..."
className="focus:outline-none focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 active:ring-0"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="promptId"
render={() => (
<FormItem>
<FormLabel>Prompt</FormLabel>
{/* FIX: I need the command list in the popover to be scrollable, currently it's not */}
<div className="mb-2 flex gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-2/3 justify-between px-2 font-normal"
>
{selectedPromptName || "Select a prompt"}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] overflow-auto p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search prompts..."
className="h-9"
/>
<CommandList>
<CommandEmpty>No prompt found.</CommandEmpty>
<CommandGroup>
{promptsByName &&
Object.entries(promptsByName).map(
([name, promptData]) => (
<CommandItem
key={name}
onSelect={() => {
setSelectedPromptName(name);
const latestVersion = promptData[0];
setSelectedPromptVersion(
latestVersion.version,
);
form.setValue("promptId", latestVersion.id);
form.clearErrors("promptId");
}}
>
{name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
name === selectedPromptName
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
),
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<Button
disabled={!selectedPromptName}
variant="outline"
role="combobox"
className="w-1/3 justify-between px-2 font-normal"
>
{selectedPromptVersion
? `Version ${selectedPromptVersion}`
: "Version"}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] p-0"
align="start"
>
<Command>
<CommandList>
<CommandEmpty>No version found.</CommandEmpty>
<CommandGroup className="overflow-y-auto">
{promptsByName &&
selectedPromptName &&
promptsByName[selectedPromptName] ? (
promptsByName[selectedPromptName].map((prompt) => (
<CommandItem
key={prompt.id}
onSelect={() => {
setSelectedPromptVersion(prompt.version);
form.setValue("promptId", prompt.id);
form.clearErrors("promptId");
}}
>
Version {prompt.version}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
prompt.version === selectedPromptVersion
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))
) : (
<CommandItem disabled>
No versions available
</CommandItem>
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="modelConfig"
render={() => (
<FormItem>
<Card className="p-4">
<ModelParameters
{...{
modelParams,
availableModels,
availableProviders,
updateModelParamValue: updateModelParamValue,
setModelParamEnabled,
modelParamsDescription:
"Select a model which supports function calling.",
}}
evalModelsOnly
/>
</Card>
{form.formState.errors.modelConfig && (
<p
id="modelConfig"
className={cn("text-sm font-medium text-destructive")}
>
{[
form.formState.errors.modelConfig?.model?.message,
form.formState.errors.modelConfig?.provider?.message,
].join(", ")}
</p>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="datasetId"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>Dataset</FormLabel>
<Popover>
<PopoverTrigger asChild>
<span className="cursor-pointer text-xs text-muted-foreground">
(expected columns)
</span>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="flex flex-col space-y-2">
<h4 className="text-sm font-medium leading-none">
Expected columns
</h4>
<span className="text-sm text-muted-foreground">
{promptId ? (
<div>
<span>
Given current prompt, dataset item input must
contain at least one of these first-level JSON
keys, mapped to a string value:
</span>
<ul className="my-2 ml-2 list-inside list-disc">
{expectedColumns.map((col) => (
<li key={col}>{col}</li>
))}
</ul>
<span>
These will be used as the input to your prompt.
</span>
</div>
) : (
"Please select a prompt first"
)}
</span>
</div>
</PopoverContent>
</Popover>
</div>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a dataset" />
</SelectTrigger>
</FormControl>
<SelectContent>
{datasets.data?.map((dataset) => (
<SelectItem value={dataset.id} key={dataset.id}>
{dataset.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{evaluators.data && !!datasetId ? (
<FormItem>
<FormLabel>Evaluators</FormLabel>
<FormDescription>
Will run against your experiment results.
</FormDescription>
<MultiSelectKeyValues
key={datasetId}
placeholder="Value"
align="end"
className="grid grid-cols-[auto,1fr,auto,auto] gap-2"
disabled={!hasEvalWriteAccess}
onValueChange={handleOnValueChange}
options={evaluatorOptions}
values={
selectedEvaluators as {
value: string;
key: string;
}[]
}
hideClearButton
controlButtons={
<CommandItem
onSelect={() => {
window.open(`/project/${projectId}/evals`, "_blank");
}}
>
Manage evaluators
</CommandItem>
}
/>
</FormItem>
) : (
<FormItem>
<FormLabel>Evaluators</FormLabel>
{hasEvalReadAccess ? (
<FormDescription>
Select a dataset first to set up evaluators.
</FormDescription>
) : (
<FormDescription>
You do not have access to view evaluators. Please contact your
admin to upgrade your role.
</FormDescription>
)}
</FormItem>
)}
<div className="mt-4 flex flex-col gap-4">
{validationResult.isLoading && Boolean(promptId && datasetId) && (
<Card className="relative overflow-hidden rounded-md shadow-none group-data-[collapsible=icon]:hidden">
<CardHeader className="p-2">
<CardTitle className="flex items-center justify-between text-sm">
<span>Validating configuration...</span>
<Loader2 className="h-3 w-3 animate-spin" />
</CardTitle>
<CardDescription className="text-foreground">
Checking dataset items against prompt variables
</CardDescription>
</CardHeader>
</Card>
)}
{validationResult.data?.isValid === false && (
<Card className="relative overflow-hidden rounded-md border-dark-yellow bg-light-yellow shadow-none group-data-[collapsible=icon]:hidden">
<CardHeader className="p-2">
<CardTitle className="flex items-center justify-between text-sm text-dark-yellow">
<span>Invalid configuration</span>
{/* TODO: add link to docs explaining error cases */}
<Info className="h-4 w-4" />
</CardTitle>
<CardDescription className="text-foreground">
{validationResult.data?.message}
</CardDescription>
</CardHeader>
</Card>
)}
{validationResult.data?.isValid === true && (
<Card className="relative overflow-hidden rounded-md border-dark-green bg-light-green shadow-none group-data-[collapsible=icon]:hidden">
<CardHeader className="p-2">
<CardTitle className="flex items-center justify-between text-sm text-dark-green">
<span>Valid configuration</span>
<CircleCheck className="h-4 w-4" />
</CardTitle>
<div className="text-sm">
Matches between dataset items and prompt variables
<ul className="my-2 ml-2 list-inside list-disc">
{Object.entries(
validationResult.data.variablesMap ?? {},
).map(([variable, count]) => (
<li key={variable}>
<strong>{variable}:</strong> {count} /{" "}
{validationResult.data?.isValid
? validationResult.data.totalItems
: "unknown"}
</li>
))}
</ul>
Items missing all prompt variables will be excluded from the
experiment.
</div>
</CardHeader>
</Card>
)}
<div className="flex justify-end">
<Button
type="submit"
disabled={
Boolean(promptId && datasetId) &&
!validationResult.data?.isValid
}
loading={form.formState.isSubmitting}
>
Create
</Button>
</div>
</div>
</form>
</Form>
);
};
@@ -0,0 +1,220 @@
import { z } from "zod";
import { randomUUID } from "crypto";
import {
type ExperimentMetadata,
QueueJobs,
QueueName,
redis,
ZodModelConfig,
ExperimentCreateQueue,
} from "@langfuse/shared/src/server";
import { env } from "@/src/env.mjs";
import {
createTRPCRouter,
protectedProjectProcedure,
} from "@/src/server/api/trpc";
import { PromptType } from "@/src/features/prompts/server/utils/validation";
import {
type DatasetItem,
extractVariables,
UnauthorizedError,
} from "@langfuse/shared";
import { throwIfNoEntitlement } from "@/src/features/entitlements/server/hasEntitlement";
import { throwIfNoProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
const ValidConfigResponse = z.object({
isValid: z.literal(true),
totalItems: z.number(),
variablesMap: z.record(z.string(), z.number()),
});
const InvalidConfigResponse = z.object({
isValid: z.literal(false),
message: z.string(),
});
const ConfigResponse = z.discriminatedUnion("isValid", [
ValidConfigResponse,
InvalidConfigResponse,
]);
const validateDatasetItems = (
datasetItems: DatasetItem[],
variables: string[],
): Record<string, number> => {
const variableMap: Record<string, number> = {};
for (const { input } of datasetItems) {
if (!input) {
continue;
}
const inputKeys = Object.keys(input);
// For each variable, increment its count if it exists in this item
for (const variable of variables) {
if (inputKeys.includes(variable)) {
variableMap[variable] = (variableMap[variable] || 0) + 1;
}
}
}
return variableMap;
};
export const experimentsRouter = createTRPCRouter({
validateConfig: protectedProjectProcedure
.input(
z.object({
projectId: z.string(),
datasetId: z.string(),
promptId: z.string(),
}),
)
.output(ConfigResponse)
.query(async ({ input, ctx }) => {
throwIfNoEntitlement({
entitlement: "experiments",
projectId: input.projectId,
sessionUser: ctx.session.user,
});
throwIfNoProjectAccess({
session: ctx.session,
projectId: input.projectId,
scope: "experiments:CUD",
});
const prompt = await ctx.prisma.prompt.findFirst({
where: {
id: input.promptId,
projectId: input.projectId,
},
});
if (!prompt) {
return {
isValid: false,
message: "Selected prompt not found.",
};
}
const extractedVariables = extractVariables(
prompt?.type === PromptType.Text
? (prompt.prompt?.toString() ?? "")
: JSON.stringify(prompt.prompt),
);
if (!Boolean(extractedVariables.length)) {
return {
isValid: false,
message: "Selected prompt has no variables.",
};
}
const datasetItems = await ctx.prisma.datasetItem.findMany({
where: {
datasetId: input.datasetId,
projectId: input.projectId,
},
});
if (!Boolean(datasetItems.length)) {
return {
isValid: false,
message: "Selected dataset is empty.",
};
}
const variablesMap = validateDatasetItems(
datasetItems,
extractedVariables,
);
if (!Boolean(Object.keys(variablesMap).length)) {
return {
isValid: false,
message: "No dataset item contains any variables.",
};
}
return {
isValid: true,
totalItems: datasetItems.length,
variablesMap: variablesMap,
};
}),
createExperiment: protectedProjectProcedure
.input(
z.object({
projectId: z.string(),
name: z.string().optional(),
promptId: z.string().min(1, "Please select a prompt"),
datasetId: z.string().min(1, "Please select a dataset"),
description: z.string().max(1000).optional(),
modelConfig: z.object({
provider: z.string().min(1, "Please select a provider"),
model: z.string().min(1, "Please select a model"),
modelParams: ZodModelConfig,
}),
}),
)
.mutation(async ({ input, ctx }) => {
throwIfNoEntitlement({
entitlement: "experiments",
projectId: input.projectId,
sessionUser: ctx.session.user,
});
throwIfNoProjectAccess({
session: ctx.session,
projectId: input.projectId,
scope: "experiments:CUD",
});
if (!redis || !env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) {
throw new UnauthorizedError("Experiment creation failed");
}
const metadata: ExperimentMetadata = {
prompt_id: input.promptId,
provider: input.modelConfig.provider,
model: input.modelConfig.model,
model_params: input.modelConfig.modelParams,
};
const name =
input.name ?? `${input.promptId}-${new Date().toISOString()}`;
const datasetRun = await ctx.prisma.datasetRuns.create({
data: {
name: name,
description: input.description,
datasetId: input.datasetId,
metadata: metadata,
projectId: input.projectId,
},
});
const queue = ExperimentCreateQueue.getInstance();
if (queue) {
await queue.add(QueueName.ExperimentCreate, {
name: QueueJobs.ExperimentCreateJob,
id: randomUUID(),
timestamp: new Date(),
payload: {
projectId: input.projectId,
datasetId: input.datasetId,
runId: datasetRun.id,
description: input.description,
},
});
}
return {
success: true,
datasetId: input.datasetId,
runId: datasetRun.id,
runName: name,
};
}),
});
@@ -16,9 +16,9 @@ import usePlaygroundCache from "@/src/ee/features/playground/page/hooks/usePlayg
import { getFinalModelParams } from "@/src/ee/utils/getFinalModelParams";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
import { extractVariables } from "@/src/utils/string";
import {
ChatMessageRole,
extractVariables,
type ChatMessageWithId,
type PromptVariable,
type UIModelParams,
+6 -1
View File
@@ -46,7 +46,7 @@ export const env = createEnv({
LANGFUSE_DEFAULT_PROJECT_ROLE: z
.enum(["OWNER", "ADMIN", "MEMBER", "VIEWER"])
.optional(),
LANGFUSE_CSP_ENFORCE_HTTPS: z.enum(["true", "false"]).optional(),
LANGFUSE_CSP_ENFORCE_HTTPS: z.enum(["true", "false"]).optional().default("false"),
// Telemetry
TELEMETRY_ENABLED: z.enum(["true", "false"]).optional(),
// AUTH
@@ -119,6 +119,7 @@ export const env = createEnv({
CLICKHOUSE_URL: z.string().optional(),
CLICKHOUSE_USER: z.string().optional(),
CLICKHOUSE_PASSWORD: z.string().optional(),
CLICKHOUSE_CLUSTER_ENABLED: z.enum(["true", "false"]).default("false"),
// EE ui customization
LANGFUSE_UI_API_HOST: z.string().optional(),
LANGFUSE_UI_DOCUMENTATION_HREF: z.string().url().optional(),
@@ -196,6 +197,7 @@ export const env = createEnv({
LANGFUSE_READ_FROM_POSTGRES_ONLY: z.enum(["true", "false"]).default("true"),
LANGFUSE_RETURN_FROM_CLICKHOUSE: z.enum(["true", "false"]).default("false"),
LANGFUSE_EXPERIMENT_EXCLUDED_PROJECT_IDS: z.string().optional(),
LANGFUSE_EXPERIMENT_EXCLUDED_OPERATIONS: z.string().optional(),
LANGFUSE_READ_DASHBOARDS_FROM_CLICKHOUSE: z
.enum(["true", "false"])
.default("false"),
@@ -264,6 +266,8 @@ export const env = createEnv({
NEXT_PUBLIC_SIGN_UP_DISABLED: process.env.NEXT_PUBLIC_SIGN_UP_DISABLED,
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:
process.env.LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES,
LANGFUSE_EXPERIMENT_EXCLUDED_OPERATIONS:
process.env.LANGFUSE_EXPERIMENT_EXCLUDED_OPERATIONS,
LANGFUSE_DISABLE_EXPENSIVE_POSTGRES_QUERIES:
process.env.LANGFUSE_DISABLE_EXPENSIVE_POSTGRES_QUERIES,
LANGFUSE_TEAM_SLACK_WEBHOOK: process.env.LANGFUSE_TEAM_SLACK_WEBHOOK,
@@ -373,6 +377,7 @@ export const env = createEnv({
CLICKHOUSE_URL: process.env.CLICKHOUSE_URL,
CLICKHOUSE_USER: process.env.CLICKHOUSE_USER,
CLICKHOUSE_PASSWORD: process.env.CLICKHOUSE_PASSWORD,
CLICKHOUSE_CLUSTER_ENABLED: process.env.CLICKHOUSE_CLUSTER_ENABLED,
// EE ui customization
LANGFUSE_UI_API_HOST: process.env.LANGFUSE_UI_API_HOST,
LANGFUSE_UI_DOCUMENTATION_HREF: process.env.LANGFUSE_UI_DOCUMENTATION_HREF,
@@ -47,6 +47,7 @@ const DatasetAggregateCell = ({
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Infinity,
onError: () => {},
},
);
const observation = api.observations.byId.useQuery(
@@ -66,6 +67,7 @@ const DatasetAggregateCell = ({
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Infinity,
onError: () => {},
},
);
@@ -81,7 +83,10 @@ const DatasetAggregateCell = ({
{variant === "peek" && actionButtons}
<div className="flex flex-row items-center justify-center gap-1">
<IOTableCell
isLoading={!!!observationId ? trace.isLoading : observation.isLoading}
isLoading={
(!!!observationId ? trace.isLoading : observation.isLoading) ||
!data
}
data={data?.output}
className={"bg-accent-light-green"}
singleLine={singleLine}
@@ -10,7 +10,7 @@ import { type ScoreAggregate } from "@langfuse/shared";
import { type Prisma } from "@langfuse/shared";
import { NumberParam } from "use-query-params";
import { useQueryParams, withDefault } from "use-query-params";
import { useMemo, useState } from "react";
import { useMemo, useState, useCallback } from "react";
import { usdFormatter } from "@/src/utils/numbers";
import { getScoreDataTypeIcon } from "@/src/features/scores/components/ScoreDetailColumnHelpers";
import { api, type RouterOutputs } from "@/src/utils/api";
@@ -24,6 +24,8 @@ import {
} from "@/src/components/ui/dropdown-menu";
import { DatasetCompareRunPeekView } from "@/src/features/datasets/components/DatasetCompareRunPeekView";
import { useClickhouse } from "@/src/components/layouts/ClickhouseAdminToggle";
import { getQueryKey } from "@trpc/react-query";
import { useQueryClient } from "@tanstack/react-query";
export type RunMetrics = {
id: string;
@@ -47,6 +49,16 @@ export type DatasetCompareRunRowData = {
runs?: RunAggregate;
};
const getRefetchInterval = (
runId: string,
localExperiments: { key: string; value: string }[],
unchangedCounts: Record<string, number>,
) => {
if (unchangedCounts[runId] < 2) return 5000;
if (localExperiments.some((run) => run.key === runId)) return 3000;
return false;
};
const DATASET_RUN_METRICS = ["scores", "resourceMetrics"] as const;
export type DatasetRunMetric = (typeof DATASET_RUN_METRICS)[number];
@@ -55,6 +67,7 @@ export function DatasetCompareRunsTable(props: {
datasetId: string;
runIds: string[];
runsData?: RouterOutputs["datasets"]["baseRunDataByDatasetId"];
localExperiments: { key: string; value: string }[];
}) {
const [selectedMetrics, setSelectedMetrics] = useState<DatasetRunMetric[]>([
"scores",
@@ -69,6 +82,10 @@ export function DatasetCompareRunsTable(props: {
traceId: string;
observationId?: string;
} | null>(null);
const [unchangedCounts, setUnchangedCounts] = useState<
Record<string, number>
>({});
const queryClient = useQueryClient();
const rowHeight = "l";
@@ -84,8 +101,51 @@ export function DatasetCompareRunsTable(props: {
limit: paginationState.pageSize,
});
const queryClickhouse = useClickhouse();
// Individual queries for each run
const runs = (props.runIds ?? []).map((runId) => ({
// 1. First, separate the run definitions
const runQueries = useMemo(
() =>
(props.runIds ?? []).map((runId) => ({
runId,
queryKey: getQueryKey(api.datasets.runitemsByRunIdOrItemId, {
projectId: props.projectId,
datasetRunId: runId,
page: paginationState.pageIndex,
limit: paginationState.pageSize,
queryClickhouse,
}),
})),
[
props.runIds,
props.projectId,
paginationState.pageIndex,
paginationState.pageSize,
queryClickhouse,
],
);
// 2. Track changes using onSuccess callback in the queries instead of useEffect
const handleQuerySuccess = useCallback(
(runId: string, newData: any) => {
setUnchangedCounts((prev) => {
const prevCount = prev[runId] || 0;
const queryKey = runQueries.find((r) => r.runId === runId)?.queryKey;
const prevData = queryClient.getQueryData(queryKey || []);
// Only increment if we have previous data and it matches the new data
if (prevData && JSON.stringify(prevData) === JSON.stringify(newData)) {
const newCount = prevCount + 1;
return { ...prev, [runId]: newCount };
}
return { ...prev, [runId]: 0 };
});
},
[queryClient, runQueries],
);
// 3. Use the queries with success callback
const runs = runQueries.map(({ runId }) => ({
runId,
items: api.datasets.runitemsByRunIdOrItemId.useQuery(
{
@@ -99,8 +159,14 @@ export function DatasetCompareRunsTable(props: {
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: 5 * 60 * 1000, // 5 minutes
staleTime: 5 * 60 * 1000,
enabled: baseDatasetItems.isSuccess,
refetchInterval: getRefetchInterval(
runId,
props.localExperiments,
unchangedCounts,
),
onSuccess: (data) => handleQuerySuccess(runId, data),
},
),
}));
@@ -139,7 +205,7 @@ export function DatasetCompareRunsTable(props: {
{},
);
return baseDatasetItems.data?.map(
return baseDatasetItems.data?.datasetItems.map(
(item): DatasetCompareRunRowData => ({
id: item.id,
input: item.input ?? "null",
@@ -356,7 +422,7 @@ export function DatasetCompareRunsTable(props: {
}
}
pagination={{
totalCount: baseDatasetItems.data?.length ?? null,
totalCount: baseDatasetItems.data?.totalCount ?? null,
onChange: setPaginationState,
state: paginationState,
}}
@@ -17,14 +17,19 @@ export const constructDatasetRunAggregateColumns = ({
selectedMetrics,
cellsLoading = false,
}: {
runAggregateColumnProps: { id: string; name: string; description?: string }[];
runAggregateColumnProps: {
id: string;
name: string;
description?: string;
createdAt?: Date;
}[];
projectId: string;
scoreKeyToDisplayName: Map<string, string>;
selectedMetrics: DatasetRunMetric[];
cellsLoading?: boolean;
}): LangfuseColumnDef<DatasetCompareRunRowData>[] => {
return runAggregateColumnProps.map((col) => {
const { id, name, description } = col;
const { id, name, description, createdAt } = col;
return {
id,
@@ -40,7 +45,12 @@ export const constructDatasetRunAggregateColumns = ({
cell: ({ row }: { row: Row<DatasetCompareRunRowData> }) => {
const runData: RunAggregate = row.getValue("runs") ?? {};
if (cellsLoading) return <Skeleton className="h-3 w-1/2" />;
// if cell is loading or if run created at timestamp is less than 20 seconds ago, show skeleton
if (
cellsLoading ||
(createdAt && createdAt.getTime() + 20000 > Date.now())
)
return <Skeleton className="h-full min-h-0 w-full" />;
if (!Boolean(Object.keys(runData).length)) return null;
if (!runData.hasOwnProperty(id)) return null;
@@ -328,6 +328,7 @@ const TraceObservationIOCell = ({
},
},
refetchOnMount: false, // prevents refetching loops
onError: () => {},
},
);
const observation = api.observations.byId.useQuery(
@@ -344,6 +345,7 @@ const TraceObservationIOCell = ({
},
},
refetchOnMount: false, // prevents refetching loops
onError: () => {},
},
);
@@ -351,7 +353,9 @@ const TraceObservationIOCell = ({
return (
<IOTableCell
isLoading={!!!observationId ? trace.isLoading : observation.isLoading}
isLoading={
(!!!observationId ? trace.isLoading : observation.isLoading) || !data
}
data={io === "output" ? data?.output : data?.input}
className={cn(io === "output" && "bg-accent-light-green")}
singleLine={singleLine}
@@ -24,6 +24,7 @@ export function useDatasetRunAggregateColumns({
name: runNameAndMetadata?.name ?? `run${runId}`,
id: runId,
description: runNameAndMetadata?.description ?? undefined,
createdAt: runNameAndMetadata?.createdAt,
};
});
@@ -18,20 +18,14 @@ import {
paginationZod,
} from "@langfuse/shared";
import { aggregateScores } from "@/src/features/scores/lib/aggregateScores";
import {
getLatencyAndTotalCostForObservations,
getLatencyAndTotalCostForObservationsByTraces,
getScoresForObservations,
getScoresForTraces,
traceException,
} from "@langfuse/shared/src/server";
import { measureAndReturnApi } from "@/src/server/utils/checkClickhouseAccess";
import Decimal from "decimal.js";
import {
createDatasetRunsTable,
datasetRunsTableSchema,
fetchDatasetItems,
getRunItemsByRunIdOrItemId,
} from "@/src/features/datasets/server/service";
import { traceException } from "@langfuse/shared/src/server";
export const datasetRouter = createTRPCRouter({
allDatasetMeta: protectedProjectProcedure
@@ -154,7 +148,13 @@ export const datasetRouter = createTRPCRouter({
.query(async ({ input, ctx }) => {
return ctx.prisma.datasetRuns.findMany({
where: { datasetId: input.datasetId, projectId: input.projectId },
select: { name: true, id: true, metadata: true, description: true },
select: {
name: true,
id: true,
metadata: true,
description: true,
createdAt: true,
},
});
}),
runsByDatasetId: protectedProjectProcedure
@@ -374,7 +374,7 @@ export const datasetRouter = createTRPCRouter({
}),
)
.query(async ({ input, ctx }) => {
return ctx.prisma.datasetItem.findMany({
const datasetItems = await ctx.prisma.datasetItem.findMany({
where: { datasetId: input.datasetId, projectId: input.projectId },
select: {
id: true,
@@ -382,10 +382,22 @@ export const datasetRouter = createTRPCRouter({
expectedOutput: true,
metadata: true,
},
orderBy: { id: "asc" },
orderBy: { createdAt: "desc" },
take: input.limit,
skip: input.page * input.limit,
});
const count = await ctx.prisma.datasetItem.count({
where: {
datasetId: input.datasetId,
projectId: input.projectId,
},
});
return {
datasetItems,
totalCount: count,
};
}),
updateDatasetItem: protectedProjectProcedure
.input(
@@ -729,18 +741,50 @@ export const datasetRouter = createTRPCRouter({
),
)
.query(async ({ input, ctx }) => {
const runItems = await ctx.prisma.datasetRunItems.findMany({
where: {
projectId: input.projectId,
datasetRunId: input.datasetRunId,
datasetItemId: input.datasetItemId,
},
orderBy: {
datasetItemId: "asc", // Order by dataset item ID instead of createdAt
},
take: input.limit,
skip: input.page * input.limit,
});
const filterQuery =
input.datasetRunId && input.datasetItemId
? Prisma.sql`AND (dri.dataset_run_id = ${input.datasetRunId} OR dri.dataset_item_id = ${input.datasetItemId})`
: input.datasetRunId
? Prisma.sql`AND dri.dataset_run_id = ${input.datasetRunId}`
: input.datasetItemId
? Prisma.sql`AND dri.dataset_item_id = ${input.datasetItemId}`
: Prisma.sql``;
const runItems = await ctx.prisma.$queryRaw<
Array<{
id: string;
traceId: string;
observationId: string | null;
createdAt: Date;
updatedAt: Date;
datasetItemCreatedAt: Date;
datasetItemId: string;
projectId: string;
datasetRunId: string;
}>
>`
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"
FROM dataset_run_items dri
INNER JOIN dataset_items di
ON dri.dataset_item_id = di.id
AND dri.project_id = di.project_id
WHERE
dri.project_id = ${input.projectId}
${filterQuery}
ORDER BY
di.created_at DESC
LIMIT ${input.limit}
OFFSET ${input.page * input.limit}
`;
if (runItems.length === 0) return { totalRunItems: 0, runItems: [] };
@@ -761,19 +805,7 @@ export const datasetRouter = createTRPCRouter({
where: {
projectId: ctx.session.projectId,
traceId: {
in: runItems
.filter((ri) => ri.observationId === null) // only include trace scores if run is not linked to an observation
.map((ri) => ri.traceId),
},
},
});
const observationScores = await ctx.prisma.score.findMany({
where: {
projectId: ctx.session.projectId,
observationId: {
in: runItems
.filter((ri) => ri.observationId !== null)
.map((ri) => ri.observationId) as string[],
in: runItems.map((ri) => ri.traceId),
},
},
});
@@ -834,10 +866,6 @@ export const datasetRouter = createTRPCRouter({
traceScores,
traceException,
);
const validatedObservationScores = filterAndValidateDbScoreList(
observationScores,
traceException,
);
const items = runItems.map((ri) => {
return {
@@ -846,16 +874,9 @@ export const datasetRouter = createTRPCRouter({
datasetItemId: ri.datasetItemId,
observation: observations.find((o) => o.id === ri.observationId),
trace: traces.find((t) => t.id === ri.traceId),
scores: aggregateScores([
...validatedTraceScores.filter(
(s) => s.traceId === ri.traceId && ri.observationId === null,
),
...validatedObservationScores.filter(
(s) =>
s.observationId === ri.observationId &&
s.traceId === ri.traceId,
),
]),
scores: aggregateScores(
validatedTraceScores.filter((s) => s.traceId === ri.traceId),
),
};
});
@@ -866,81 +887,13 @@ export const datasetRouter = createTRPCRouter({
};
},
clickhouseExecution: async () => {
const [
traceScores,
observationScores,
observationAggregates,
traceAggregate,
] = await Promise.all([
getScoresForTraces(
input.projectId,
runItems
.filter((ri) => ri.observationId === null) // only include trace scores if run is not linked to an observation
.map((ri) => ri.traceId),
),
getScoresForObservations(
input.projectId,
runItems
.filter((ri) => ri.observationId !== null)
.map((ri) => ri.observationId) as string[],
),
getLatencyAndTotalCostForObservations(
input.projectId,
runItems
.filter((ri) => ri.observationId !== null)
.map((ri) => ri.observationId) as string[],
),
getLatencyAndTotalCostForObservationsByTraces(
input.projectId,
runItems.map((ri) => ri.traceId),
),
]);
const validatedTraceScores = filterAndValidateDbScoreList(
traceScores,
traceException,
);
const validatedObservationScores = filterAndValidateDbScoreList(
observationScores,
traceException,
);
const items = runItems.map((ri) => {
return {
id: ri.id,
createdAt: ri.createdAt,
datasetItemId: ri.datasetItemId,
observation: observationAggregates
.map((o) => ({
id: o.id,
latency: o.latency,
calculatedTotalCost: new Decimal(o.totalCost),
}))
.find((o) => o.id === ri.observationId),
trace: traceAggregate
.map((t) => ({
id: t.traceId,
duration: t.latency,
totalCost: t.totalCost,
}))
.find((t) => t.id === ri.traceId),
scores: aggregateScores([
...validatedTraceScores.filter(
(s) => s.traceId === ri.traceId && ri.observationId === null,
),
...validatedObservationScores.filter(
(s) =>
s.observationId === ri.observationId &&
s.traceId === ri.traceId,
),
]),
};
});
// 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: items,
runItems: await getRunItemsByRunIdOrItemId(
input.projectId,
runItems,
),
};
},
});
+96 -8
View File
@@ -1,4 +1,10 @@
import { paginationZod, Prisma, type PrismaClient } from "@langfuse/shared";
import {
filterAndValidateDbScoreList,
paginationZod,
Prisma,
type PrismaClient,
type DatasetRunItems,
} from "@langfuse/shared";
import { prisma } from "@langfuse/shared/src/db";
import { v4 } from "uuid";
import { z } from "zod";
@@ -7,15 +13,20 @@ import {
clickhouseCompliantRandomCharacters,
commandClickhouse,
convertToScore,
type FetchScoresReturnType,
getLatencyAndTotalCostForObservations,
getLatencyAndTotalCostForObservationsByTraces,
getObservationsById,
getScoresForTraces,
getTracesByIds,
logger,
queryClickhouse,
type ScoreRecordReadType,
traceException,
} from "@langfuse/shared/src/server";
import { aggregateScores } from "@/src/features/scores/lib/aggregateScores";
import Decimal from "decimal.js";
import { measureAndReturnApi } from "@/src/server/utils/checkClickhouseAccess";
import { env } from "@/src/env.mjs";
export const datasetRunsTableSchema = z.object({
projectId: z.string(),
@@ -66,6 +77,7 @@ export const createDatasetRunsTable = async (input: DatasetRunsTableInput) => {
tableName,
clickhouseSession,
);
const obsAgg = await getObservationLatencyAndCostForDataset(
input,
tableName,
@@ -140,7 +152,7 @@ export const createTempTableInClickhouse = async (
clickhouseSession: string,
) => {
const query = `
CREATE TABLE IF NOT EXISTS ${tableName}
CREATE TABLE IF NOT EXISTS ${tableName} ${env.CLICKHOUSE_CLUSTER_ENABLED === "true" ? "ON CLUSTER default" : ""}
(
project_id String,
run_id String,
@@ -148,7 +160,10 @@ export const createTempTableInClickhouse = async (
dataset_id String,
trace_id String,
observation_id Nullable(String)
) ENGINE = Memory
)
ENGINE = ${env.CLICKHOUSE_CLUSTER_ENABLED === "true" ? "ReplicatedMergeTree()" : "MergeTree()"}
PRIMARY KEY (project_id, dataset_id, run_id, trace_id)
`;
await commandClickhouse({
@@ -198,6 +213,7 @@ export const getDatasetRunsFromPostgres = async (
d.id = ${input.datasetId}
AND d.project_id = ${input.projectId}
GROUP BY runs.id, runs.name, runs.description, runs.metadata, runs.created_at, runs.updated_at
ORDER BY runs.created_at DESC
LIMIT ${input.limit}
OFFSET ${input.page * input.limit}
`,
@@ -209,24 +225,24 @@ const getScoresFromTempTable = async (
tableName: string,
clickhouseSession: 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.*,
tmp.run_id
FROM ${tableName} tmp JOIN scores s
ON tmp.project_id = s.project_id
AND tmp.observation_id = s.observation_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
SETTINGS select_sequential_consistency = 1;
`;
const rows = await queryClickhouse<
FetchScoresReturnType & { run_id: string }
>({
const rows = await queryClickhouse<ScoreRecordReadType & { run_id: string }>({
query: query,
params: {
projectId: input.projectId,
@@ -480,3 +496,75 @@ export const fetchDatasetItems = async (input: DatasetRunItemsTableInput) => {
}),
};
};
export const getRunItemsByRunIdOrItemId = async (
projectId: string,
runItems: DatasetRunItems[],
) => {
const [traceScores, observationAggregates, traceAggregate] =
await Promise.all([
getScoresForTraces(
projectId,
runItems.map((ri) => ri.traceId),
),
getLatencyAndTotalCostForObservations(
projectId,
runItems
.filter((ri) => ri.observationId !== null)
.map((ri) => ri.observationId) as string[],
),
getLatencyAndTotalCostForObservationsByTraces(
projectId,
runItems.map((ri) => ri.traceId),
),
]);
const validatedTraceScores = filterAndValidateDbScoreList(
traceScores,
traceException,
);
return runItems.map((ri) => {
const trace = traceAggregate
.map((t) => ({
id: t.traceId,
duration: t.latency,
totalCost: t.totalCost,
}))
.find((t) => t.id === ri.traceId) ?? {
// we default to the traceId provided. The traceId must not be missing.
id: ri.traceId,
totalCost: 0,
duration: 0,
};
const observation =
observationAggregates
.map((o) => ({
id: o.id,
latency: o.latency,
calculatedTotalCost: new Decimal(o.totalCost),
}))
.find((o) => o.id === ri.observationId) ??
(ri.observationId
? // we default to the observationId provided. The observationId must not be missing
// in case it is on the dataset run item.
{
id: ri.observationId,
calculatedTotalCost: new Decimal(0),
latency: 0,
}
: undefined);
return {
id: ri.id,
createdAt: ri.createdAt,
datasetItemId: ri.datasetItemId,
observation,
trace,
scores: aggregateScores([
...validatedTraceScores.filter((s) => s.traceId === ri.traceId),
]),
};
});
};
@@ -9,6 +9,7 @@ const entitlements = [
"integration-posthog",
"batch-export",
"annotation-queues",
"experiments",
] as const;
export type Entitlement = (typeof entitlements)[number];
@@ -20,6 +21,7 @@ const cloudAllPlansEntitlements: Entitlement[] = [
"integration-posthog",
"batch-export",
"annotation-queues",
"experiments",
];
export const entitlementAccess: Record<Plan, Entitlement[]> = {
+1 -1
View File
@@ -234,7 +234,7 @@ const getTokensByModel = (model: TiktokenModel, text: string) => {
encoding = getEncoding("cl100k_base");
}
const cleandedText = unicodeToBytesInString(text);
return encoding?.encode(cleandedText).length;
return encoding?.encode(cleandedText, "all").length;
};
interface Tokenizer {
@@ -1,13 +1,16 @@
import { env } from "@/src/env.mjs";
import { S3StorageService } from "@langfuse/shared/src/server";
import {
type StorageService,
StorageServiceFactory,
} from "@langfuse/shared/src/server";
let s3StorageServiceClient: S3StorageService;
let s3StorageServiceClient: StorageService;
export const getMediaStorageServiceClient = (
bucketName: string,
): S3StorageService => {
): StorageService => {
if (!s3StorageServiceClient) {
s3StorageServiceClient = new S3StorageService({
s3StorageServiceClient = StorageServiceFactory.getInstance({
bucketName,
accessKeyId: env.LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID,
secretAccessKey: env.LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY,
@@ -59,7 +59,13 @@ const events = {
"version_delete_submit",
],
session_detail: ["publish_button_click"],
eval_config: ["delete", "new_form_submit", "new_form_open"],
eval_config: [
"new_form_submit",
"new_form_open",
"activate",
"deactivate",
"update",
],
eval_templates: [
"view_version",
"new_form_open",
@@ -7,12 +7,10 @@ import {
ChatMessageRole,
ChatMessageDefaultRoleSchema,
type ChatMessageWithId,
ChatMessageListSchema,
} from "@langfuse/shared";
import {
ChatMessageListSchema,
type NewPromptFormSchemaType,
} from "./validation";
import { type NewPromptFormSchemaType } from "./validation";
import type { ControllerRenderProps } from "react-hook-form";
import type { MessagesContext } from "@/src/components/ChatMessages/types";
@@ -26,15 +26,18 @@ import {
} from "@/src/features/prompts/server/utils/validation";
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
import { api } from "@/src/utils/api";
import { extractVariables, getIsCharOrUnderscore } from "@/src/utils/string";
import { zodResolver } from "@hookform/resolvers/zod";
import type { Prompt } from "@langfuse/shared";
import {
type Prompt,
extractVariables,
getIsCharOrUnderscore,
} from "@langfuse/shared";
import { PromptChatMessages } from "./PromptChatMessages";
import {
NewPromptFormSchema,
type NewPromptFormSchemaType,
PromptContentSchema,
type PromptContentType,
PromptVariantSchema,
type PromptVariant,
} from "./validation";
import { Input } from "@/src/components/ui/input";
import Link from "next/link";
@@ -62,25 +65,25 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
const utils = api.useUtils();
const capture = usePostHogClientCapture();
let initialPromptContent: PromptContentType | null;
let initialPromptVariant: PromptVariant | null;
try {
initialPromptContent = PromptContentSchema.parse({
initialPromptVariant = PromptVariantSchema.parse({
type: initialPrompt?.type,
prompt: initialPrompt?.prompt?.valueOf(),
});
} catch (err) {
initialPromptContent = null;
initialPromptVariant = null;
}
const defaultValues: NewPromptFormSchemaType = {
type: initialPromptContent?.type ?? PromptType.Text,
type: initialPromptVariant?.type ?? PromptType.Text,
chatPrompt:
initialPromptContent?.type === PromptType.Chat
? initialPromptContent?.prompt
initialPromptVariant?.type === PromptType.Chat
? initialPromptVariant?.prompt
: [],
textPrompt:
initialPromptContent?.type === PromptType.Text
? initialPromptContent?.prompt
initialPromptVariant?.type === PromptType.Text
? initialPromptVariant?.prompt
: "",
name: initialPrompt?.name ?? "",
config: JSON.stringify(initialPrompt?.config?.valueOf(), null, 2) || "{}",
@@ -258,8 +261,8 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
<TabsList className="flex w-full">
<TabsTrigger
disabled={
Boolean(initialPromptContent) &&
initialPromptContent?.type !== PromptType.Text
Boolean(initialPromptVariant) &&
initialPromptVariant?.type !== PromptType.Text
}
className="flex-1"
value={PromptType.Text}
@@ -268,8 +271,8 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
</TabsTrigger>
<TabsTrigger
disabled={
Boolean(initialPromptContent) &&
initialPromptContent?.type !== PromptType.Chat
Boolean(initialPromptVariant) &&
initialPromptVariant?.type !== PromptType.Chat
}
className="flex-1"
value={PromptType.Chat}
@@ -1,14 +1,6 @@
import { z } from "zod";
import { PromptType } from "@/src/features/prompts/server/utils/validation";
import { ChatMessageDefaultRoleSchema } from "@langfuse/shared";
const ChatMessageSchema = z.object({
role: z.union([ChatMessageDefaultRoleSchema, z.string()]), // Users may ingest any string as role via API/SDK
content: z.string(),
});
export const ChatMessageListSchema = z.array(ChatMessageSchema);
export const TextPromptSchema = z.string().min(1, "Enter a prompt");
import { ChatMessageListSchema, TextPromptSchema } from "@langfuse/shared";
const NewPromptBaseSchema = z.object({
name: z.string().min(1, "Enter a name"),
@@ -39,7 +31,7 @@ export const NewPromptFormSchema = z.union([
]);
export type NewPromptFormSchemaType = z.infer<typeof NewPromptFormSchema>;
export const PromptContentSchema = z.union([
export const PromptVariantSchema = z.union([
z.object({
type: z.literal(PromptType.Chat),
prompt: ChatMessageListSchema,
@@ -49,7 +41,7 @@ export const PromptContentSchema = z.union([
prompt: z.string(),
}),
]);
export type PromptContentType = z.infer<typeof PromptContentSchema>;
export type PromptVariant = z.infer<typeof PromptVariantSchema>;
function validateJson(content: string): boolean {
try {
@@ -11,7 +11,7 @@ import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNa
import { PromptType } from "@/src/features/prompts/server/utils/validation";
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
import { api } from "@/src/utils/api";
import { extractVariables } from "@/src/utils/string";
import { extractVariables } from "@langfuse/shared";
import { ScrollArea } from "@radix-ui/react-scroll-area";
import { TagPromptDetailsPopover } from "@/src/features/tag/components/TagPromptDetailsPopover";
import { PromptHistoryNode } from "./prompt-history";
@@ -25,11 +25,23 @@ import {
import { JumpToPlaygroundButton } from "@/src/ee/features/playground/page/components/JumpToPlaygroundButton";
import { ChatMlArraySchema } from "@/src/components/schemas/ChatMlSchema";
import { CommentList } from "@/src/features/comments/CommentList";
import { Lock, Plus } from "lucide-react";
import { Lock, Plus, FlaskConical } from "lucide-react";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { Button } from "@/src/components/ui/button";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { ScrollScreenPage } from "@/src/components/layouts/scroll-screen-page";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/src/components/ui/dialog";
import { CreateExperimentsForm } from "@/src/ee/features/experiments/components/CreateExperimentsForm";
import { useState } from "react";
import { useHasOrgEntitlement } from "@/src/features/entitlements/hooks";
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
export const PromptDetail = () => {
const projectId = useProjectIdFromURL();
@@ -39,10 +51,17 @@ export const PromptDetail = () => {
"version",
NumberParam,
);
const hasEntitlement = useHasOrgEntitlement("experiments");
const [isCreateExperimentDialogOpen, setIsCreateExperimentDialogOpen] =
useState(false);
const hasAccess = useHasProjectAccess({
projectId,
scope: "prompts:CUD",
});
const hasExperimentWriteAccess = useHasProjectAccess({
projectId,
scope: "experiments:CUD",
});
const promptHistory = api.prompts.allVersions.useQuery(
{
name: promptName,
@@ -75,6 +94,27 @@ export const PromptDetail = () => {
);
}
}
const utils = api.useUtils();
const handleExperimentSuccess = async (data?: {
success: boolean;
datasetId: string;
runId: string;
runName: string;
}) => {
setIsCreateExperimentDialogOpen(false);
if (!data) return;
void utils.datasets.baseRunDataByDatasetId.invalidate();
void utils.datasets.runsByDatasetId.invalidate();
showSuccessToast({
title: "Experiment run triggered successfully",
description: "Waiting for experiment to complete...",
link: {
text: "View experiment",
href: `/project/${projectId}/datasets/${data.datasetId}/compare?runIds=${data.runId}`,
},
});
};
const allTags = (
api.prompts.filterOptions.useQuery(
@@ -123,21 +163,70 @@ export const PromptDetail = () => {
actionButtons={
<>
{hasAccess ? (
<Button
variant="secondary"
onClick={() => {
capture("prompts:update_form_open");
}}
>
<Link
href={`/project/${projectId}/prompts/new?promptId=${encodeURIComponent(prompt.id)}`}
<>
{hasEntitlement && (
<Dialog
open={isCreateExperimentDialogOpen}
onOpenChange={setIsCreateExperimentDialogOpen}
>
<DialogTrigger asChild disabled={!hasExperimentWriteAccess}>
<Button
variant="secondary"
disabled={!hasExperimentWriteAccess}
>
<FlaskConical className="h-4 w-4" />
<span className="ml-2">New experiment</span>
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Set up experiment</DialogTitle>
<DialogDescription>
Create an experiment to test a prompt version on a
dataset. See{" "}
<Link
href="https://langfuse.com/docs/datasets/prompt-experiments"
target="_blank"
className="underline"
>
documentation
</Link>{" "}
to learn more.
</DialogDescription>
</DialogHeader>
<CreateExperimentsForm
key={`create-experiment-form-${prompt.id}`}
projectId={projectId as string}
setFormOpen={setIsCreateExperimentDialogOpen}
defaultValues={{
promptId: prompt.id,
}}
promptDefault={{
name: prompt.name,
version: prompt.version,
}}
handleExperimentSuccess={handleExperimentSuccess}
/>
</DialogContent>
</Dialog>
)}
<Button
variant="secondary"
onClick={() => {
capture("prompts:update_form_open");
}}
>
<div className="flex flex-row items-center">
<Plus className="h-4 w-4" />
<span className="ml-2">New version</span>
</div>
</Link>
</Button>
<Link
href={`/project/${projectId}/prompts/new?promptId=${encodeURIComponent(prompt.id)}`}
>
<div className="flex flex-row items-center">
<Plus className="h-4 w-4" />
<span className="ml-2">New version</span>
</div>
</Link>
</Button>
</>
) : (
<Button variant="secondary" disabled>
<div className="flex flex-row items-center">
@@ -603,6 +603,29 @@ export const promptRouter = createTRPCRouter({
logger.error(error);
}
}),
allPromptMeta: protectedProjectProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input, ctx }) => {
throwIfNoProjectAccess({
session: ctx.session,
projectId: input.projectId,
scope: "prompts:read",
});
return await ctx.prisma.prompt.findMany({
select: {
id: true,
name: true,
version: true,
type: true,
prompt: true,
},
where: {
projectId: input.projectId,
},
orderBy: [{ version: "desc" }],
});
}),
allVersions: protectedProjectProcedure
.input(
z.object({
@@ -0,0 +1,119 @@
import { type ScoreQueryType } from "@/src/features/public-api/server/sores";
import { filterOperators } from "@langfuse/shared";
import {
FilterList,
DateTimeFilter,
ArrayOptionsFilter,
StringOptionsFilter,
StringFilter,
NumberFilter,
type ClickhouseOperator,
} from "@langfuse/shared/src/server";
import { z } from "zod";
export type ApiColumnMapping = {
id: string;
clickhouseSelect: string;
clickhouseTable: string;
filterType: string;
operator?: ClickhouseOperator;
clickhousePrefix?: string;
};
export function convertApiProvidedFilterToClickhouseFilter(
filter: ScoreQueryType,
columnMapping: ApiColumnMapping[],
) {
const filterList = new FilterList();
columnMapping.forEach((columnMapping) => {
const value = filter[columnMapping.id as keyof ScoreQueryType];
if (value) {
let filterInstance;
switch (columnMapping.filterType) {
case "DateTimeFilter":
// get filter options from the filterOperators
// validate that the user provided operator is in the list of available operators
const availableOperators = z.enum(filterOperators.datetime);
const parsedOperator = availableOperators.safeParse(filter.operator);
// otherwise fall back to the operator provided in the column mapping
const finalOperator = parsedOperator.success
? parsedOperator.data
: columnMapping.operator;
finalOperator &&
typeof value === "string" &&
["<", "<=", ">", ">="].includes(finalOperator)
? (filterInstance = new DateTimeFilter({
clickhouseTable: columnMapping.clickhouseTable,
field: columnMapping.clickhouseSelect,
operator: finalOperator as "<" | "<=" | ">" | ">=",
value: new Date(value),
tablePrefix: columnMapping.clickhousePrefix,
}))
: undefined;
break;
case "ArrayOptionsFilter":
if (Array.isArray(value) || typeof value === "string") {
filterInstance = new ArrayOptionsFilter({
clickhouseTable: columnMapping.clickhouseTable,
field: columnMapping.clickhouseSelect,
operator: "all of",
values: Array.isArray(value) ? value : value.split(","),
tablePrefix: columnMapping.clickhousePrefix,
});
}
break;
case "StringOptionsFilter":
if (Array.isArray(value) || typeof value === "string") {
filterInstance = new StringOptionsFilter({
clickhouseTable: columnMapping.clickhouseTable,
field: columnMapping.clickhouseSelect,
operator: "any of",
values: Array.isArray(value) ? value : value.split(","),
tablePrefix: columnMapping.clickhousePrefix,
});
}
break;
case "StringFilter":
if (typeof value === "string") {
filterInstance = new StringFilter({
clickhouseTable: columnMapping.clickhouseTable,
field: columnMapping.clickhouseSelect,
operator: "=",
value: value,
tablePrefix: columnMapping.clickhousePrefix,
});
}
break;
case "NumberFilter":
const availableOperatorsNum = z.enum([
...filterOperators.number,
"!=",
]);
const parsedOperatorNum = availableOperatorsNum.safeParse(
filter.operator,
);
if (parsedOperatorNum.success) {
filterInstance = new NumberFilter({
clickhouseTable: columnMapping.clickhouseTable,
field: columnMapping.clickhouseSelect,
operator: parsedOperatorNum.data,
value: Number(value),
tablePrefix: columnMapping.clickhousePrefix,
});
}
break;
}
filterInstance && filterList.push(filterInstance);
}
});
console.log("filterList", filterList);
return filterList;
}
@@ -0,0 +1,174 @@
import { convertApiProvidedFilterToClickhouseFilter } from "@/src/features/public-api/server/filter-builder";
import {
StringFilter,
type ObservationRecordReadType,
queryClickhouse,
convertObservationToView,
} from "@langfuse/shared/src/server";
export type QueryType = {
page: number;
limit: number;
projectId: string;
traceId?: string;
userId?: string;
name?: string;
type?: string;
parentObservationId?: string;
fromStartTime?: string;
toStartTime?: string;
version?: string;
};
export const generateObservationsForPublicApi = async (props: QueryType) => {
const filter = generateFilter(props);
const appliedFilter = filter.apply();
const query = `
SELECT
id,
trace_id,
project_id,
type,
parent_observation_id,
start_time,
end_time,
name,
metadata,
level,
status_message,
version,
input,
output,
provided_model_name,
internal_model_id,
model_parameters,
provided_usage_details,
usage_details,
provided_cost_details,
cost_details,
total_cost,
completion_start_time,
prompt_id,
prompt_name,
prompt_version,
created_at,
updated_at,
event_ts
FROM observations o
WHERE project_id = {projectId: String}
AND ${appliedFilter.query}
ORDER BY start_time desc
LIMIT 1 by id, project_id
${props.limit !== undefined && props.page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
`;
const records = await queryClickhouse<ObservationRecordReadType>({
query,
params: {
...appliedFilter.params,
projectId: props.projectId,
...(props.limit !== undefined ? { limit: props.limit } : {}),
...(props.page !== undefined
? { offset: (props.page - 1) * props.limit }
: {}),
},
});
return records.map(convertObservationToView);
};
export const getObservationsCountForPublicApi = async (props: QueryType) => {
const filter = generateFilter(props).apply();
const query = `
SELECT
count() as count
FROM observations o
WHERE project_id = {projectId: String}
AND ${filter.query}
`;
const records = await queryClickhouse<{ count: string }>({
query,
params: { ...filter.params, projectId: props.projectId },
});
return records.map((record) => Number(record.count)).shift();
};
const filterParams = [
{
id: "userId",
clickhouseSelect: "user_id",
filterType: "StringFilter",
clickhouseTable: "observations",
clickhousePrefix: "o",
},
{
id: "traceId",
clickhouseSelect: "trace_id",
filterType: "StringFilter",
clickhouseTable: "observations",
clickhousePrefix: "o",
},
{
id: "name",
clickhouseSelect: "name",
filterType: "StringFilter",
clickhouseTable: "observations",
clickhousePrefix: "o",
},
{
id: "type",
clickhouseSelect: "type",
filterType: "StringFilter",
clickhouseTable: "observations",
clickhousePrefix: "o",
},
{
id: "parentObservationId",
clickhouseSelect: "parent_observation_id",
filterType: "StringFilter",
clickhouseTable: "observations",
clickhousePrefix: "o",
},
{
id: "fromStartTime",
clickhouseSelect: "start_time",
operator: ">=" as const,
filterType: "DateTimeFilter",
clickhouseTable: "observations",
clickhousePrefix: "o",
},
{
id: "toStartTime",
clickhouseSelect: "start_time",
operator: "<" as const,
filterType: "DateTimeFilter",
clickhouseTable: "observations",
clickhousePrefix: "o",
},
{
id: "version",
clickhouseSelect: "version",
filterType: "StringFilter",
clickhouseTable: "observations",
clickhousePrefix: "o",
},
];
const generateFilter = (filter: QueryType) => {
const observationsFilter = convertApiProvidedFilterToClickhouseFilter(
filter,
filterParams,
);
observationsFilter.push(
new StringFilter({
clickhouseTable: "observations",
field: "project_id",
operator: "=",
value: filter.projectId,
}),
);
return observationsFilter;
};
+228
View File
@@ -0,0 +1,228 @@
import {
type ApiColumnMapping,
convertApiProvidedFilterToClickhouseFilter,
} from "@/src/features/public-api/server/filter-builder";
import {
convertToScore,
queryClickhouse,
StringFilter,
type ScoreRecordReadType,
} from "@langfuse/shared/src/server";
export type ScoreQueryType = {
page: number;
limit: number;
projectId: string;
traceId?: string;
userId?: string;
name?: string;
source?: string;
fromTimestamp?: string;
toTimestamp?: string;
value?: number;
scoreId?: string;
configId?: string;
queueId?: string;
traceTags?: string | string[];
operator?: string;
scoreIds?: string[];
dataType?: string;
};
export const generateScoresForPublicApi = async (props: ScoreQueryType) => {
const { scoresFilter, tracesFilter } = generateScoreFilter(props);
const appliedScoresFilter = scoresFilter.apply();
const appliedTracesFilter = tracesFilter.apply();
const query = `
SELECT
s.id,
s.timestamp,
s.name,
s.value,
s.string_value,
s.author_user_id,
s.project_id,
s.created_at,
s.updated_at,
s.source,
s.comment,
s.data_type,
s.config_id,
s.queue_id,
s.trace_id,
s.observation_id,
t.user_id,
t.tags
FROM scores s
JOIN traces t FINAL ON s.trace_id = t.id AND s.project_id = t.project_id
WHERE s.project_id = {projectId: String}
AND t.project_id = {projectId: String}
${appliedScoresFilter.query ? `AND ${appliedScoresFilter.query}` : ""}
${tracesFilter.length() > 0 ? `AND ${appliedTracesFilter.query}` : ""}
ORDER BY s.timestamp desc
LIMIT 1 BY s.id, s.project_id
${props.limit !== undefined && props.page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
`;
const records = await queryClickhouse<
ScoreRecordReadType & { tags: string[]; user_id: string }
>({
query,
params: {
...appliedScoresFilter.params,
...appliedTracesFilter.params,
projectId: props.projectId,
...(props.limit !== undefined ? { limit: props.limit } : {}),
...(props.page !== undefined
? { offset: (props.page - 1) * props.limit }
: {}),
},
});
return records.map((record) => ({
...convertToScore(record),
trace: { userId: record.user_id, tags: record.tags },
}));
};
export const getScoresCountForPublicApi = async (props: ScoreQueryType) => {
const { scoresFilter, tracesFilter } = generateScoreFilter(props);
const appliedScoresFilter = scoresFilter.apply();
const appliedTracesFilter = tracesFilter.apply();
const query = `
SELECT
count() as count
FROM scores s
JOIN traces t FINAL ON s.trace_id = t.id AND s.project_id = t.project_id
WHERE s.project_id = {projectId: String}
AND t.project_id = {projectId: String}
${appliedScoresFilter.query ? `AND ${appliedScoresFilter.query}` : ""}
${tracesFilter.length() > 0 ? `AND ${appliedTracesFilter.query}` : ""}
`;
const records = await queryClickhouse<{ count: string }>({
query,
params: {
...appliedScoresFilter.params,
...appliedTracesFilter.params,
projectId: props.projectId,
},
});
return records.map((record) => Number(record.count)).shift();
};
const secureScoreFilterOptions: ApiColumnMapping[] = [
{
id: "traceId",
clickhouseSelect: "trace_id",
clickhouseTable: "scores",
filterType: "StringFilter",
clickhousePrefix: "s",
},
{
id: "name",
clickhouseSelect: "name",
clickhouseTable: "scores",
filterType: "StringFilter",
clickhousePrefix: "s",
},
{
id: "source",
clickhouseSelect: "source",
clickhouseTable: "scores",
filterType: "StringFilter",
clickhousePrefix: "s",
},
{
id: "fromTimestamp",
clickhouseSelect: "timestamp",
operator: ">=" as const,
clickhouseTable: "scores",
filterType: "DateTimeFilter",
clickhousePrefix: "s",
},
{
id: "toTimestamp",
clickhouseSelect: "timestamp",
operator: "<" as const,
clickhouseTable: "scores",
filterType: "DateTimeFilter",
clickhousePrefix: "s",
},
{
id: "value",
clickhouseSelect: "value",
clickhouseTable: "scores",
filterType: "NumberFilter",
clickhousePrefix: "s",
},
{
id: "scoreIds",
clickhouseSelect: "id",
clickhouseTable: "scores",
filterType: "StringOptionsFilter",
clickhousePrefix: "s",
},
{
id: "configId",
clickhouseSelect: "config_id",
clickhouseTable: "scores",
filterType: "StringFilter",
clickhousePrefix: "s",
},
{
id: "queueId",
clickhouseSelect: "queue_id",
clickhouseTable: "scores",
filterType: "StringFilter",
clickhousePrefix: "s",
},
{
id: "dataType",
clickhouseSelect: "data_type",
clickhouseTable: "scores",
filterType: "StringFilter",
clickhousePrefix: "s",
},
];
const secureTraceFilterOptions = [
{
id: "traceTags",
clickhouseSelect: "tags",
clickhouseTable: "traces",
filterType: "ArrayOptionsFilter",
clickhousePrefix: "t",
},
{
id: "userId",
clickhouseSelect: "user_id",
clickhouseTable: "traces",
filterType: "StringFilter",
clickhousePrefix: "t",
},
];
const generateScoreFilter = (filter: ScoreQueryType) => {
const scoresFilter = convertApiProvidedFilterToClickhouseFilter(
filter,
secureScoreFilterOptions,
);
scoresFilter.push(
new StringFilter({
clickhouseTable: "scores",
field: "project_id",
operator: "=",
value: filter.projectId,
}),
);
const tracesFilter = convertApiProvidedFilterToClickhouseFilter(
filter,
secureTraceFilterOptions,
);
return { scoresFilter, tracesFilter };
};
@@ -48,6 +48,9 @@ const projectScopes = [
"comments:CUD",
"comments:read",
"experiments:CUD",
"experiments:read",
] as const;
// type string of all Resource:Action, e.g. "members:read"
@@ -88,6 +91,8 @@ export const projectRoleAccessRights: Record<Role, ProjectScope[]> = {
"comments:read",
"annotationQueues:read",
"annotationQueues:CUD",
"experiments:CUD",
"experiments:read",
],
ADMIN: [
"project:read",
@@ -122,6 +127,8 @@ export const projectRoleAccessRights: Record<Role, ProjectScope[]> = {
"comments:read",
"annotationQueues:read",
"annotationQueues:CUD",
"experiments:CUD",
"experiments:read",
],
MEMBER: [
"project:read",
@@ -147,6 +154,8 @@ export const projectRoleAccessRights: Record<Role, ProjectScope[]> = {
"comments:read",
"annotationQueues:read",
"annotationQueues:CUD",
"experiments:CUD",
"experiments:read",
],
VIEWER: [
"project:read",
@@ -158,6 +167,7 @@ export const projectRoleAccessRights: Record<Role, ProjectScope[]> = {
"llmApiKeys:read",
"comments:read",
"annotationQueues:read",
"experiments:read",
],
NONE: [],
};
+9 -11
View File
@@ -82,19 +82,17 @@ export default async function handler(
}
if (req.method === "GET") {
const queues = [
QueueName.LegacyIngestionQueue,
QueueName.BatchExport,
QueueName.DatasetRunItemUpsert,
QueueName.EvaluationExecution,
QueueName.TraceUpsert,
QueueName.IngestionQueue,
];
const queues = Object.values(QueueName);
const queueCounts = await Promise.all(
queues.map(async (queueName) => {
const queue = getQueue(queueName);
const jobCount = await queue?.getJobCounts();
return { queueName, jobCount };
try {
const queue = getQueue(queueName);
const jobCount = await queue?.getJobCounts();
return { queueName, jobCount };
} catch (e) {
logger.error(`Failed to get job count for queue ${queueName}`, e);
return { queueName, jobCount: NaN };
}
}),
);
return res.status(200).json(queueCounts);
+17 -9
View File
@@ -8,6 +8,8 @@ import {
} from "@/src/features/public-api/types/datasets";
import { LangfuseNotFoundError, InvalidRequestError } from "@langfuse/shared";
import { addDatasetRunItemsToEvalQueue } from "@/src/ee/features/evals/server/addDatasetRunItemsToEvalQueue";
import { getObservationById } from "@langfuse/shared/src/server";
import { env } from "@/src/env.mjs";
export default withMiddlewares({
POST: createAuthedAPIRoute({
@@ -49,14 +51,20 @@ export default withMiddlewares({
// Backwards compatibility: historically, dataset run items were linked to observations, not traces
if (!traceId && observationId) {
const observation = observationId
? await prisma.observation.findUnique({
where: {
id: observationId,
projectId: auth.scope.projectId,
},
})
: undefined;
const observation =
env.LANGFUSE_RETURN_FROM_CLICKHOUSE === "true"
? await getObservationById(
observationId,
auth.scope.projectId,
true,
)
: await prisma.observation.findUnique({
where: {
id: observationId,
projectId: auth.scope.projectId,
},
});
if (observationId && !observation) {
throw new LangfuseNotFoundError("Observation not found");
}
@@ -94,7 +102,7 @@ export default withMiddlewares({
const runItem = await prisma.datasetRunItems.create({
data: {
datasetItemId: datasetItemId,
datasetItemId,
traceId: finalTraceId,
observationId,
datasetRunId: run.id,
+1 -1
View File
@@ -38,7 +38,7 @@ export default withMiddlewares({
if (!media) throw new LangfuseNotFoundError("Media asset not found");
if (!media.uploadHttpStatus)
throw new LangfuseNotFoundError("Media not yet uploaded");
if (media.uploadHttpStatus !== 200)
if (!(media.uploadHttpStatus === 200 || media.uploadHttpStatus === 201))
throw new LangfuseNotFoundError(
`Media upload failed with status ${media.uploadHttpStatus}: \n ${media.uploadHttpError}`,
);
@@ -33,6 +33,7 @@ export default withMiddlewares({
const observation = await getObservationViewById(
observationId,
auth.scope.projectId,
true,
);
if (!observation) {
throw new LangfuseNotFoundError(
+133 -38
View File
@@ -10,6 +10,11 @@ import {
GetObservationsV1Response,
transformDbToApiObservation,
} from "@/src/features/public-api/types/observations";
import { measureAndReturnApi } from "@/src/server/utils/checkClickhouseAccess";
import {
generateObservationsForPublicApi,
getObservationsCountForPublicApi,
} from "@/src/features/public-api/server/observations";
export default withMiddlewares({
GET: createAuthedAPIRoute({
@@ -17,39 +22,44 @@ export default withMiddlewares({
querySchema: GetObservationsV1Query,
responseSchema: GetObservationsV1Response,
fn: async ({ query, auth }) => {
const userIdCondition = query.userId
? Prisma.sql`AND traces."user_id" = ${query.userId}`
: Prisma.empty;
return await measureAndReturnApi({
input: { projectId: auth.scope.projectId, queryClickhouse: false },
operation: "api/public/observations",
user: null,
pgExecution: async () => {
const userIdCondition = query.userId
? Prisma.sql`AND traces."user_id" = ${query.userId}`
: Prisma.empty;
const nameCondition = query.name
? Prisma.sql`AND o."name" = ${query.name}`
: Prisma.empty;
const nameCondition = query.name
? Prisma.sql`AND o."name" = ${query.name}`
: Prisma.empty;
const observationTypeCondition = query.type
? Prisma.sql`AND o."type" = ${query.type}::"ObservationType"`
: Prisma.empty;
const observationTypeCondition = query.type
? Prisma.sql`AND o."type" = ${query.type}::"ObservationType"`
: Prisma.empty;
const traceIdCondition = query.traceId
? Prisma.sql`AND o."trace_id" = ${query.traceId}`
: Prisma.empty;
const traceIdCondition = query.traceId
? Prisma.sql`AND o."trace_id" = ${query.traceId}`
: Prisma.empty;
const parentObservationIdCondition = query.parentObservationId
? Prisma.sql`AND o."parent_observation_id" = ${query.parentObservationId}`
: Prisma.empty;
const parentObservationIdCondition = query.parentObservationId
? Prisma.sql`AND o."parent_observation_id" = ${query.parentObservationId}`
: Prisma.empty;
const fromStartTimeCondition = query.fromStartTime
? Prisma.sql`AND o."start_time" >= ${query.fromStartTime}::timestamp with time zone at time zone 'UTC'`
: Prisma.empty;
const fromStartTimeCondition = query.fromStartTime
? Prisma.sql`AND o."start_time" >= ${query.fromStartTime}::timestamp with time zone at time zone 'UTC'`
: Prisma.empty;
const toStartTimeCondition = query.toStartTime
? Prisma.sql`AND o."start_time" < ${query.toStartTime}::timestamp with time zone at time zone 'UTC'`
: Prisma.empty;
const toStartTimeCondition = query.toStartTime
? Prisma.sql`AND o."start_time" < ${query.toStartTime}::timestamp with time zone at time zone 'UTC'`
: Prisma.empty;
const versionCondition = query.version
? Prisma.sql`AND o."version" = ${query.version}`
: Prisma.empty;
const versionCondition = query.version
? Prisma.sql`AND o."version" = ${query.version}`
: Prisma.empty;
const observations = await prisma.$queryRaw<ObservationView[]>`
const observations = await prisma.$queryRaw<ObservationView[]>`
SELECT
o."id",
o."name",
@@ -100,7 +110,7 @@ export default withMiddlewares({
OFFSET ${(query.page - 1) * query.limit}
LIMIT ${query.limit}
`;
const countRes = await prisma.$queryRaw<{ count: bigint }[]>`
const countRes = await prisma.$queryRaw<{ count: bigint }[]>`
SELECT COUNT(*) FROM observations o LEFT JOIN traces ON o."trace_id" = traces."id"
WHERE o."project_id" = ${auth.scope.projectId}
${observationTypeCondition}
@@ -112,20 +122,105 @@ export default withMiddlewares({
${fromStartTimeCondition}
${toStartTimeCondition}
`;
if (countRes.length !== 1) {
throw new InternalServerError("Unexpected totalItems result");
}
const totalItems = Number(countRes[0].count);
if (countRes.length !== 1) {
throw new InternalServerError("Unexpected totalItems result");
}
const totalItems = Number(countRes[0].count);
return {
data: observations.map(transformDbToApiObservation),
meta: {
page: query.page,
limit: query.limit,
totalItems,
totalPages: Math.ceil(totalItems / query.limit),
return {
data: observations.map(transformDbToApiObservation),
meta: {
page: query.page,
limit: query.limit,
totalItems,
totalPages: Math.ceil(totalItems / query.limit),
},
};
},
};
clickhouseExecution: async () => {
const [items, count] = await Promise.all([
generateObservationsForPublicApi({
projectId: auth.scope.projectId,
page: query.page ?? undefined,
limit: query.limit ?? undefined,
traceId: query.traceId ?? undefined,
userId: query.userId ?? undefined,
name: query.name ?? undefined,
type: query.type ?? undefined,
parentObservationId: query.parentObservationId ?? undefined,
fromStartTime: query.fromStartTime ?? undefined,
toStartTime: query.toStartTime ?? undefined,
version: query.version ?? undefined,
}),
getObservationsCountForPublicApi({
projectId: auth.scope.projectId,
page: query.page ?? undefined,
limit: query.limit ?? undefined,
traceId: query.traceId ?? undefined,
userId: query.userId ?? undefined,
name: query.name ?? undefined,
type: query.type ?? undefined,
parentObservationId: query.parentObservationId ?? undefined,
fromStartTime: query.fromStartTime ?? undefined,
toStartTime: query.toStartTime ?? undefined,
version: query.version ?? undefined,
}),
]);
const uniqueModels: string[] = Array.from(
new Set(
items
.map((r) => r.modelId)
.filter((r): r is string => Boolean(r)),
),
);
const models =
uniqueModels.length > 0
? await prisma.model.findMany({
where: {
id: {
in: uniqueModels,
},
OR: [
{ projectId: auth.scope.projectId },
{ projectId: null },
],
},
include: {
Price: true,
},
})
: [];
const finalCount = count ? count : 0;
return {
data: items
.map((i) => {
const model = models.find((m) => m.id === i.modelId);
return {
...i,
modelId: model?.id ?? null,
inputPrice:
model?.Price?.find((m) => m.usageType === "input")?.price ??
null,
outputPrice:
model?.Price?.find((m) => m.usageType === "output")
?.price ?? null,
totalPrice:
model?.Price?.find((m) => m.usageType === "total")?.price ??
null,
};
})
.map(transformDbToApiObservation),
meta: {
page: query.page,
limit: query.limit,
totalItems: finalCount,
totalPages: Math.ceil(finalCount / query.limit),
},
};
},
});
},
}),
});
+169 -106
View File
@@ -16,6 +16,11 @@ import {
processEventBatch,
} from "@langfuse/shared/src/server";
import { tokenCount } from "@/src/features/ingest/usage";
import { measureAndReturnApi } from "@/src/server/utils/checkClickhouseAccess";
import {
generateScoresForPublicApi,
getScoresCountForPublicApi,
} from "@/src/features/public-api/server/sores";
export default withMiddlewares({
POST: createAuthedAPIRoute({
@@ -48,7 +53,7 @@ export default withMiddlewares({
},
}),
GET: createAuthedAPIRoute({
name: "Get Scores",
name: "/api/public/scores",
querySchema: GetScoresQuery,
responseSchema: GetScoresResponse,
fn: async ({ query, auth }) => {
@@ -69,118 +74,176 @@ export default withMiddlewares({
dataType,
} = query;
const skipValue = (page - 1) * limit;
const configCondition = configId
? Prisma.sql`AND s."config_id" = ${configId}`
: Prisma.empty;
const queueCondition = queueId
? Prisma.sql`AND s."queue_id" = ${queueId}`
: Prisma.empty;
const traceTagsCondition = traceTags
? Prisma.sql`AND ARRAY[${Prisma.join(
(Array.isArray(traceTags) ? traceTags : traceTags.split(",")).map(
(v) => Prisma.sql`${v}`,
),
", ",
)}] <@ t."tags"`
: Prisma.empty;
const dataTypeCondition = dataType
? Prisma.sql`AND s."data_type" = ${dataType}::"ScoreDataType"`
: Prisma.empty;
const userCondition = userId
? Prisma.sql`AND t."user_id" = ${userId}`
: Prisma.empty;
const nameCondition = name
? Prisma.sql`AND s."name" = ${name}`
: Prisma.empty;
const fromTimestampCondition = fromTimestamp
? Prisma.sql`AND s."timestamp" >= ${fromTimestamp}::timestamp with time zone at time zone 'UTC'`
: Prisma.empty;
const toTimestampCondition = toTimestamp
? Prisma.sql`AND s."timestamp" < ${toTimestamp}::timestamp with time zone at time zone 'UTC'`
: Prisma.empty;
const sourceCondition = source
? Prisma.sql`AND s."source" = ${source}`
: Prisma.empty;
const valueCondition =
operator && value !== null && value !== undefined
? Prisma.sql`AND s."value" ${Prisma.raw(`${operator}`)} ${value}`
: Prisma.empty;
const scoreIdCondition = scoreIds
? Prisma.sql`AND s."id" = ANY(${scoreIds})`
: Prisma.empty;
return await measureAndReturnApi({
input: { projectId: auth.scope.projectId, queryClickhouse: false },
operation: "api/public/scores",
user: null,
pgExecution: async () => {
const skipValue = (page - 1) * limit;
const configCondition = configId
? Prisma.sql`AND s."config_id" = ${configId}`
: Prisma.empty;
const queueCondition = queueId
? Prisma.sql`AND s."queue_id" = ${queueId}`
: Prisma.empty;
const traceTagsCondition = traceTags
? Prisma.sql`AND ARRAY[${Prisma.join(
(Array.isArray(traceTags)
? traceTags
: traceTags.split(",")
).map((v) => Prisma.sql`${v}`),
", ",
)}] <@ t."tags"`
: Prisma.empty;
const dataTypeCondition = dataType
? Prisma.sql`AND s."data_type" = ${dataType}::"ScoreDataType"`
: Prisma.empty;
const userCondition = userId
? Prisma.sql`AND t."user_id" = ${userId}`
: Prisma.empty;
const nameCondition = name
? Prisma.sql`AND s."name" = ${name}`
: Prisma.empty;
const fromTimestampCondition = fromTimestamp
? Prisma.sql`AND s."timestamp" >= ${fromTimestamp}::timestamp with time zone at time zone 'UTC'`
: Prisma.empty;
const toTimestampCondition = toTimestamp
? Prisma.sql`AND s."timestamp" < ${toTimestamp}::timestamp with time zone at time zone 'UTC'`
: Prisma.empty;
const sourceCondition = source
? Prisma.sql`AND s."source" = ${source}`
: Prisma.empty;
const valueCondition =
operator && value !== null && value !== undefined
? Prisma.sql`AND s."value" ${Prisma.raw(`${operator}`)} ${value}`
: Prisma.empty;
const scoreIdCondition = scoreIds
? Prisma.sql`AND s."id" = ANY(${scoreIds})`
: Prisma.empty;
const scores = await prisma.$queryRaw<Array<unknown>>(Prisma.sql`
SELECT
s.id,
s.timestamp,
s.name,
s.value,
s.string_value as "stringValue",
s.author_user_id as "authorUserId",
s.project_id as "projectId",
s.created_at as "createdAt",
s.updated_at as "updatedAt",
s.source,
s.comment,
s.data_type as "dataType",
s.config_id as "configId",
s.queue_id as "queueId",
s.trace_id as "traceId",
s.observation_id as "observationId",
json_build_object('userId', t.user_id, 'tags', t.tags) as "trace"
FROM "scores" AS s
LEFT JOIN "traces" AS t ON t.id = s.trace_id AND t.project_id = ${auth.scope.projectId}
WHERE s.project_id = ${auth.scope.projectId}
${configCondition}
${queueCondition}
${traceTagsCondition}
${dataTypeCondition}
${userCondition}
${nameCondition}
${sourceCondition}
${fromTimestampCondition}
${toTimestampCondition}
${valueCondition}
${scoreIdCondition}
ORDER BY s."timestamp" DESC
LIMIT ${limit} OFFSET ${skipValue}
const scores = await prisma.$queryRaw<Array<unknown>>(Prisma.sql`
SELECT
s.id,
s.timestamp,
s.name,
s.value,
s.string_value as "stringValue",
s.author_user_id as "authorUserId",
s.project_id as "projectId",
s.created_at as "createdAt",
s.updated_at as "updatedAt",
s.source,
s.comment,
s.data_type as "dataType",
s.config_id as "configId",
s.queue_id as "queueId",
s.trace_id as "traceId",
s.observation_id as "observationId",
json_build_object('userId', t.user_id, 'tags', t.tags) as "trace"
FROM "scores" AS s
LEFT JOIN "traces" AS t ON t.id = s.trace_id AND t.project_id = ${auth.scope.projectId}
WHERE s.project_id = ${auth.scope.projectId}
${configCondition}
${queueCondition}
${traceTagsCondition}
${dataTypeCondition}
${userCondition}
${nameCondition}
${sourceCondition}
${fromTimestampCondition}
${toTimestampCondition}
${valueCondition}
${scoreIdCondition}
ORDER BY s."timestamp" DESC
LIMIT ${limit} OFFSET ${skipValue}
`);
const totalItemsRes = await prisma.$queryRaw<{ count: bigint }[]>(
Prisma.sql`
SELECT COUNT(*) as count
FROM "scores" AS s
LEFT JOIN "traces" AS t ON t.id = s.trace_id AND t.project_id = ${auth.scope.projectId}
WHERE s.project_id = ${auth.scope.projectId}
${configCondition}
${queueCondition}
${traceTagsCondition}
${dataTypeCondition}
${userCondition}
${nameCondition}
${sourceCondition}
${fromTimestampCondition}
${toTimestampCondition}
${valueCondition}
${scoreIdCondition}
`,
);
const totalItemsRes = await prisma.$queryRaw<{ count: bigint }[]>(
Prisma.sql`
SELECT COUNT(*) as count
FROM "scores" AS s
LEFT JOIN "traces" AS t ON t.id = s.trace_id AND t.project_id = ${auth.scope.projectId}
WHERE s.project_id = ${auth.scope.projectId}
${configCondition}
${queueCondition}
${traceTagsCondition}
${dataTypeCondition}
${userCondition}
${nameCondition}
${sourceCondition}
${fromTimestampCondition}
${toTimestampCondition}
${valueCondition}
${scoreIdCondition}
`,
);
const validatedScores = legacyFilterAndValidateV1GetScoreList(scores);
const validatedScores = legacyFilterAndValidateV1GetScoreList(scores);
const totalItems =
totalItemsRes[0] !== undefined ? Number(totalItemsRes[0].count) : 0;
const totalItems =
totalItemsRes[0] !== undefined ? Number(totalItemsRes[0].count) : 0;
return {
data: validatedScores,
meta: {
page: page,
limit: limit,
totalItems,
totalPages: Math.ceil(totalItems / limit),
return {
data: validatedScores,
meta: {
page: page,
limit: limit,
totalItems,
totalPages: Math.ceil(totalItems / limit),
},
};
},
};
clickhouseExecution: async () => {
const [items, count] = await Promise.all([
generateScoresForPublicApi({
projectId: auth.scope.projectId,
page: query.page ?? undefined,
limit: query.limit ?? undefined,
userId: query.userId ?? undefined,
name: query.name ?? undefined,
configId: query.configId ?? undefined,
queueId: query.queueId ?? undefined,
traceTags: query.traceTags ?? undefined,
dataType: query.dataType ?? undefined,
fromTimestamp: query.fromTimestamp ?? undefined,
toTimestamp: query.toTimestamp ?? undefined,
source: query.source ?? undefined,
value: query.value ?? undefined,
operator: query.operator ?? undefined,
scoreIds: query.scoreIds ?? undefined,
}),
getScoresCountForPublicApi({
projectId: auth.scope.projectId,
page: query.page ?? undefined,
limit: query.limit ?? undefined,
userId: query.userId ?? undefined,
name: query.name ?? undefined,
configId: query.configId ?? undefined,
queueId: query.queueId ?? undefined,
traceTags: query.traceTags ?? undefined,
dataType: query.dataType ?? undefined,
fromTimestamp: query.fromTimestamp ?? undefined,
toTimestamp: query.toTimestamp ?? undefined,
source: query.source ?? undefined,
value: query.value ?? undefined,
operator: query.operator ?? undefined,
scoreIds: query.scoreIds ?? undefined,
}),
]);
const finalCount = count ? count : 0;
return {
data: legacyFilterAndValidateV1GetScoreList(items),
meta: {
page: query.page,
limit: query.limit,
totalItems: finalCount,
totalPages: Math.ceil(finalCount / query.limit),
},
};
},
});
},
}),
});

Some files were not shown because too many files have changed in this diff Show More