Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cd82c97cf | ||
|
|
f55bee2d36 | ||
|
|
5391dd90f4 | ||
|
|
a1e670a729 | ||
|
|
b6e21fc44b | ||
|
|
eb59082bf7 | ||
|
|
950f36b87d | ||
|
|
c55b70d119 | ||
|
|
c1109c91f1 | ||
|
|
3b737934f9 | ||
|
|
a12f3f675e | ||
|
|
30d5755c24 | ||
|
|
39762cb1c0 | ||
|
|
8ac4176aaf | ||
|
|
f87af4f675 | ||
|
|
bc323772c1 | ||
|
|
fcef737dc0 | ||
|
|
4bf77d68ed | ||
|
|
d7ec924ed8 | ||
|
|
cefaf56613 | ||
|
|
6aff9b0f4a | ||
|
|
815de149aa | ||
|
|
bc8923485e | ||
|
|
1a60425692 | ||
|
|
1d3ac98123 | ||
|
|
de67ec1a02 | ||
|
|
9510995a67 | ||
|
|
4623d76155 | ||
|
|
6a6abc6bd8 | ||
|
|
ddee827e22 | ||
|
|
e327db8bbd | ||
|
|
9229d759e1 | ||
|
|
d8d8ab7060 | ||
|
|
b851359923 | ||
|
|
b36ea74314 | ||
|
|
b2f103549a | ||
|
|
f660b80438 | ||
|
|
5bbfffef50 | ||
|
|
cb6f95dc93 | ||
|
|
8345ce48fc | ||
|
|
f71d28900a | ||
|
|
14eaf62e45 | ||
|
|
f8e61c2659 | ||
|
|
3b205b45ce | ||
|
|
7260c44486 | ||
|
|
92ec2b60e2 | ||
|
|
a856488f2d | ||
|
|
a18f09fd8d | ||
|
|
2a95dd1da0 | ||
|
|
04ebd5994f | ||
|
|
268b741a3a | ||
|
|
8b686e9b31 | ||
|
|
e802a5c23a | ||
|
|
5180e2382e | ||
|
|
2cae1253d3 | ||
|
|
be2414a82c | ||
|
|
95e63c6f78 | ||
|
|
32b6502d13 | ||
|
|
1bc33c7b92 | ||
|
|
4967b20478 | ||
|
|
1130894cdc | ||
|
|
af276e9e78 | ||
|
|
d69dbd81a2 | ||
|
|
7e807c0de8 | ||
|
|
cfa2ac6da9 | ||
|
|
952a550847 | ||
|
|
6199903117 | ||
|
|
88cdb8999d | ||
|
|
9ad2ef1f91 | ||
|
|
7a9717cb5e | ||
|
|
ce31a806c7 | ||
|
|
e7e6cf1b99 | ||
|
|
9f76457f22 | ||
|
|
c79c84ea96 | ||
|
|
4634669fcd | ||
|
|
549e0eb629 | ||
|
|
80adf63fe6 | ||
|
|
6c1d7534bb | ||
|
|
23a4bbd2cb | ||
|
|
07d048d46f | ||
|
|
bd7226bbb8 | ||
|
|
fe70d92211 | ||
|
|
bb3621f31d | ||
|
|
63225a9fd6 | ||
|
|
947da9cc84 | ||
|
|
93a0ab6f3a | ||
|
|
68ad079d8c | ||
|
|
cf29c6b7e4 | ||
|
|
e4d2a5ec6c | ||
|
|
35f3092a1c | ||
|
|
7b222318e1 | ||
|
|
d9c077ea56 | ||
|
|
1ec7325b85 | ||
|
|
96b2127f36 | ||
|
|
d3c2b8cf27 | ||
|
|
beeef222e4 | ||
|
|
ee376f9cad | ||
|
|
c8936ec68d | ||
|
|
8d8170c62c | ||
|
|
72e1a43192 | ||
|
|
a842625bd1 | ||
|
|
890a542759 | ||
|
|
1e8af5b821 | ||
|
|
d3f3f354db | ||
|
|
18a7cb6684 | ||
|
|
2d70ca574e | ||
|
|
1c980c78dd | ||
|
|
5170718aad | ||
|
|
a01c27f4cc | ||
|
|
51354e6c6d | ||
|
|
10dd50cff0 | ||
|
|
d8783f8698 | ||
|
|
16a572f51d | ||
|
|
947641cbbf | ||
|
|
249e161473 | ||
|
|
90bab4386f | ||
|
|
33fddb1344 | ||
|
|
c24dcb068a | ||
|
|
2986723254 | ||
|
|
b45b2fd8f8 | ||
|
|
8936fb4ad9 | ||
|
|
7d7e25b39c | ||
|
|
138d2f1c09 | ||
|
|
51595eb15d | ||
|
|
6ee68cf508 | ||
|
|
308513e644 | ||
|
|
155543bc93 | ||
|
|
8f961925d5 | ||
|
|
7ddc844d7d | ||
|
|
5381c5f19b |
@@ -1,86 +0,0 @@
|
||||
# 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_CLUSTER_ENABLED="false"
|
||||
|
||||
# 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.
|
||||
|
||||
# S3 Batch Exports
|
||||
LANGFUSE_S3_BATCH_EXPORT_ENABLED=true
|
||||
LANGFUSE_S3_BATCH_EXPORT_BUCKET=langfuse
|
||||
LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID=minio
|
||||
LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY=miniosecret
|
||||
LANGFUSE_S3_BATCH_EXPORT_REGION=us-east-1
|
||||
LANGFUSE_S3_BATCH_EXPORT_ENDPOINT=http://localhost:9090
|
||||
## Necessary for minio compatibility
|
||||
LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE=true
|
||||
LANGFUSE_S3_BATCH_EXPORT_PREFIX=exports/
|
||||
|
||||
# # S3 Media Upload LOCAL
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET=langfuse
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID=minio
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY=miniosecret
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_REGION=us-east-1
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT=http://localhost:9090
|
||||
## 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_BUCKET=langfuse
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=minio
|
||||
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=miniosecret
|
||||
LANGFUSE_S3_EVENT_UPLOAD_REGION=us-east-1
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT=http://localhost:9090
|
||||
## Necessary for minio compatibility
|
||||
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE=true
|
||||
LANGFUSE_S3_EVENT_UPLOAD_PREFIX=events/
|
||||
|
||||
# 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=true
|
||||
LANGFUSE_RETURN_FROM_CLICKHOUSE=false
|
||||
LANGFUSE_READ_DASHBOARDS_FROM_CLICKHOUSE=false
|
||||
LANGFUSE_READ_FROM_CLICKHOUSE_ONLY=false
|
||||
@@ -245,6 +245,7 @@ OTEL_SERVICE_NAME="langfuse"
|
||||
# CLICKHOUSE_URL=
|
||||
# CLICKHOUSE_USER=
|
||||
# CLICKHOUSE_PASSWORD=
|
||||
# CLICKHOUSE_DB=
|
||||
|
||||
# Ingestion
|
||||
# LANGFUSE_INGESTION_QUEUE_DELAY_MS=
|
||||
|
||||
@@ -111,8 +111,9 @@ jobs:
|
||||
- name: Load default env
|
||||
run: |
|
||||
cp .env.dev.example .env
|
||||
grep -v -e '^LANGFUSE_S3_BATCH_EXPORT_ENABLED=' -e '^REDIS_HOST=' -e '^NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT=' .env.dev.legacy.example > .env
|
||||
echo "LANGFUSE_POSTGRES_INGESTION_ENABLED=true" >> .env
|
||||
grep -v -e '^LANGFUSE_S3_BATCH_EXPORT_ENABLED=' -e '^NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT=' .env.dev.example > .env
|
||||
echo "LANGFUSE_INGESTION_QUEUE_DELAY_MS=1" >> .env
|
||||
echo "LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=1" >> .env
|
||||
- name: Run + migrate
|
||||
run: |
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
@@ -184,7 +185,9 @@ jobs:
|
||||
- name: Load default env
|
||||
run: |
|
||||
cp .env.dev${{ matrix.blob-provider }}.example .env
|
||||
grep -v -e '^LANGFUSE_S3_BATCH_EXPORT_ENABLED=' -e '^REDIS_HOST=' -e '^NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT=' .env.dev${{ matrix.blob-provider }}.example > .env
|
||||
grep -v -e '^LANGFUSE_S3_BATCH_EXPORT_ENABLED=' -e '^NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT=' .env.dev${{ matrix.blob-provider }}.example > .env
|
||||
echo "LANGFUSE_INGESTION_QUEUE_DELAY_MS=1" >> .env
|
||||
echo "LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=1" >> .env
|
||||
- name: Run + migrate
|
||||
run: |
|
||||
docker compose -f docker-compose.dev${{ matrix.blob-provider }}.yml up -d
|
||||
|
||||
@@ -39,7 +39,6 @@ yarn-error.log*
|
||||
!.env.dev.example
|
||||
!.env.dev-azure.example
|
||||
!.env.prod.example
|
||||
!.env.dev.legacy.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
+33
-29
@@ -121,36 +121,40 @@ flowchart TB
|
||||
end
|
||||
end
|
||||
|
||||
DB[Postgres Database]
|
||||
Redis[Redis Cache/Queue]
|
||||
Clickhouse[Clickhouse Database]
|
||||
subgraph s9 ["VPC (US and EU separated)"]
|
||||
DB[Postgres Database]
|
||||
Redis[Redis Cache/Queue]
|
||||
Clickhouse[Clickhouse Database]
|
||||
|
||||
subgraph s1["Application (langfuse/langfuse/web)"]
|
||||
API[Public HTTP API]
|
||||
G[TRPC API]
|
||||
I[NextAuth]
|
||||
H[React Frontend]
|
||||
ORM
|
||||
H --> G
|
||||
H --> I
|
||||
G --> I
|
||||
G --- ORM
|
||||
API --- ORM
|
||||
I --- ORM
|
||||
end
|
||||
|
||||
subgraph s5["Application (langfuse/langfuse/worker)"]
|
||||
Worker
|
||||
end
|
||||
|
||||
Worker --- DB
|
||||
Worker --- Redis
|
||||
Worker --- Clickhouse
|
||||
|
||||
ORM --- DB
|
||||
ORM --- Redis
|
||||
ORM --- Clickhouse
|
||||
|
||||
|
||||
subgraph s1["Application (langfuse/langfuse/web)"]
|
||||
API[Public HTTP API]
|
||||
G[TRPC API]
|
||||
I[NextAuth]
|
||||
H[React Frontend]
|
||||
ORM
|
||||
H --> G
|
||||
H --> I
|
||||
G --> I
|
||||
G --- ORM
|
||||
API --- ORM
|
||||
I --- ORM
|
||||
end
|
||||
|
||||
subgraph s5["Application (langfuse/langfuse/worker)"]
|
||||
Worker
|
||||
end
|
||||
|
||||
Worker --- DB
|
||||
Worker --- Redis
|
||||
Worker --- Clickhouse
|
||||
|
||||
ORM --- DB
|
||||
ORM --- Redis
|
||||
ORM --- Clickhouse
|
||||
|
||||
JS --- API
|
||||
Python --- API
|
||||
```
|
||||
@@ -397,7 +401,7 @@ The background color of the following component will be `hsl(var(--primary))` an
|
||||
| --primary-accent | Primary accent color used for branding | Layout |
|
||||
| --hover-primary-accent | Primary accent color used for hover effects for links | SignIn and AuthCloudRegionSwitch |
|
||||
| --muted-green | Muted green for Event label | ObservationTree |
|
||||
| --muted-orange | Muted orange for Generation label | ObservationTree |
|
||||
| --muted-magenta | Muted magenta for Generation label | ObservationTree |
|
||||
| --muted-blue | Muted blue for Span label | ObservationTree |
|
||||
| --muted-gray | Muted gray for disabled status badges | StatusBadge |
|
||||
| --accent-light-green | Light green accent for background of output and assistant messages | IOPreview, Generations, Traces |
|
||||
@@ -439,7 +443,7 @@ You can update the default AI models and prices by adding or updating an entry i
|
||||
Please note that
|
||||
|
||||
- prices are in USD
|
||||
- the list is ordered by ID, so make sure to keep this order
|
||||
- the list is ordered by ID, so make sure to keep this order and insert new models at the end of the list
|
||||
- the `updated_at` field must be updated with the current date in ISO 8601 format. Otherwise, the change will be ignored.
|
||||
|
||||
### Transition period until V3 release
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
_We are Hiring_
|
||||
Join us in scaling Langfuse in Berlin, Germany. We are an open source company, we hire in person, we are only hiring technical talent.
|
||||
Join us in building out Langfuse in Berlin, Germany. Langfuse is the open source LLM engineering platform: we build tooling to help developers [build & improve LLM applications](https://langfuse.com/docs).
|
||||
|
||||
We are an open source company, we hire in person (4+ days a week), we only hire excellent technical talent. Find more information on our [careers page](https://langfuse.com/careers)
|
||||
|
||||
_Open Roles_
|
||||
|
||||
- Product Engineer, 70-130k EUR, 0.25-0.75% Equity, https://www.ycombinator.com/companies/langfuse/jobs/aAvmoFB-product-engineer
|
||||
- Developer Advocate, 60-110k EUR, 0.25-0.5% Equity, https://www.ycombinator.com/companies/langfuse/jobs/uHysbKH-developer-advocate-devrel
|
||||
- Product Engineer, 70-130k EUR, 0.1-0.35% Equity, https://www.ycombinator.com/companies/langfuse/jobs/aAvmoFB-product-engineer
|
||||
- Backend Engineer, 70-130k EUR, 0.1-0.35% Equity, https://www.ycombinator.com/companies/langfuse/jobs/1bO16H6-backend-engineer
|
||||
- Design Engineer, 70-130k EUR, 0.1-0.35% Equity, https://www.ycombinator.com/companies/langfuse/jobs/mDquP95-design-engineer
|
||||
- Developer Advocate, 70-130k EUR, 0.1-0.35% Equity, https://www.ycombinator.com/companies/langfuse/jobs/uHysbKH-developer-advocate-devrel
|
||||
|
||||
|
||||
_More Info_
|
||||
|
||||
- https://langfuse.com/careers
|
||||
- https://langfuse.com/docs
|
||||
- https://langfuse.com/why
|
||||
- https://langfuse.com/changelog
|
||||
|
||||
+2
-4
@@ -38,6 +38,8 @@ services:
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: ${LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT:-http://minio:9000}
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: ${LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE:-true}
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: ${LANGFUSE_S3_MEDIA_UPLOAD_PREFIX:-media/}
|
||||
LANGFUSE_INGESTION_QUEUE_DELAY_MS: ${LANGFUSE_INGESTION_QUEUE_DELAY_MS:-}
|
||||
LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS: ${LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS:-}
|
||||
REDIS_HOST: ${REDIS_HOST:-redis}
|
||||
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||
REDIS_AUTH: ${REDIS_AUTH:-myredissecret}
|
||||
@@ -61,10 +63,6 @@ services:
|
||||
LANGFUSE_INIT_USER_EMAIL: ${LANGFUSE_INIT_USER_EMAIL:-}
|
||||
LANGFUSE_INIT_USER_NAME: ${LANGFUSE_INIT_USER_NAME:-}
|
||||
LANGFUSE_INIT_USER_PASSWORD: ${LANGFUSE_INIT_USER_PASSWORD:-}
|
||||
LANGFUSE_SDK_CI_SYNC_PROCESSING_ENABLED: ${LANGFUSE_SDK_CI_SYNC_PROCESSING_ENABLED:-false}
|
||||
LANGFUSE_READ_FROM_POSTGRES_ONLY: ${LANGFUSE_READ_FROM_POSTGRES_ONLY:-false}
|
||||
LANGFUSE_READ_FROM_CLICKHOUSE_ONLY: ${LANGFUSE_READ_FROM_CLICKHOUSE_ONLY:-true}
|
||||
LANGFUSE_RETURN_FROM_CLICKHOUSE: ${LANGFUSE_RETURN_FROM_CLICKHOUSE:-true}
|
||||
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server
|
||||
|
||||
+3
-2
@@ -27,7 +27,7 @@
|
||||
"@langfuse/shared": "workspace:*",
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0",
|
||||
"axios": "^1.7.7",
|
||||
"next": "^14.2.15",
|
||||
"next": "^14.2.21",
|
||||
"next-auth": "^4.24.11",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@@ -47,7 +47,8 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"jsonpath-plus": "10.0.7"
|
||||
"jsonpath-plus": "10.0.7",
|
||||
"nanoid": "^3.3.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,10 +378,10 @@ types:
|
||||
type: string
|
||||
startDate:
|
||||
docs: Apply only to generations which are newer than this ISO date.
|
||||
type: optional<date>
|
||||
type: optional<datetime>
|
||||
unit:
|
||||
docs: Unit used by this model.
|
||||
type: ModelUsageUnit
|
||||
type: optional<ModelUsageUnit>
|
||||
inputPrice:
|
||||
docs: Price (USD) per input unit
|
||||
type: optional<double>
|
||||
|
||||
@@ -11,6 +11,7 @@ service:
|
||||
Batched ingestion for Langfuse Tracing. If you want to use tracing via the API, such as to build your own Langfuse client implementation, this is the only API route you need to implement.
|
||||
|
||||
Notes:
|
||||
- Introduction to data model: https://langfuse.com/docs/tracing-data-model
|
||||
- Batch sizes are limited to 3.5 MB in total. You need to adjust the number of events per batch accordingly.
|
||||
- The API does not return a 4xx status code for input errors. Instead, it responds with a 207 status code, which includes a list of the encountered errors.
|
||||
method: POST
|
||||
|
||||
@@ -58,7 +58,7 @@ types:
|
||||
type: optional<datetime>
|
||||
unit:
|
||||
docs: Unit used by this model.
|
||||
type: commons.ModelUsageUnit
|
||||
type: optional<commons.ModelUsageUnit>
|
||||
inputPrice:
|
||||
docs: Price (USD) per input unit
|
||||
type: optional<double>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"organization": "finto",
|
||||
"organization": "langfuse",
|
||||
"version": "0.43.7"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "3.4.0",
|
||||
"version": "3.11.0",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -84,7 +84,8 @@
|
||||
"packageManager": "pnpm@9.5.0",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"jsonpath-plus": "10.0.7"
|
||||
"jsonpath-plus": "10.0.7",
|
||||
"nanoid": "^3.3.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,21 +18,31 @@ then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure CLICKHOUSE_DB is set
|
||||
if [ -z "${CLICKHOUSE_DB}" ]; then
|
||||
export CLICKHOUSE_DB="default"
|
||||
fi
|
||||
|
||||
# Ensure CLICKHOUSE_CLUSTER_NAME is set
|
||||
if [ -z "${CLICKHOUSE_CLUSTER_NAME}" ]; then
|
||||
export CLICKHOUSE_CLUSTER_NAME="default"
|
||||
fi
|
||||
|
||||
# Construct the database URL
|
||||
if [ "$CLICKHOUSE_CLUSTER_ENABLED" == "false" ] ; 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-migrations-table-engine=MergeTree"
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&secure=true&skip_verify=true&x-migrations-table-engine=MergeTree"
|
||||
else
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-migrations-table-engine=MergeTree"
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&x-migrations-table-engine=MergeTree"
|
||||
fi
|
||||
|
||||
# Execute the up command
|
||||
migrate -source file://clickhouse/migrations/unclustered -database "$DATABASE_URL" down
|
||||
else
|
||||
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"
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&secure=true&skip_verify=true&x-cluster-name=${CLICKHOUSE_CLUSTER_NAME}&x-migrations-table-engine=ReplicatedMergeTree"
|
||||
else
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-cluster-name=default&x-migrations-table-engine=ReplicatedMergeTree"
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&x-cluster-name=${CLICKHOUSE_CLUSTER_NAME}&x-migrations-table-engine=ReplicatedMergeTree"
|
||||
fi
|
||||
|
||||
# Execute the up command
|
||||
|
||||
@@ -12,11 +12,16 @@ then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure CLICKHOUSE_DB is set
|
||||
if [ -z "${CLICKHOUSE_DB}" ]; then
|
||||
export CLICKHOUSE_DB="default"
|
||||
fi
|
||||
|
||||
# Construct the database URL
|
||||
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-migrations-table-engine=MergeTree"
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&secure=true&skip_verify=true&x-migrations-table-engine=MergeTree"
|
||||
else
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-migrations-table-engine=MergeTree"
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&x-migrations-table-engine=MergeTree"
|
||||
fi
|
||||
# Execute the drop command
|
||||
migrate -source file://clickhouse/migrations -database "$DATABASE_URL" drop
|
||||
|
||||
@@ -18,21 +18,31 @@ then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure CLICKHOUSE_DB is set
|
||||
if [ -z "${CLICKHOUSE_DB}" ]; then
|
||||
export CLICKHOUSE_DB="default"
|
||||
fi
|
||||
|
||||
# Ensure CLICKHOUSE_CLUSTER_NAME is set
|
||||
if [ -z "${CLICKHOUSE_CLUSTER_NAME}" ]; then
|
||||
export CLICKHOUSE_CLUSTER_NAME="default"
|
||||
fi
|
||||
|
||||
# Construct the database URL
|
||||
if [ "$CLICKHOUSE_CLUSTER_ENABLED" == "false" ] ; 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-migrations-table-engine=MergeTree"
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&secure=true&skip_verify=true&x-migrations-table-engine=MergeTree"
|
||||
else
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-migrations-table-engine=MergeTree"
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&x-migrations-table-engine=MergeTree"
|
||||
fi
|
||||
|
||||
# Execute the up command
|
||||
migrate -source file://clickhouse/migrations/unclustered -database "$DATABASE_URL" up
|
||||
else
|
||||
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"
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&secure=true&skip_verify=true&x-cluster-name=${CLICKHOUSE_CLUSTER_NAME}&x-migrations-table-engine=ReplicatedMergeTree"
|
||||
else
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-cluster-name=default&x-migrations-table-engine=ReplicatedMergeTree"
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&x-cluster-name=${CLICKHOUSE_CLUSTER_NAME}&x-migrations-table-engine=ReplicatedMergeTree"
|
||||
fi
|
||||
|
||||
# Execute the up command
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"axios": "^1.7.7",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.12.10",
|
||||
"bullmq": "^5.34.10",
|
||||
"dd-trace": "^5.23.1",
|
||||
"decimal.js": "^10.4.3",
|
||||
"exponential-backoff": "^3.1.1",
|
||||
@@ -121,7 +121,8 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"jsonpath-plus": "10.0.7"
|
||||
"jsonpath-plus": "10.0.7",
|
||||
"nanoid": "^3.3.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,17 @@ export type BatchExport = {
|
||||
url: string | null;
|
||||
log: string | null;
|
||||
};
|
||||
export type BillingMeterBackup = {
|
||||
stripe_customer_id: string;
|
||||
meter_id: string;
|
||||
start_time: Timestamp;
|
||||
end_time: Timestamp;
|
||||
aggregated_value: number;
|
||||
event_name: string;
|
||||
org_id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
};
|
||||
export type Comment = {
|
||||
id: string;
|
||||
project_id: string;
|
||||
@@ -479,6 +490,13 @@ export type Prompt = {
|
||||
tags: Generated<string[]>;
|
||||
labels: Generated<string[]>;
|
||||
};
|
||||
export type QueueBackUp = {
|
||||
id: string;
|
||||
project_id: string | null;
|
||||
queue_name: string;
|
||||
content: unknown;
|
||||
created_at: Generated<Timestamp>;
|
||||
};
|
||||
export type Score = {
|
||||
id: string;
|
||||
timestamp: Generated<Timestamp>;
|
||||
@@ -604,6 +622,7 @@ export type DB = {
|
||||
audit_logs: AuditLog;
|
||||
background_migrations: BackgroundMigration;
|
||||
batch_exports: BatchExport;
|
||||
billing_meter_backups: BillingMeterBackup;
|
||||
comments: Comment;
|
||||
cron_jobs: CronJobs;
|
||||
dataset_items: DatasetItem;
|
||||
@@ -628,6 +647,7 @@ export type DB = {
|
||||
project_memberships: ProjectMembership;
|
||||
projects: Project;
|
||||
prompts: Prompt;
|
||||
queue_backups: QueueBackUp;
|
||||
score_configs: ScoreConfig;
|
||||
scores: Score;
|
||||
Session: Session;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "queue_backups" (
|
||||
"id" TEXT NOT NULL,
|
||||
"project_id" TEXT,
|
||||
"queue_name" TEXT NOT NULL,
|
||||
"content" JSONB NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "queue_backups_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "traces" ADD CONSTRAINT "traces_session_id_project_id_fkey" FOREIGN KEY ("session_id", "project_id") REFERENCES "trace_sessions"("id", "project_id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "traces" DROP CONSTRAINT "traces_session_id_project_id_fkey";
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "billing_meter_backups" (
|
||||
"stripe_customer_id" TEXT NOT NULL,
|
||||
"meter_id" TEXT NOT NULL,
|
||||
"start_time" TIMESTAMP(3) NOT NULL,
|
||||
"end_time" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
"aggregated_value" INTEGER NOT NULL,
|
||||
|
||||
"event_name" TEXT NOT NULL,
|
||||
"org_id" TEXT NOT NULL,
|
||||
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "billing_meter_backups_stripe_customer_id_meter_id_start_tim_idx" ON "billing_meter_backups"("stripe_customer_id", "meter_id", "start_time", "end_time");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "billing_meter_backups_stripe_customer_id_meter_id_start_tim_key" ON "billing_meter_backups"("stripe_customer_id", "meter_id", "start_time", "end_time");
|
||||
@@ -272,7 +272,6 @@ model TraceSession {
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
bookmarked Boolean @default(false)
|
||||
public Boolean @default(false)
|
||||
traces Trace[]
|
||||
|
||||
@@id([id, projectId])
|
||||
@@index([projectId])
|
||||
@@ -284,25 +283,24 @@ model TraceSession {
|
||||
// Update TraceView below when making changes to this model!
|
||||
|
||||
model Trace {
|
||||
id String @id @default(cuid())
|
||||
externalId String? @map("external_id")
|
||||
timestamp DateTime @default(now())
|
||||
id String @id @default(cuid())
|
||||
externalId String? @map("external_id")
|
||||
timestamp DateTime @default(now())
|
||||
name String?
|
||||
userId String? @map("user_id")
|
||||
userId String? @map("user_id")
|
||||
metadata Json?
|
||||
release String?
|
||||
version String?
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
public Boolean @default(false)
|
||||
bookmarked Boolean @default(false)
|
||||
tags String[] @default([])
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
public Boolean @default(false)
|
||||
bookmarked Boolean @default(false)
|
||||
tags String[] @default([])
|
||||
input Json?
|
||||
output Json?
|
||||
sessionId String? @map("session_id")
|
||||
session TraceSession? @relation(fields: [sessionId, projectId], references: [id, projectId])
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
sessionId String? @map("session_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
@@index([projectId, timestamp])
|
||||
@@index([sessionId])
|
||||
@@ -1017,3 +1015,37 @@ model ObservationMedia {
|
||||
@@index([projectId, observationId])
|
||||
@@map("observation_media")
|
||||
}
|
||||
|
||||
model QueueBackUp {
|
||||
id String @id @default(cuid())
|
||||
projectId String? @map("project_id")
|
||||
queueName String @map("queue_name")
|
||||
content Json
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@map("queue_backups")
|
||||
}
|
||||
|
||||
model BillingMeterBackup {
|
||||
// unique
|
||||
stripeCustomerId String @map("stripe_customer_id")
|
||||
meterId String @map("meter_id")
|
||||
startTime DateTime @map("start_time")
|
||||
endTime DateTime @map("end_time")
|
||||
|
||||
// value
|
||||
aggregatedValue Int @map("aggregated_value")
|
||||
|
||||
// labels
|
||||
eventName String @map("event_name")
|
||||
orgId String @map("org_id")
|
||||
|
||||
// ts
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([stripeCustomerId, meterId, startTime, endTime])
|
||||
@@index([stripeCustomerId, meterId, startTime, endTime])
|
||||
@@map("billing_meter_backups")
|
||||
}
|
||||
|
||||
@@ -96,20 +96,36 @@ export const prepareClickhouse = async (
|
||||
'version' AS version,
|
||||
repeat('input', toInt64(randExponential(1 / 100))) AS input,
|
||||
repeat('output', toInt64(randExponential(1 / 100))) AS output,
|
||||
case
|
||||
when number % 2 = 0 then 'claude-3-haiku-20240307'
|
||||
else 'gpt-4'
|
||||
end as provided_model_name,
|
||||
case
|
||||
when number % 2 = 0 then 'cltr0w45b000008k1407o9qv1'
|
||||
else 'clrntkjgy000f08jx79v9g1xj'
|
||||
end as internal_model_id,
|
||||
'{"temperature": 0.7, "max_tokens": 150}' AS model_parameters,
|
||||
map('input', toUInt64(randUniform(0, 1000)), 'output', toUInt64(randUniform(0, 1000)), 'total', toUInt64(randUniform(0, 2000))) AS provided_usage_details,
|
||||
map('input', toUInt64(randUniform(0, 1000)), 'output', toUInt64(randUniform(0, 1000)), 'total', toUInt64(randUniform(0, 2000))) AS usage_details,
|
||||
map('input', toDecimal64(randUniform(0, 1000), 12), 'output', toDecimal64(randUniform(0, 1000), 12), 'total', toDecimal64(randUniform(0, 2000), 12)) AS provided_cost_details,
|
||||
map('input', toDecimal64(randUniform(0, 1000), 12), 'output', toDecimal64(randUniform(0, 1000), 12), 'total', toDecimal64(randUniform(0, 2000), 12)) AS cost_details,
|
||||
toDecimal64(randUniform(0, 2000), 12) AS total_cost,
|
||||
if("type" = 'GENERATION',
|
||||
case
|
||||
when number % 2 = 0 then 'claude-3-haiku-20240307'
|
||||
else 'gpt-4'
|
||||
end,
|
||||
NULL) as provided_model_name,
|
||||
if("type" = 'GENERATION',
|
||||
case
|
||||
when number % 2 = 0 then 'cltr0w45b000008k1407o9qv1'
|
||||
else 'clrntkjgy000f08jx79v9g1xj'
|
||||
end,
|
||||
NULL) as internal_model_id,
|
||||
if("type" = 'GENERATION',
|
||||
'{"temperature": 0.7, "max_tokens": 150}',
|
||||
'{}') AS model_parameters,
|
||||
if("type" = 'GENERATION',
|
||||
map('input', toUInt64(randUniform(0, 1000)), 'output', toUInt64(randUniform(0, 1000)), 'total', toUInt64(randUniform(0, 2000))),
|
||||
map()) AS provided_usage_details,
|
||||
if("type" = 'GENERATION',
|
||||
map('input', toUInt64(randUniform(0, 1000)), 'output', toUInt64(randUniform(0, 1000)), 'total', toUInt64(randUniform(0, 2000))),
|
||||
map()) AS usage_details,
|
||||
if("type" = 'GENERATION',
|
||||
map('input', toDecimal64(randUniform(0, 1000), 12), 'output', toDecimal64(randUniform(0, 1000), 12), 'total', toDecimal64(randUniform(0, 2000), 12)),
|
||||
map()) AS provided_cost_details,
|
||||
if("type" = 'GENERATION',
|
||||
map('input', toDecimal64(randUniform(0, 1000), 12), 'output', toDecimal64(randUniform(0, 1000), 12), 'total', toDecimal64(randUniform(0, 2000), 12)),
|
||||
map()) AS cost_details,
|
||||
if("type" = 'GENERATION',
|
||||
toDecimal64(randUniform(0, 2000), 12),
|
||||
NULL) AS total_cost,
|
||||
addMilliseconds(start_time, if(rand() < 0.6, floor(randUniform(0, 500)), floor(randUniform(0, 600)))) AS completion_start_time,
|
||||
array(${SEED_PROMPTS.map((p) => `concat('${p.id}',project_id)`).join(
|
||||
",",
|
||||
|
||||
@@ -29,17 +29,10 @@ const EnvSchema = z.object({
|
||||
LANGFUSE_CACHE_PROMPT_ENABLED: z.enum(["true", "false"]).default("false"),
|
||||
LANGFUSE_CACHE_PROMPT_TTL_SECONDS: z.coerce.number().default(60 * 60),
|
||||
CLICKHOUSE_URL: z.string().url(),
|
||||
CLICKHOUSE_CLUSTER_NAME: z.string().default("default"),
|
||||
CLICKHOUSE_DB: z.string().default("default"),
|
||||
CLICKHOUSE_USER: z.string(),
|
||||
CLICKHOUSE_PASSWORD: z.string(),
|
||||
LANGFUSE_SDK_CI_SYNC_PROCESSING_ENABLED: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
LANGFUSE_CLICKHOUSE_INGESTION_ENABLED: z
|
||||
.enum(["true", "false"])
|
||||
.default("true"),
|
||||
LANGFUSE_POSTGRES_INGESTION_ENABLED: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
|
||||
LANGFUSE_INGESTION_QUEUE_DELAY_MS: z.coerce
|
||||
.number()
|
||||
@@ -66,6 +59,13 @@ const EnvSchema = z.object({
|
||||
.default("false"),
|
||||
LANGFUSE_USE_AZURE_BLOB: z.enum(["true", "false"]).default("false"),
|
||||
STRIPE_SECRET_KEY: z.string().optional(),
|
||||
|
||||
LANGFUSE_S3_CORE_DATA_EXPORT_IS_ENABLED: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
LANGFUSE_POSTGRES_METERING_DATA_EXPORT_IS_ENABLED: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
});
|
||||
|
||||
export const env: z.infer<typeof EnvSchema> =
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BaseError } from "./BaseError";
|
||||
|
||||
export class ApiError extends BaseError {
|
||||
constructor(description = "Api call failed") {
|
||||
super("ApiError", 500, description, true);
|
||||
constructor(description = "Api call failed", status = 500) {
|
||||
super("ApiError", status, description, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,13 @@ export const observationsTableCols: ColumnDefinition[] = [
|
||||
options: [], // to be added at runtime
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "type",
|
||||
id: "type",
|
||||
type: "stringOptions",
|
||||
options: [],
|
||||
internal: 'o."type"',
|
||||
},
|
||||
{ name: "Trace ID", id: "traceId", type: "string", internal: 't."id"' },
|
||||
{
|
||||
name: "Trace Name",
|
||||
@@ -200,6 +207,7 @@ export type ObservationOptions = {
|
||||
scores_avg: Array<string>;
|
||||
promptName: Array<OptionsDefinition>;
|
||||
tags: Array<OptionsDefinition>;
|
||||
type: Array<OptionsDefinition>;
|
||||
};
|
||||
|
||||
export function observationsTableColsWithOptions(
|
||||
@@ -227,6 +235,9 @@ export function observationsTableColsWithOptions(
|
||||
if (col.id === "tags") {
|
||||
return { ...col, options: options?.tags ?? [] };
|
||||
}
|
||||
if (col.id === "type") {
|
||||
return { ...col, options: options?.type ?? [] };
|
||||
}
|
||||
return col;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export const clickhouseClient = (opts?: NodeClickHouseClientConfigOptions) =>
|
||||
url: env.CLICKHOUSE_URL,
|
||||
username: env.CLICKHOUSE_USER,
|
||||
password: env.CLICKHOUSE_PASSWORD,
|
||||
database: "default",
|
||||
database: env.CLICKHOUSE_DB,
|
||||
clickhouse_settings: {
|
||||
async_insert: 1,
|
||||
wait_for_async_insert: 1, // if disabled, we won't get errors from clickhouse
|
||||
|
||||
@@ -32,6 +32,8 @@ export * from "./redis/legacyIngestion";
|
||||
export * from "./redis/ingestionQueue";
|
||||
export * from "./redis/postHogIntegrationQueue";
|
||||
export * from "./redis/postHogIntegrationProcessingQueue";
|
||||
export * from "./redis/coreDataS3ExportQueue";
|
||||
export * from "./redis/meteringDataPostgresExportQueue";
|
||||
export * from "./redis/experimentCreateQueue";
|
||||
export * from "./auth/types";
|
||||
export * from "./ingestion/legacy/index";
|
||||
@@ -44,6 +46,7 @@ export * from "./logger";
|
||||
export * from "./queries";
|
||||
export * from "./repositories";
|
||||
export * from "./redis/evalExecutionQueue";
|
||||
export * from "./services/sessions-ui-table-service";
|
||||
|
||||
// test utils
|
||||
export * from "./test-utils";
|
||||
|
||||
@@ -394,7 +394,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
model?: Model,
|
||||
existingObservation?: Omit<Observation, "input" | "output">,
|
||||
) {
|
||||
let newPromptTokens = body.usage?.input || body.usageDetails?.input;
|
||||
let newPromptTokens = body.usage?.input ?? body.usageDetails?.input;
|
||||
if (newPromptTokens === undefined && model && model.tokenizerId) {
|
||||
if (body.input) {
|
||||
newPromptTokens = calculateTokenDelegate({
|
||||
@@ -419,7 +419,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
let newCompletionTokens = body.usage?.output || body.usageDetails?.output;
|
||||
let newCompletionTokens = body.usage?.output ?? body.usageDetails?.output;
|
||||
|
||||
if (newCompletionTokens === undefined && model && model.tokenizerId) {
|
||||
if (body.output) {
|
||||
|
||||
@@ -18,11 +18,9 @@ import {
|
||||
traceException,
|
||||
} from "../instrumentation";
|
||||
import { logger } from "../logger";
|
||||
import { LegacyIngestionEventType, QueueJobs } from "../queues";
|
||||
import { QueueJobs } from "../queues";
|
||||
import { IngestionQueue } from "../redis/ingestionQueue";
|
||||
import { LegacyIngestionQueue } from "../redis/legacyIngestion";
|
||||
import { redis } from "../redis/redis";
|
||||
import { handleBatch } from "./legacy";
|
||||
import {
|
||||
StorageService,
|
||||
StorageServiceFactory,
|
||||
@@ -185,140 +183,47 @@ export const processEventBatch = async (
|
||||
});
|
||||
});
|
||||
|
||||
// This is a workaround to allow us to disable async ingestion processing for SDK CI testing
|
||||
// TODO: remove this block after SDKs are ready for V3 async ingestion processing
|
||||
if (env.LANGFUSE_SDK_CI_SYNC_PROCESSING_ENABLED === "true") {
|
||||
const result = await handleBatch(
|
||||
sortedBatch,
|
||||
authCheck,
|
||||
tokenCountDelegate,
|
||||
);
|
||||
|
||||
// in case we did not return early, we return the result here
|
||||
return aggregateBatchResult(
|
||||
[...validationErrors, ...authenticationErrors, ...result.errors],
|
||||
result.results,
|
||||
authCheck.scope.projectId,
|
||||
);
|
||||
}
|
||||
|
||||
// Send each event individually to IngestionQueue for ClickHouse processing
|
||||
if (env.LANGFUSE_CLICKHOUSE_INGESTION_ENABLED === "true") {
|
||||
if (s3UploadErrored) {
|
||||
throw new Error(
|
||||
"Failed to upload events to blob storage, aborting event processing",
|
||||
);
|
||||
}
|
||||
|
||||
if (redis) {
|
||||
const queue = IngestionQueue.getInstance();
|
||||
await Promise.all(
|
||||
Object.keys(sortedBatchByEventBodyId).map(async (id) =>
|
||||
queue
|
||||
? queue.add(
|
||||
QueueJobs.IngestionJob,
|
||||
{
|
||||
id: randomUUID(),
|
||||
timestamp: new Date(),
|
||||
name: QueueJobs.IngestionJob as const,
|
||||
payload: {
|
||||
data: {
|
||||
type: sortedBatchByEventBodyId[id].type,
|
||||
eventBodyId: sortedBatchByEventBodyId[id].eventBodyId,
|
||||
},
|
||||
authCheck,
|
||||
},
|
||||
},
|
||||
{
|
||||
delay: env.LANGFUSE_INGESTION_QUEUE_DELAY_MS,
|
||||
},
|
||||
)
|
||||
: Promise.reject("Failed to instantiate queue"),
|
||||
),
|
||||
);
|
||||
if (env.LANGFUSE_POSTGRES_INGESTION_ENABLED !== "true") {
|
||||
// If postgres ingestion is disabled, we return early
|
||||
return aggregateBatchResult(
|
||||
[...validationErrors, ...authenticationErrors],
|
||||
sortedBatch.map((event) => ({ id: event.id, result: event })),
|
||||
authCheck.scope.projectId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (env.LANGFUSE_POSTGRES_INGESTION_ENABLED === "true") {
|
||||
// As part of the legacy processing we sent the entire batch to the worker.
|
||||
if (redis) {
|
||||
const queue = LegacyIngestionQueue.getInstance();
|
||||
|
||||
if (queue) {
|
||||
let addToQueueFailed = false;
|
||||
|
||||
const queuePayload: LegacyIngestionEventType = !s3UploadErrored
|
||||
? {
|
||||
data: Object.keys(sortedBatchByEventBodyId).map((id) => {
|
||||
const { key, type, eventBodyId } = sortedBatchByEventBodyId[id];
|
||||
return {
|
||||
type,
|
||||
eventBodyId,
|
||||
eventId: key,
|
||||
};
|
||||
}),
|
||||
authCheck,
|
||||
useS3EventStore: true,
|
||||
}
|
||||
: { data: sortedBatch, authCheck, useS3EventStore: false };
|
||||
|
||||
try {
|
||||
await queue.add(QueueJobs.LegacyIngestionJob, {
|
||||
payload: queuePayload,
|
||||
id: randomUUID(),
|
||||
timestamp: new Date(),
|
||||
name: QueueJobs.LegacyIngestionJob as const,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
logger.warn(
|
||||
"Failed to add batch to queue, falling back to sync processing",
|
||||
e,
|
||||
);
|
||||
addToQueueFailed = true;
|
||||
}
|
||||
|
||||
if (!addToQueueFailed) {
|
||||
return aggregateBatchResult(
|
||||
// we are not sending additional server errors to the client in case of early return
|
||||
[...validationErrors, ...authenticationErrors],
|
||||
sortedBatch.map((event) => ({ id: event.id, result: event })),
|
||||
authCheck.scope.projectId,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
"Ingestion queue not initialized, falling back to sync processing",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************
|
||||
* SYNC PROCESSING *
|
||||
*******************/
|
||||
const result = await handleBatch(
|
||||
sortedBatch,
|
||||
authCheck,
|
||||
tokenCountDelegate,
|
||||
);
|
||||
|
||||
// in case we did not return early, we return the result here
|
||||
return aggregateBatchResult(
|
||||
[...validationErrors, ...authenticationErrors, ...result.errors],
|
||||
result.results,
|
||||
authCheck.scope.projectId,
|
||||
if (s3UploadErrored) {
|
||||
throw new Error(
|
||||
"Failed to upload events to blob storage, aborting event processing",
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Either Clickhouse or Postgres ingestion (or both) must be enabled",
|
||||
if (!redis) {
|
||||
throw new Error("Redis not initialized, aborting event processing");
|
||||
}
|
||||
|
||||
const queue = IngestionQueue.getInstance();
|
||||
await Promise.all(
|
||||
Object.keys(sortedBatchByEventBodyId).map(async (id) =>
|
||||
queue
|
||||
? queue.add(
|
||||
QueueJobs.IngestionJob,
|
||||
{
|
||||
id: randomUUID(),
|
||||
timestamp: new Date(),
|
||||
name: QueueJobs.IngestionJob as const,
|
||||
payload: {
|
||||
data: {
|
||||
type: sortedBatchByEventBodyId[id].type,
|
||||
eventBodyId: sortedBatchByEventBodyId[id].eventBodyId,
|
||||
},
|
||||
authCheck,
|
||||
},
|
||||
},
|
||||
{
|
||||
delay: env.LANGFUSE_INGESTION_QUEUE_DELAY_MS,
|
||||
},
|
||||
)
|
||||
: Promise.reject("Failed to instantiate queue"),
|
||||
),
|
||||
);
|
||||
|
||||
return aggregateBatchResult(
|
||||
[...validationErrors, ...authenticationErrors],
|
||||
sortedBatch.map((event) => ({ id: event.id, result: event })),
|
||||
authCheck.scope.projectId,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -112,13 +112,16 @@ export const UsageOrCostDetails = z
|
||||
.union([OpenAIUsageSchema, RawUsageOrCostDetails])
|
||||
.nullish();
|
||||
|
||||
// Using z.any instead of jsonSchema for input/output as we saw huge CPU overhead for large numeric arrays.
|
||||
// With this setup parsing should be more lightweight and doesn't block other requests.
|
||||
// As we allow plain values, arrays, and objects the JSON parse via bodyParser should suffice.
|
||||
export const TraceBody = z.object({
|
||||
id: z.string().nullish(),
|
||||
timestamp: stringDateTime,
|
||||
name: z.string().max(1000).nullish(),
|
||||
externalId: z.string().nullish(),
|
||||
input: jsonSchema.nullish(),
|
||||
output: jsonSchema.nullish(),
|
||||
input: z.any().nullish(),
|
||||
output: z.any().nullish(),
|
||||
sessionId: z.string().nullish(),
|
||||
userId: z.string().nullish(),
|
||||
metadata: jsonSchema.nullish(),
|
||||
@@ -133,8 +136,8 @@ export const OptionalObservationBody = z.object({
|
||||
name: z.string().nullish(),
|
||||
startTime: stringDateTime,
|
||||
metadata: jsonSchema.nullish(),
|
||||
input: jsonSchema.nullish(),
|
||||
output: jsonSchema.nullish(),
|
||||
input: z.any().nullish(),
|
||||
output: z.any().nullish(),
|
||||
level: z.nativeEnum(ObservationLevel).nullish(),
|
||||
statusMessage: z.string().nullish(),
|
||||
parentObservationId: z.string().nullish(),
|
||||
|
||||
@@ -116,6 +116,7 @@ export const anthropicModels = [
|
||||
] as const;
|
||||
|
||||
export const vertexAIModels = [
|
||||
"gemini-2.0-flash-exp",
|
||||
"gemini-1.5-pro",
|
||||
"gemini-1.5-flash",
|
||||
"gemini-1.0-pro",
|
||||
|
||||
@@ -118,6 +118,8 @@ export enum QueueName {
|
||||
ExperimentCreate = "experiment-create-queue",
|
||||
PostHogIntegrationQueue = "posthog-integration-queue",
|
||||
PostHogIntegrationProcessingQueue = "posthog-integration-processing-queue",
|
||||
CoreDataS3ExportQueue = "core-data-s3-export-queue",
|
||||
MeteringDataPostgresExportQueue = "metering-data-postgres-export-queue",
|
||||
}
|
||||
|
||||
export enum QueueJobs {
|
||||
@@ -134,6 +136,8 @@ export enum QueueJobs {
|
||||
ExperimentCreateJob = "experiment-create-job",
|
||||
PostHogIntegrationJob = "posthog-integration-job",
|
||||
PostHogIntegrationProcessingJob = "posthog-integration-processing-job",
|
||||
CoreDataS3ExportJob = "core-data-s3-export-job",
|
||||
MeteringDataPostgresExportJob = "metering-data-postgres-export-job",
|
||||
}
|
||||
|
||||
export type TQueueJobTypes = {
|
||||
|
||||
@@ -45,6 +45,7 @@ export class CloudUsageMeteringQueue {
|
||||
QueueJobs.CloudUsageMeteringJob,
|
||||
{},
|
||||
{
|
||||
// Run at minute 5 of every hour (e.g. 1:05, 2:05, 3:05, etc)
|
||||
repeat: { pattern: "5 * * * *" },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Queue } from "bullmq";
|
||||
import { QueueName, QueueJobs } from "../queues";
|
||||
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
|
||||
import { logger } from "../logger";
|
||||
import { env } from "../../env";
|
||||
|
||||
export class CoreDataS3ExportQueue {
|
||||
private static instance: Queue | null = null;
|
||||
|
||||
public static getInstance(): Queue | null {
|
||||
if (env.LANGFUSE_S3_CORE_DATA_EXPORT_IS_ENABLED !== "true") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (CoreDataS3ExportQueue.instance) {
|
||||
return CoreDataS3ExportQueue.instance;
|
||||
}
|
||||
|
||||
const newRedis = createNewRedisInstance({
|
||||
enableOfflineQueue: false,
|
||||
...redisQueueRetryOptions,
|
||||
});
|
||||
|
||||
CoreDataS3ExportQueue.instance = newRedis
|
||||
? new Queue(QueueName.CoreDataS3ExportQueue, {
|
||||
connection: newRedis,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 5000,
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
CoreDataS3ExportQueue.instance?.on("error", (err) => {
|
||||
logger.error("CoreDataS3ExportQueue error", err);
|
||||
});
|
||||
|
||||
if (CoreDataS3ExportQueue.instance) {
|
||||
logger.debug("Scheduling jobs for CoreDataS3ExportQueue");
|
||||
CoreDataS3ExportQueue.instance
|
||||
.add(
|
||||
QueueJobs.CoreDataS3ExportJob,
|
||||
{},
|
||||
{
|
||||
repeat: { pattern: "15 3 * * *" }, // every day at 3:15am
|
||||
},
|
||||
)
|
||||
.catch((err) => {
|
||||
logger.error("Error adding CoreDataS3ExportJob schedule", err);
|
||||
});
|
||||
}
|
||||
|
||||
return CoreDataS3ExportQueue.instance;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import { TraceDeleteQueue } from "./traceDelete";
|
||||
import { ProjectDeleteQueue } from "./projectDelete";
|
||||
import { PostHogIntegrationQueue } from "./postHogIntegrationQueue";
|
||||
import { PostHogIntegrationProcessingQueue } from "./postHogIntegrationProcessingQueue";
|
||||
import { CoreDataS3ExportQueue } from "./coreDataS3ExportQueue";
|
||||
import { MeteringDataPostgresExportQueue } from "./meteringDataPostgresExportQueue";
|
||||
|
||||
export function getQueue(queueName: QueueName): Queue | null {
|
||||
switch (queueName) {
|
||||
@@ -41,6 +43,10 @@ export function getQueue(queueName: QueueName): Queue | null {
|
||||
return PostHogIntegrationProcessingQueue.getInstance();
|
||||
case QueueName.IngestionSecondaryQueue:
|
||||
return SecondaryIngestionQueue.getInstance();
|
||||
case QueueName.CoreDataS3ExportQueue:
|
||||
return CoreDataS3ExportQueue.getInstance();
|
||||
case QueueName.MeteringDataPostgresExportQueue:
|
||||
return MeteringDataPostgresExportQueue.getInstance();
|
||||
default:
|
||||
const exhaustiveCheckDefault: never = queueName;
|
||||
throw new Error(`Queue ${queueName} not found`);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Queue } from "bullmq";
|
||||
import { QueueName, QueueJobs } from "../queues";
|
||||
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
|
||||
import { logger } from "../logger";
|
||||
import { env } from "../../env";
|
||||
|
||||
export class MeteringDataPostgresExportQueue {
|
||||
private static instance: Queue | null = null;
|
||||
|
||||
public static getInstance(): Queue | null {
|
||||
if (env.LANGFUSE_POSTGRES_METERING_DATA_EXPORT_IS_ENABLED !== "true") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (MeteringDataPostgresExportQueue.instance) {
|
||||
return MeteringDataPostgresExportQueue.instance;
|
||||
}
|
||||
|
||||
const newRedis = createNewRedisInstance({
|
||||
enableOfflineQueue: false,
|
||||
...redisQueueRetryOptions,
|
||||
});
|
||||
|
||||
MeteringDataPostgresExportQueue.instance = newRedis
|
||||
? new Queue(QueueName.MeteringDataPostgresExportQueue, {
|
||||
connection: newRedis,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 5000,
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
MeteringDataPostgresExportQueue.instance?.on("error", (err) => {
|
||||
logger.error("MeteringDataPostgresExportQueue error", err);
|
||||
});
|
||||
|
||||
if (MeteringDataPostgresExportQueue.instance) {
|
||||
logger.debug("Scheduling jobs for MeteringDataPostgresExportQueue");
|
||||
MeteringDataPostgresExportQueue.instance
|
||||
.add(
|
||||
QueueJobs.MeteringDataPostgresExportJob,
|
||||
{},
|
||||
{
|
||||
repeat: { pattern: "30 2 * * *" }, // every day at 2:30am UTC
|
||||
},
|
||||
)
|
||||
.catch((err) => {
|
||||
logger.error(
|
||||
"Error adding MeteringDataPostgresExportJob schedule",
|
||||
err,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return MeteringDataPostgresExportQueue.instance;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
convertDateToClickhouseDateTime,
|
||||
} from "../clickhouse/client";
|
||||
import { logger } from "../logger";
|
||||
import { instrumentAsync } from "../instrumentation";
|
||||
import { getTracer, instrumentAsync } from "../instrumentation";
|
||||
import {
|
||||
StorageService,
|
||||
StorageServiceFactory,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { randomUUID } from "crypto";
|
||||
import { getClickhouseEntityType } from "../clickhouse/schemaUtils";
|
||||
import { NodeClickHouseClientConfigOptions } from "@clickhouse/client/dist/config";
|
||||
import { context, trace } from "@opentelemetry/api";
|
||||
|
||||
let s3StorageServiceClient: StorageService;
|
||||
|
||||
@@ -101,6 +102,64 @@ export async function upsertClickhouse<
|
||||
});
|
||||
}
|
||||
|
||||
export async function* queryClickhouseStream<T>(opts: {
|
||||
query: string;
|
||||
params?: Record<string, unknown> | undefined;
|
||||
clickhouseConfigs?: NodeClickHouseClientConfigOptions;
|
||||
}): AsyncGenerator<T> {
|
||||
const tracer = getTracer("clickhouse-query-stream");
|
||||
const span = tracer.startSpan("clickhouse-query-stream");
|
||||
|
||||
try {
|
||||
const res = await context.with(
|
||||
trace.setSpan(context.active(), span),
|
||||
async () => {
|
||||
// https://opentelemetry.io/docs/specs/semconv/database/database-spans/
|
||||
span.setAttribute("ch.query.text", opts.query);
|
||||
|
||||
const res = await clickhouseClient(opts.clickhouseConfigs).query({
|
||||
query: opts.query,
|
||||
format: "JSONEachRow",
|
||||
query_params: opts.params,
|
||||
});
|
||||
// same logic as for prisma. we want to see queries in development
|
||||
if (env.NODE_ENV === "development") {
|
||||
logger.info(`clickhouse:query ${res.query_id} ${opts.query}`);
|
||||
}
|
||||
|
||||
span.setAttribute("ch.queryId", res.query_id);
|
||||
|
||||
// add summary headers to the span. Helps to tune performance
|
||||
const summaryHeader = res.response_headers["x-clickhouse-summary"];
|
||||
if (summaryHeader) {
|
||||
try {
|
||||
const summary = Array.isArray(summaryHeader)
|
||||
? JSON.parse(summaryHeader[0])
|
||||
: JSON.parse(summaryHeader);
|
||||
for (const key in summary) {
|
||||
span.setAttribute(`ch.${key}`, summary[key]);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Failed to parse clickhouse summary header ${summaryHeader}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
},
|
||||
);
|
||||
|
||||
for await (const rows of res.stream<T>()) {
|
||||
for (const row of rows) {
|
||||
yield row.json();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
export async function queryClickhouse<T>(opts: {
|
||||
query: string;
|
||||
params?: Record<string, unknown> | undefined;
|
||||
|
||||
@@ -418,7 +418,7 @@ export const getObservationLatencies = async (
|
||||
// Skipping FINAL here, as the quantiles are approximate to begin with.
|
||||
const query = `
|
||||
SELECT
|
||||
quantiles(0.5, 0.9, 0.95, 0.99)(date_diff('milliseconds', o.start_time, o.end_time)) as quantiles,
|
||||
quantiles(0.5, 0.9, 0.95, 0.99)(date_diff('millisecond', o.start_time, o.end_time)) as quantiles,
|
||||
name
|
||||
FROM observations o
|
||||
${chFilter.find((f) => f.clickhouseTable === "traces") ? "LEFT JOIN traces t ON o.trace_id = t.id AND o.project_id = t.project_id" : ""}
|
||||
@@ -465,7 +465,7 @@ export const getTracesLatencies = async (
|
||||
select o.trace_id,
|
||||
t.name,
|
||||
o.project_id,
|
||||
date_diff('milliseconds', min(o.start_time), coalesce(max(o.end_time), max(o.start_time))) as duration
|
||||
date_diff('millisecond', min(o.start_time), coalesce(max(o.end_time), max(o.start_time))) as duration
|
||||
FROM traces t
|
||||
JOIN observations o
|
||||
ON o.trace_id = t.id AND o.project_id = t.project_id
|
||||
@@ -521,7 +521,7 @@ export const getModelLatenciesOverTime = async (
|
||||
SELECT
|
||||
${selectTimeseriesColumn(groupBy, "o.start_time", "start_time_bucket")},
|
||||
provided_model_name,
|
||||
quantiles(0.5, 0.75, 0.9, 0.95, 0.99)(date_diff('milliseconds', o.start_time, o.end_time)) as quantiles
|
||||
quantiles(0.5, 0.75, 0.9, 0.95, 0.99)(date_diff('millisecond', o.start_time, o.end_time)) as quantiles
|
||||
FROM observations o
|
||||
${traceFilter ? "JOIN traces t ON o.trace_id = t.id AND o.project_id = t.project_id" : ""}
|
||||
WHERE project_id = {projectId: String}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
commandClickhouse,
|
||||
parseClickhouseUTCDateTimeFormat,
|
||||
queryClickhouse,
|
||||
queryClickhouseStream,
|
||||
upsertClickhouse,
|
||||
} from "./clickhouse";
|
||||
import { ObservationLevel } from "@prisma/client";
|
||||
@@ -414,7 +415,7 @@ export const getObservationsTable = async (
|
||||
const observationRecords = await getObservationsTableInternal<
|
||||
Omit<
|
||||
ObservationsTableQueryResult,
|
||||
"trace_tags" | "trace_name" | "trace_user_id" | "type"
|
||||
"trace_tags" | "trace_name" | "trace_user_id"
|
||||
>
|
||||
>({
|
||||
...opts,
|
||||
@@ -431,7 +432,7 @@ export const getObservationsTable = async (
|
||||
return observationRecords.map((o) => {
|
||||
const trace = traces.find((t) => t.id === o.trace_id);
|
||||
return {
|
||||
...convertObservationToView({ ...o, type: "GENERATION" }),
|
||||
...convertObservationToView(o),
|
||||
latency: o.latency ? Number(o.latency) / 1000 : null,
|
||||
timeToFirstToken: o.time_to_first_token
|
||||
? Number(o.time_to_first_token) / 1000
|
||||
@@ -449,7 +450,7 @@ export const getObservationsTableWithModelData = async (
|
||||
const observationRecords = await getObservationsTableInternal<
|
||||
Omit<
|
||||
ObservationsTableQueryResult,
|
||||
"trace_tags" | "trace_name" | "trace_user_id" | "type"
|
||||
"trace_tags" | "trace_name" | "trace_user_id"
|
||||
>
|
||||
>({
|
||||
...opts,
|
||||
@@ -490,7 +491,7 @@ export const getObservationsTableWithModelData = async (
|
||||
const trace = traces.find((t) => t.id === o.trace_id);
|
||||
const model = models.find((m) => m.id === o.internal_model_id);
|
||||
return {
|
||||
...convertObservationToView({ ...o, type: "GENERATION" }),
|
||||
...convertObservationToView(o),
|
||||
latency: o.latency ? Number(o.latency) / 1000 : null,
|
||||
timeToFirstToken: o.time_to_first_token
|
||||
? Number(o.time_to_first_token) / 1000
|
||||
@@ -517,6 +518,7 @@ const getObservationsTableInternal = async <T>(
|
||||
? "count(*) as count"
|
||||
: `
|
||||
o.id as id,
|
||||
o.type as type,
|
||||
o.project_id as "project_id",
|
||||
o.name as name,
|
||||
o."model_parameters" as model_parameters,
|
||||
@@ -540,8 +542,8 @@ const getObservationsTableInternal = async <T>(
|
||||
o.prompt_name as "prompt_name",
|
||||
o.prompt_version as "prompt_version",
|
||||
internal_model_id as "internal_model_id",
|
||||
if(isNull(end_time), NULL, date_diff('milliseconds', start_time, end_time)) as latency,
|
||||
if(isNull(completion_start_time), NULL, date_diff('milliseconds', start_time, completion_start_time)) as "time_to_first_token"`;
|
||||
if(isNull(end_time), NULL, date_diff('millisecond', start_time, end_time)) as latency,
|
||||
if(isNull(completion_start_time), NULL, date_diff('millisecond', start_time, completion_start_time)) as "time_to_first_token"`;
|
||||
|
||||
const { projectId, filter, selectIOAndMetadata, limit, offset, orderBy } =
|
||||
opts;
|
||||
@@ -680,7 +682,7 @@ const getObservationsTableInternal = async <T>(
|
||||
${traceTableFilter.length > 0 || orderByTraces || search.query ? "LEFT JOIN traces t FINAL ON t.id = o.trace_id AND t.project_id = o.project_id" : ""}
|
||||
${hasScoresFilter ? `LEFT JOIN scores_avg AS s_avg ON s_avg.trace_id = o.trace_id and s_avg.observation_id = o.id` : ""}
|
||||
WHERE ${appliedObservationsFilter.query}
|
||||
AND o.type = 'GENERATION'
|
||||
|
||||
${timeFilter && (traceTableFilter.length > 0 || orderByTraces) ? `AND t.timestamp > {tracesTimestampFilter: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}` : ""}
|
||||
${search.query}
|
||||
${chOrderBy}
|
||||
@@ -1010,7 +1012,7 @@ export const getObservationMetricsForPrompts = async (
|
||||
end_time,
|
||||
usage_details,
|
||||
cost_details,
|
||||
dateDiff('milliseconds', start_time, end_time) AS latency_ms
|
||||
dateDiff('millisecond', start_time, end_time) AS latency_ms
|
||||
FROM observations
|
||||
FINAL
|
||||
WHERE (type = 'GENERATION')
|
||||
@@ -1073,7 +1075,7 @@ export const getLatencyAndTotalCostForObservations = async (
|
||||
SELECT
|
||||
id,
|
||||
cost_details['total'] AS total_cost,
|
||||
dateDiff('milliseconds', start_time, end_time) AS latency_ms
|
||||
dateDiff('millisecond', start_time, end_time) AS latency_ms
|
||||
FROM observations FINAL
|
||||
WHERE project_id = {projectId: String}
|
||||
AND id IN ({observationIds: Array(String)})
|
||||
@@ -1105,7 +1107,7 @@ export const getLatencyAndTotalCostForObservationsByTraces = async (
|
||||
SELECT
|
||||
trace_id,
|
||||
sumMap(cost_details)['total'] AS total_cost,
|
||||
dateDiff('milliseconds', min(start_time), max(end_time)) AS latency_ms
|
||||
dateDiff('millisecond', min(start_time), max(end_time)) AS latency_ms
|
||||
FROM observations FINAL
|
||||
WHERE project_id = {projectId: String}
|
||||
AND trace_id IN ({traceIds: Array(String)})
|
||||
@@ -1214,23 +1216,23 @@ export const getTraceIdsForObservations = async (
|
||||
}));
|
||||
};
|
||||
|
||||
export const getGenerationsForPostHog = async (
|
||||
export const getGenerationsForPostHog = async function* (
|
||||
projectId: string,
|
||||
minTimestamp: Date,
|
||||
maxTimestamp: Date,
|
||||
) => {
|
||||
) {
|
||||
const query = `
|
||||
SELECT
|
||||
o.name as name,
|
||||
o.start_time as start_time,
|
||||
o.id as id,
|
||||
o.total_cost as total_cost,
|
||||
if(isNull(completion_start_time), NULL, date_diff('milliseconds', start_time, completion_start_time)) as time_to_first_token,
|
||||
if(isNull(completion_start_time), NULL, date_diff('millisecond', start_time, completion_start_time)) as time_to_first_token,
|
||||
o.usage_details['total'] as input_tokens,
|
||||
o.usage_details['output'] as output_tokens,
|
||||
o.cost_details['total'] as total_tokens,
|
||||
o.project_id as project_id,
|
||||
if(isNull(end_time), NULL, date_diff('milliseconds', start_time, end_time) / 1000) as latency,
|
||||
if(isNull(end_time), NULL, date_diff('millisecond', start_time, end_time) / 1000) as latency,
|
||||
o.provided_model_name as model,
|
||||
o.level as level,
|
||||
o.version as version,
|
||||
@@ -1252,7 +1254,7 @@ export const getGenerationsForPostHog = async (
|
||||
AND o.type = 'GENERATION'
|
||||
`;
|
||||
|
||||
const records = await queryClickhouse<Record<string, unknown>>({
|
||||
const records = queryClickhouseStream<Record<string, unknown>>({
|
||||
query,
|
||||
params: {
|
||||
projectId,
|
||||
@@ -1262,32 +1264,34 @@ export const getGenerationsForPostHog = async (
|
||||
});
|
||||
|
||||
const baseUrl = env.NEXTAUTH_URL?.replace("/api/auth", "");
|
||||
return records.map((record) => ({
|
||||
timestamp: record.start_time,
|
||||
langfuse_generation_name: record.name,
|
||||
langfuse_trace_name: record.trace_name,
|
||||
langfuse_url: `${baseUrl}/project/${projectId}/traces/${encodeURIComponent(record.trace_id as string)}?observation=${encodeURIComponent(record.id as string)}`,
|
||||
langfuse_id: record.id,
|
||||
langfuse_cost_usd: record.total_cost,
|
||||
langfuse_input_units: record.input_tokens,
|
||||
langfuse_output_units: record.output_tokens,
|
||||
langfuse_total_units: record.total_tokens,
|
||||
langfuse_session_id: record.trace_session_id,
|
||||
langfuse_project_id: projectId,
|
||||
langfuse_user_id: record.trace_user_id || "langfuse_unknown_user",
|
||||
langfuse_latency: record.latency,
|
||||
langfuse_time_to_first_token: record.time_to_first_token,
|
||||
langfuse_release: record.trace_release,
|
||||
langfuse_version: record.version,
|
||||
langfuse_model: record.model,
|
||||
langfuse_level: record.level,
|
||||
langfuse_tags: record.trace_tags,
|
||||
langfuse_event_version: "1.0.0",
|
||||
$session_id: record.posthog_session_id ?? null,
|
||||
$set: {
|
||||
langfuse_user_url: record.user_id
|
||||
? `${baseUrl}/project/${projectId}/users/${encodeURIComponent(record.user_id as string)}`
|
||||
: null,
|
||||
},
|
||||
}));
|
||||
for await (const record of records) {
|
||||
yield {
|
||||
timestamp: record.start_time,
|
||||
langfuse_generation_name: record.name,
|
||||
langfuse_trace_name: record.trace_name,
|
||||
langfuse_url: `${baseUrl}/project/${projectId}/traces/${encodeURIComponent(record.trace_id as string)}?observation=${encodeURIComponent(record.id as string)}`,
|
||||
langfuse_id: record.id,
|
||||
langfuse_cost_usd: record.total_cost,
|
||||
langfuse_input_units: record.input_tokens,
|
||||
langfuse_output_units: record.output_tokens,
|
||||
langfuse_total_units: record.total_tokens,
|
||||
langfuse_session_id: record.trace_session_id,
|
||||
langfuse_project_id: projectId,
|
||||
langfuse_user_id: record.trace_user_id || "langfuse_unknown_user",
|
||||
langfuse_latency: record.latency,
|
||||
langfuse_time_to_first_token: record.time_to_first_token,
|
||||
langfuse_release: record.trace_release,
|
||||
langfuse_version: record.version,
|
||||
langfuse_model: record.model,
|
||||
langfuse_level: record.level,
|
||||
langfuse_tags: record.trace_tags,
|
||||
langfuse_event_version: "1.0.0",
|
||||
$session_id: record.posthog_session_id ?? null,
|
||||
$set: {
|
||||
langfuse_user_url: record.user_id
|
||||
? `${baseUrl}/project/${projectId}/users/${encodeURIComponent(record.user_id as string)}`
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,7 +9,6 @@ import Decimal from "decimal.js";
|
||||
import { parseClickhouseUTCDateTimeFormat } from "./clickhouse";
|
||||
import { ObservationRecordReadType } from "./definitions";
|
||||
import { parseJsonPrioritised } from "../../utils/json";
|
||||
import { jsonSchema } from "../../utils/zod";
|
||||
|
||||
export const convertObservationToView = (
|
||||
record: ObservationRecordReadType,
|
||||
@@ -62,15 +61,22 @@ export const convertObservation = (
|
||||
? parseClickhouseUTCDateTimeFormat(record.end_time)
|
||||
: null,
|
||||
name: record.name ?? null,
|
||||
metadata: record.metadata,
|
||||
metadata:
|
||||
record.metadata &&
|
||||
Object.fromEntries(
|
||||
Object.entries(record.metadata ?? {}).map(([key, val]) => [
|
||||
key,
|
||||
val && parseJsonPrioritised(val),
|
||||
]),
|
||||
),
|
||||
level: record.level as ObservationLevel,
|
||||
statusMessage: record.status_message ?? null,
|
||||
version: record.version ?? null,
|
||||
input: (record.input
|
||||
? jsonSchema.parse(parseJsonPrioritised(record.input))
|
||||
? parseJsonPrioritised(record.input)
|
||||
: null) as Prisma.JsonValue | null,
|
||||
output: (record.output
|
||||
? jsonSchema.parse(parseJsonPrioritised(record.output))
|
||||
? parseJsonPrioritised(record.output)
|
||||
: null) as Prisma.JsonValue | null,
|
||||
modelParameters: record.model_parameters
|
||||
? JSON.parse(record.model_parameters)
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
commandClickhouse,
|
||||
parseClickhouseUTCDateTimeFormat,
|
||||
queryClickhouse,
|
||||
queryClickhouseStream,
|
||||
upsertClickhouse,
|
||||
} from "./clickhouse";
|
||||
import { FilterList } from "../queries/clickhouse-sql/clickhouse-filter";
|
||||
@@ -135,13 +136,16 @@ export const upsertScore = async (score: Partial<ScoreRecordReadType>) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const getScoresForTraces = async (
|
||||
projectId: string,
|
||||
traceIds: string[],
|
||||
timestamp?: Date,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
) => {
|
||||
export type GetScoresForTracesProps = {
|
||||
projectId: string;
|
||||
traceIds: string[];
|
||||
timestamp?: Date;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export const getScoresForTraces = async (props: GetScoresForTracesProps) => {
|
||||
const { projectId, traceIds, timestamp, limit, offset } = props;
|
||||
const query = `
|
||||
select
|
||||
*
|
||||
@@ -200,20 +204,24 @@ export const getScoresForObservations = async (
|
||||
return rows.map(convertToScore);
|
||||
};
|
||||
|
||||
export const getScoresGroupedByNameSourceType = async (projectId: string) => {
|
||||
export const getScoresGroupedByNameSourceType = async (
|
||||
projectId: string,
|
||||
timestamp: Date | undefined,
|
||||
) => {
|
||||
// We mainly use queries like this to retrieve filter options.
|
||||
// Therefore, we can skip final as some inaccuracy in count is acceptable.
|
||||
const query = `
|
||||
select
|
||||
name,
|
||||
source,
|
||||
data_type
|
||||
from scores s
|
||||
WHERE s.project_id = {projectId: String}
|
||||
GROUP BY name, source, data_type
|
||||
ORDER BY count() desc
|
||||
LIMIT 1000;
|
||||
`;
|
||||
select
|
||||
name,
|
||||
source,
|
||||
data_type
|
||||
from scores s
|
||||
WHERE s.project_id = {projectId: String}
|
||||
${timestamp ? `AND s.timestamp >= {timestamp: DateTime64(3)}` : ""}
|
||||
GROUP BY name, source, data_type
|
||||
ORDER BY count() desc
|
||||
LIMIT 1000;
|
||||
`;
|
||||
|
||||
const rows = await queryClickhouse<{
|
||||
name: string;
|
||||
@@ -223,6 +231,9 @@ export const getScoresGroupedByNameSourceType = async (projectId: string) => {
|
||||
query: query,
|
||||
params: {
|
||||
projectId: projectId,
|
||||
...(timestamp
|
||||
? { timestamp: convertDateToClickhouseDateTime(timestamp) }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -666,11 +677,11 @@ export const getDistinctScoreNames = async (
|
||||
return rows.map((row) => row.name);
|
||||
};
|
||||
|
||||
export const getScoresForPostHog = async (
|
||||
export const getScoresForPostHog = async function* (
|
||||
projectId: string,
|
||||
minTimestamp: Date,
|
||||
maxTimestamp: Date,
|
||||
) => {
|
||||
) {
|
||||
const query = `
|
||||
SELECT
|
||||
s.id as id,
|
||||
@@ -694,7 +705,7 @@ export const getScoresForPostHog = async (
|
||||
AND t.timestamp <= {maxTimestamp: DateTime64(3)}
|
||||
`;
|
||||
|
||||
const records = await queryClickhouse<Record<string, unknown>>({
|
||||
const records = queryClickhouseStream<Record<string, unknown>>({
|
||||
query,
|
||||
params: {
|
||||
projectId,
|
||||
@@ -704,24 +715,26 @@ export const getScoresForPostHog = async (
|
||||
});
|
||||
|
||||
const baseUrl = env.NEXTAUTH_URL?.replace("/api/auth", "");
|
||||
return records.map((record) => ({
|
||||
timestamp: record.timestamp,
|
||||
langfuse_score_name: record.name,
|
||||
langfuse_score_value: record.value,
|
||||
langfuse_score_comment: record.comment,
|
||||
langfuse_trace_name: record.trace_name,
|
||||
langfuse_id: record.id,
|
||||
langfuse_session_id: record.trace_session_id,
|
||||
langfuse_project_id: projectId,
|
||||
langfuse_user_id: record.trace_user_id || "langfuse_unknown_user",
|
||||
langfuse_release: record.trace_release,
|
||||
langfuse_tags: record.trace_tags,
|
||||
langfuse_event_version: "1.0.0",
|
||||
$session_id: record.posthog_session_id ?? null,
|
||||
$set: {
|
||||
langfuse_user_url: record.user_id
|
||||
? `${baseUrl}/project/${projectId}/users/${encodeURIComponent(record.user_id as string)}`
|
||||
: null,
|
||||
},
|
||||
}));
|
||||
for await (const record of records) {
|
||||
yield {
|
||||
timestamp: record.timestamp,
|
||||
langfuse_score_name: record.name,
|
||||
langfuse_score_value: record.value,
|
||||
langfuse_score_comment: record.comment,
|
||||
langfuse_trace_name: record.trace_name,
|
||||
langfuse_id: record.id,
|
||||
langfuse_session_id: record.trace_session_id,
|
||||
langfuse_project_id: projectId,
|
||||
langfuse_user_id: record.trace_user_id || "langfuse_unknown_user",
|
||||
langfuse_release: record.trace_release,
|
||||
langfuse_tags: record.trace_tags,
|
||||
langfuse_event_version: "1.0.0",
|
||||
$session_id: record.posthog_session_id ?? null,
|
||||
$set: {
|
||||
langfuse_user_url: record.user_id
|
||||
? `${baseUrl}/project/${projectId}/users/${encodeURIComponent(record.user_id as string)}`
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
commandClickhouse,
|
||||
parseClickhouseUTCDateTimeFormat,
|
||||
queryClickhouse,
|
||||
queryClickhouseStream,
|
||||
upsertClickhouse,
|
||||
} from "./clickhouse";
|
||||
import {
|
||||
@@ -16,10 +17,7 @@ import {
|
||||
} from "../queries/clickhouse-sql/clickhouse-filter";
|
||||
import { TraceRecordReadType } from "./definitions";
|
||||
import { tracesTableUiColumnDefinitions } from "../../tableDefinitions/mapTracesTable";
|
||||
import { OrderByState } from "../../interfaces/orderBy";
|
||||
import { orderByToClickhouseSql } from "../queries/clickhouse-sql/orderby-factory";
|
||||
import { UiColumnMapping } from "../../tableDefinitions";
|
||||
import { sessionCols } from "../../tableDefinitions/mapSessionTable";
|
||||
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
|
||||
import { convertClickhouseToDomain } from "./traces_converters";
|
||||
import { clickhouseSearchCondition } from "../queries/clickhouse-sql/search";
|
||||
@@ -383,196 +381,6 @@ export const getTracesGroupedByTags = async (props: GroupedTracesQueryProp) => {
|
||||
return rows;
|
||||
};
|
||||
|
||||
export type SessionDataReturnType = {
|
||||
session_id: string;
|
||||
max_timestamp: string;
|
||||
min_timestamp: string;
|
||||
trace_ids: string[];
|
||||
user_ids: string[];
|
||||
trace_count: number;
|
||||
trace_tags: string[];
|
||||
total_observations: number;
|
||||
duration: number;
|
||||
session_usage_details: Record<string, number>;
|
||||
session_cost_details: Record<string, number>;
|
||||
session_input_cost: string;
|
||||
session_output_cost: string;
|
||||
session_total_cost: string;
|
||||
session_input_usage: string;
|
||||
session_output_usage: string;
|
||||
session_total_usage: string;
|
||||
};
|
||||
|
||||
export const getSessionsTableCount = async (props: {
|
||||
projectId: string;
|
||||
filter: FilterState;
|
||||
orderBy?: OrderByState;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
}) => {
|
||||
const rows = await getSessionsTableGeneric<{ count: string }>({
|
||||
select: `
|
||||
count(session_id) as count
|
||||
`,
|
||||
projectId: props.projectId,
|
||||
filter: props.filter,
|
||||
orderBy: props.orderBy,
|
||||
limit: props.limit,
|
||||
page: props.page,
|
||||
});
|
||||
|
||||
return rows.length > 0 ? Number(rows[0].count) : 0;
|
||||
};
|
||||
|
||||
export const getSessionsTable = async (props: {
|
||||
projectId: string;
|
||||
filter: FilterState;
|
||||
orderBy?: OrderByState;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
}) => {
|
||||
const rows = await getSessionsTableGeneric<SessionDataReturnType>({
|
||||
select: `
|
||||
session_id,
|
||||
max_timestamp,
|
||||
min_timestamp,
|
||||
trace_ids,
|
||||
user_ids,
|
||||
trace_count,
|
||||
trace_tags,
|
||||
total_observations,
|
||||
duration,
|
||||
session_usage_details,
|
||||
session_cost_details,
|
||||
session_input_cost,
|
||||
session_output_cost,
|
||||
session_total_cost,
|
||||
session_input_usage,
|
||||
session_output_usage,
|
||||
session_total_usage
|
||||
`,
|
||||
projectId: props.projectId,
|
||||
filter: props.filter,
|
||||
orderBy: props.orderBy,
|
||||
limit: props.limit,
|
||||
page: props.page,
|
||||
});
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
export type FetchSessionsTableProps = {
|
||||
select: string;
|
||||
projectId: string;
|
||||
filter: FilterState;
|
||||
searchQuery?: string;
|
||||
orderBy?: OrderByState;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
};
|
||||
|
||||
const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
|
||||
const { select, projectId, filter, orderBy, limit, page } = props;
|
||||
|
||||
const { tracesFilter, scoresFilter, observationsFilter } =
|
||||
getProjectIdDefaultFilter(projectId, { tracesPrefix: "s" });
|
||||
|
||||
tracesFilter.push(...createFilterFromFilterState(filter, sessionCols));
|
||||
|
||||
const tracesFilterRes = tracesFilter.apply();
|
||||
const scoresAvgFilterRes = scoresFilter.apply();
|
||||
const observationsStatsRes = observationsFilter.apply();
|
||||
|
||||
const traceTimestampFilter: DateTimeFilter | undefined = tracesFilter.find(
|
||||
(f) =>
|
||||
f.field === "min_timestamp" &&
|
||||
(f.operator === ">=" || f.operator === ">"),
|
||||
) as DateTimeFilter | undefined;
|
||||
|
||||
const singleTraceFilter = traceTimestampFilter
|
||||
? new FilterList([
|
||||
new DateTimeFilter({
|
||||
clickhouseTable: "traces",
|
||||
field: "timestamp",
|
||||
operator: traceTimestampFilter.operator,
|
||||
value: traceTimestampFilter.value,
|
||||
}),
|
||||
]).apply()
|
||||
: undefined;
|
||||
|
||||
const query = `
|
||||
WITH observations_agg AS (
|
||||
SELECT o.trace_id,
|
||||
count(*) as obs_count,
|
||||
min(o.start_time) as min_start_time,
|
||||
max(o.end_time) as max_end_time,
|
||||
sumMap(usage_details) as sum_usage_details,
|
||||
sumMap(cost_details) as sum_cost_details,
|
||||
anyLast(project_id) as project_id
|
||||
FROM observations o FINAL
|
||||
WHERE o.project_id = {projectId: String}
|
||||
${traceTimestampFilter ? `AND o.start_time >= {observationsStartTime: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
|
||||
GROUP BY o.trace_id
|
||||
),
|
||||
session_data AS (
|
||||
SELECT
|
||||
t.session_id,
|
||||
anyLast(t.project_id) as project_id,
|
||||
max(t.timestamp) as max_timestamp,
|
||||
min(t.timestamp) as min_timestamp,
|
||||
groupArray(t.id) AS trace_ids,
|
||||
groupUniqArray(t.user_id) AS user_ids,
|
||||
count(*) as trace_count,
|
||||
groupUniqArrayArray(t.tags) as trace_tags,
|
||||
-- Aggregate observations data at session level
|
||||
sum(o.obs_count) as total_observations,
|
||||
date_diff('milliseconds', min(min_start_time), max(max_end_time)) as duration,
|
||||
sumMap(o.sum_usage_details) as session_usage_details,
|
||||
sumMap(o.sum_cost_details) as session_cost_details,
|
||||
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sumMap(o.sum_cost_details)))) as session_input_cost,
|
||||
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, sumMap(o.sum_cost_details)))) as session_output_cost,
|
||||
sumMap(o.sum_cost_details)['total'] as session_total_cost,
|
||||
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sumMap(o.sum_usage_details)))) as session_input_usage,
|
||||
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, sumMap(o.sum_usage_details)))) as session_output_usage,
|
||||
sumMap(o.sum_usage_details)['total'] as session_total_usage
|
||||
FROM traces t FINAL
|
||||
LEFT JOIN observations_agg o
|
||||
ON t.id = o.trace_id AND t.project_id = o.project_id
|
||||
WHERE t.session_id IS NOT NULL
|
||||
AND t.project_id = {projectId: String}
|
||||
${singleTraceFilter?.query ? ` AND ${singleTraceFilter.query}` : ""}
|
||||
GROUP BY t.session_id
|
||||
)
|
||||
SELECT ${select}
|
||||
FROM session_data s
|
||||
WHERE ${tracesFilterRes.query ? tracesFilterRes.query : ""}
|
||||
${orderByToClickhouseSql(orderBy ?? null, sessionCols)}
|
||||
${limit !== undefined && page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
|
||||
`;
|
||||
|
||||
const obsStartTimeValue = traceTimestampFilter
|
||||
? convertDateToClickhouseDateTime(traceTimestampFilter.value)
|
||||
: null;
|
||||
|
||||
const res = await queryClickhouse<T>({
|
||||
query: query,
|
||||
params: {
|
||||
projectId,
|
||||
limit: limit,
|
||||
offset: limit && page ? limit * page : 0,
|
||||
...tracesFilterRes.params,
|
||||
...observationsStatsRes.params,
|
||||
...scoresAvgFilterRes.params,
|
||||
...singleTraceFilter?.params,
|
||||
...(obsStartTimeValue
|
||||
? { observationsStartTime: obsStartTimeValue }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getTracesIdentifierForSession = async (
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
@@ -820,18 +628,18 @@ export const getUserMetrics = async (
|
||||
}));
|
||||
};
|
||||
|
||||
export const getTracesForPostHog = async (
|
||||
export const getTracesForPostHog = async function* (
|
||||
projectId: string,
|
||||
minTimestamp: Date,
|
||||
maxTimestamp: Date,
|
||||
) => {
|
||||
) {
|
||||
const query = `
|
||||
WITH observations_agg AS (
|
||||
SELECT o.project_id,
|
||||
o.trace_id,
|
||||
sum(total_cost) as total_cost,
|
||||
count(*) as observation_count,
|
||||
date_diff('milliseconds', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds
|
||||
date_diff('millisecond', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds
|
||||
FROM observations o FINAL
|
||||
WHERE o.project_id = {projectId: String}
|
||||
AND o.start_time >= {minTimestamp: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}
|
||||
@@ -858,7 +666,7 @@ export const getTracesForPostHog = async (
|
||||
AND t.timestamp <= {maxTimestamp: DateTime64(3)}
|
||||
`;
|
||||
|
||||
const records = await queryClickhouse<Record<string, unknown>>({
|
||||
const records = queryClickhouseStream<Record<string, unknown>>({
|
||||
query,
|
||||
params: {
|
||||
projectId,
|
||||
@@ -868,28 +676,30 @@ export const getTracesForPostHog = async (
|
||||
});
|
||||
|
||||
const baseUrl = env.NEXTAUTH_URL?.replace("/api/auth", "");
|
||||
return records.map((record) => ({
|
||||
timestamp: record.timestamp,
|
||||
langfuse_id: record.id,
|
||||
langfuse_trace_name: record.name,
|
||||
langfuse_url: `${baseUrl}/project/${projectId}/traces/${encodeURIComponent(record.id as string)}`,
|
||||
langfuse_cost_usd: record.total_cost,
|
||||
langfuse_count_observations: record.observation_count,
|
||||
langfuse_session_id: record.session_id,
|
||||
langfuse_project_id: projectId,
|
||||
langfuse_user_id: record.user_id || "langfuse_unknown_user",
|
||||
langfuse_latency: record.latency,
|
||||
langfuse_release: record.release,
|
||||
langfuse_version: record.version,
|
||||
langfuse_tags: record.tags,
|
||||
langfuse_event_version: "1.0.0",
|
||||
$session_id: record.posthog_session_id ?? null,
|
||||
$set: {
|
||||
langfuse_user_url: record.user_id
|
||||
? `${baseUrl}/project/${projectId}/users/${encodeURIComponent(record.user_id as string)}`
|
||||
: null,
|
||||
},
|
||||
}));
|
||||
for await (const record of records) {
|
||||
yield {
|
||||
timestamp: record.timestamp,
|
||||
langfuse_id: record.id,
|
||||
langfuse_trace_name: record.name,
|
||||
langfuse_url: `${baseUrl}/project/${projectId}/traces/${encodeURIComponent(record.id as string)}`,
|
||||
langfuse_cost_usd: record.total_cost,
|
||||
langfuse_count_observations: record.observation_count,
|
||||
langfuse_session_id: record.session_id,
|
||||
langfuse_project_id: projectId,
|
||||
langfuse_user_id: record.user_id || "langfuse_unknown_user",
|
||||
langfuse_latency: record.latency,
|
||||
langfuse_release: record.release,
|
||||
langfuse_version: record.version,
|
||||
langfuse_tags: record.tags,
|
||||
langfuse_event_version: "1.0.0",
|
||||
$session_id: record.posthog_session_id ?? null,
|
||||
$set: {
|
||||
langfuse_user_url: record.user_id
|
||||
? `${baseUrl}/project/${projectId}/users/${encodeURIComponent(record.user_id as string)}`
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getTracesByIdsForAnyProject = async (traceIds: string[]) => {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { parseClickhouseUTCDateTimeFormat } from "./clickhouse";
|
||||
import { TraceRecordReadType } from "./definitions";
|
||||
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
|
||||
import { parseJsonPrioritised } from "../../utils/json";
|
||||
import { jsonSchema } from "../../utils/zod";
|
||||
|
||||
export const convertTraceDomainToClickhouse = (
|
||||
trace: Trace,
|
||||
@@ -46,12 +45,19 @@ export const convertClickhouseToDomain = (
|
||||
sessionId: record.session_id ?? null,
|
||||
public: record.public,
|
||||
input: (record.input
|
||||
? jsonSchema.parse(parseJsonPrioritised(record.input))
|
||||
? parseJsonPrioritised(record.input)
|
||||
: null) as Prisma.JsonValue | null,
|
||||
output: (record.output
|
||||
? jsonSchema.parse(parseJsonPrioritised(record.output))
|
||||
? parseJsonPrioritised(record.output)
|
||||
: null) as Prisma.JsonValue | null,
|
||||
metadata: record.metadata,
|
||||
metadata:
|
||||
record.metadata &&
|
||||
Object.fromEntries(
|
||||
Object.entries(record.metadata ?? {}).map(([key, val]) => [
|
||||
key,
|
||||
val && parseJsonPrioritised(val),
|
||||
]),
|
||||
),
|
||||
createdAt: parseClickhouseUTCDateTimeFormat(record.created_at),
|
||||
updatedAt: parseClickhouseUTCDateTimeFormat(record.updated_at),
|
||||
externalId: null,
|
||||
|
||||
@@ -30,7 +30,7 @@ export interface StorageService {
|
||||
|
||||
download(path: string): Promise<string>;
|
||||
|
||||
listFiles(prefix: string): Promise<string[]>;
|
||||
listFiles(prefix: string): Promise<{ file: string; createdAt: Date }[]>;
|
||||
|
||||
getSignedUrl(
|
||||
fileName: string,
|
||||
@@ -201,7 +201,9 @@ class AzureBlobStorageService implements StorageService {
|
||||
}
|
||||
}
|
||||
|
||||
public async listFiles(prefix: string): Promise<string[]> {
|
||||
public async listFiles(
|
||||
prefix: string,
|
||||
): Promise<{ file: string; createdAt: Date }[]> {
|
||||
try {
|
||||
await this.createContainerIfNotExists();
|
||||
|
||||
@@ -209,7 +211,10 @@ class AzureBlobStorageService implements StorageService {
|
||||
const files = [];
|
||||
for await (const blob of result) {
|
||||
if (blob.name.startsWith(prefix)) {
|
||||
files.push(blob.name);
|
||||
files.push({
|
||||
file: blob.name,
|
||||
createdAt: blob?.properties?.createdOn ?? new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return files;
|
||||
@@ -364,7 +369,9 @@ class S3StorageService implements StorageService {
|
||||
}
|
||||
}
|
||||
|
||||
public async listFiles(prefix: string): Promise<string[]> {
|
||||
public async listFiles(
|
||||
prefix: string,
|
||||
): Promise<{ file: string; createdAt: Date }[]> {
|
||||
const listCommand = new ListObjectsV2Command({
|
||||
Bucket: this.bucketName,
|
||||
Prefix: prefix,
|
||||
@@ -373,7 +380,11 @@ class S3StorageService implements StorageService {
|
||||
try {
|
||||
const response = await this.client.send(listCommand);
|
||||
return (
|
||||
response.Contents?.flatMap((file) => (file.Key ? [file.Key] : [])) ?? []
|
||||
response.Contents?.flatMap((file) =>
|
||||
file.Key
|
||||
? [{ file: file.Key, createdAt: file.LastModified ?? new Date() }]
|
||||
: [],
|
||||
) ?? []
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to list files from S3 ${prefix}`, err);
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { OrderByState } from "../../interfaces/orderBy";
|
||||
import { sessionCols } from "../../tableDefinitions/mapSessionTable";
|
||||
import { FilterState } from "../../types";
|
||||
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
|
||||
import { DateTimeFilter, FilterList, orderByToClickhouseSql } from "../queries";
|
||||
import {
|
||||
getProjectIdDefaultFilter,
|
||||
createFilterFromFilterState,
|
||||
} from "../queries/clickhouse-sql/factory";
|
||||
import {
|
||||
TRACE_TO_OBSERVATIONS_INTERVAL,
|
||||
queryClickhouse,
|
||||
} from "../repositories";
|
||||
|
||||
export type SessionDataReturnType = {
|
||||
session_id: string;
|
||||
max_timestamp: string;
|
||||
min_timestamp: string;
|
||||
trace_ids: string[];
|
||||
user_ids: string[];
|
||||
trace_count: number;
|
||||
trace_tags: string[];
|
||||
};
|
||||
|
||||
export type SessionWithMetricsReturnType = SessionDataReturnType & {
|
||||
total_observations: number;
|
||||
duration: number;
|
||||
session_usage_details: Record<string, number>;
|
||||
session_cost_details: Record<string, number>;
|
||||
session_input_cost: string;
|
||||
session_output_cost: string;
|
||||
session_total_cost: string;
|
||||
session_input_usage: string;
|
||||
session_output_usage: string;
|
||||
session_total_usage: string;
|
||||
};
|
||||
|
||||
export const getSessionsTableCount = async (props: {
|
||||
projectId: string;
|
||||
filter: FilterState;
|
||||
orderBy?: OrderByState;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
}) => {
|
||||
const rows = await getSessionsTableGeneric<{ count: string }>({
|
||||
select: "count",
|
||||
projectId: props.projectId,
|
||||
filter: props.filter,
|
||||
orderBy: props.orderBy,
|
||||
limit: props.limit,
|
||||
page: props.page,
|
||||
});
|
||||
|
||||
return rows.length > 0 ? Number(rows[0].count) : 0;
|
||||
};
|
||||
|
||||
export const getSessionsTable = async (props: {
|
||||
projectId: string;
|
||||
filter: FilterState;
|
||||
orderBy?: OrderByState;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
}) => {
|
||||
const rows = await getSessionsTableGeneric<SessionDataReturnType>({
|
||||
select: "rows",
|
||||
projectId: props.projectId,
|
||||
filter: props.filter,
|
||||
orderBy: props.orderBy,
|
||||
limit: props.limit,
|
||||
page: props.page,
|
||||
});
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
trace_count: Number(row.trace_count),
|
||||
}));
|
||||
};
|
||||
|
||||
export const getSessionsWithMetrics = async (props: {
|
||||
projectId: string;
|
||||
filter: FilterState;
|
||||
orderBy?: OrderByState;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
}) => {
|
||||
const rows = await getSessionsTableGeneric<SessionWithMetricsReturnType>({
|
||||
select: "metrics",
|
||||
projectId: props.projectId,
|
||||
filter: props.filter,
|
||||
orderBy: props.orderBy,
|
||||
limit: props.limit,
|
||||
page: props.page,
|
||||
});
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
trace_count: Number(row.trace_count),
|
||||
total_observations: Number(row.total_observations),
|
||||
}));
|
||||
};
|
||||
|
||||
export type FetchSessionsTableProps = {
|
||||
select: "count" | "rows" | "metrics";
|
||||
projectId: string;
|
||||
filter: FilterState;
|
||||
searchQuery?: string;
|
||||
orderBy?: OrderByState;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
};
|
||||
|
||||
const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
|
||||
const { select, projectId, filter, orderBy, limit, page } = props;
|
||||
|
||||
let sqlSelect: string;
|
||||
switch (select) {
|
||||
case "count":
|
||||
sqlSelect = "count(session_id) as count";
|
||||
break;
|
||||
case "rows":
|
||||
sqlSelect = `
|
||||
session_id,
|
||||
max_timestamp,
|
||||
min_timestamp,
|
||||
trace_ids,
|
||||
user_ids,
|
||||
trace_count,
|
||||
trace_tags`;
|
||||
break;
|
||||
case "metrics":
|
||||
sqlSelect = `
|
||||
session_id,
|
||||
max_timestamp,
|
||||
min_timestamp,
|
||||
trace_ids,
|
||||
user_ids,
|
||||
trace_count,
|
||||
trace_tags,
|
||||
total_observations,
|
||||
duration,
|
||||
session_usage_details,
|
||||
session_cost_details,
|
||||
session_input_cost,
|
||||
session_output_cost,
|
||||
session_total_cost,
|
||||
session_input_usage,
|
||||
session_output_usage,
|
||||
session_total_usage`;
|
||||
break;
|
||||
default:
|
||||
const exhaustiveCheckDefault: never = select;
|
||||
throw new Error(`Unknown select type: ${select}`);
|
||||
}
|
||||
|
||||
const { tracesFilter } = getProjectIdDefaultFilter(projectId, {
|
||||
tracesPrefix: "s",
|
||||
});
|
||||
|
||||
tracesFilter.push(...createFilterFromFilterState(filter, sessionCols));
|
||||
|
||||
const tracesFilterRes = tracesFilter.apply();
|
||||
|
||||
const traceTimestampFilter: DateTimeFilter | undefined = tracesFilter.find(
|
||||
(f) =>
|
||||
f.field === "min_timestamp" &&
|
||||
(f.operator === ">=" || f.operator === ">"),
|
||||
) as DateTimeFilter | undefined;
|
||||
|
||||
const filters = [];
|
||||
if (traceTimestampFilter) {
|
||||
filters.push(
|
||||
new DateTimeFilter({
|
||||
clickhouseTable: "traces",
|
||||
field: "timestamp",
|
||||
operator: traceTimestampFilter.operator,
|
||||
value: traceTimestampFilter.value,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const additionalSingleTraceFilter = tracesFilter.find(
|
||||
(f) => f.field === "bookmarked" || f.field === "session_id",
|
||||
);
|
||||
|
||||
if (additionalSingleTraceFilter) {
|
||||
filters.push(additionalSingleTraceFilter);
|
||||
}
|
||||
|
||||
const singleTraceFilter =
|
||||
filters.length > 0 ? new FilterList(filters).apply() : undefined;
|
||||
|
||||
const hasMetricsFilter =
|
||||
tracesFilter.find((f) =>
|
||||
[
|
||||
"session_total_cost",
|
||||
"session_input_cost",
|
||||
"session_output_cost",
|
||||
"duration",
|
||||
"session_total_usage",
|
||||
"session_output_usage",
|
||||
"session_input_usage",
|
||||
].includes(f.field),
|
||||
) ||
|
||||
(orderBy &&
|
||||
[
|
||||
"totalCost",
|
||||
"inputCost",
|
||||
"outputCost",
|
||||
"sessionDuration",
|
||||
"totalTokens",
|
||||
"outputTokens",
|
||||
"inputTokens",
|
||||
"usage",
|
||||
].includes(orderBy?.column));
|
||||
|
||||
const selectMetrics = select === "metrics" || hasMetricsFilter;
|
||||
|
||||
// We use deduplicated traces and observations CTEs instead of final to be able to use Skip indices in Clickhouse.
|
||||
const query = `
|
||||
WITH deduplicated_traces AS (
|
||||
SELECT * EXCEPT input, output, metadata
|
||||
FROM traces t
|
||||
WHERE t.session_id IS NOT NULL
|
||||
AND t.project_id = {projectId: String}
|
||||
${singleTraceFilter?.query ? ` AND ${singleTraceFilter.query}` : ""}
|
||||
ORDER BY event_ts DESC
|
||||
LIMIT 1 BY id, project_id
|
||||
),
|
||||
deduplicated_observations AS (
|
||||
SELECT *
|
||||
FROM observations o
|
||||
WHERE o.project_id = {projectId: String}
|
||||
${traceTimestampFilter ? `AND o.start_time >= {observationsStartTime: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
|
||||
AND o.trace_id IN (
|
||||
SELECT id
|
||||
FROM deduplicated_traces
|
||||
)
|
||||
ORDER BY event_ts DESC
|
||||
LIMIT 1 BY id, project_id
|
||||
),
|
||||
observations_agg AS (
|
||||
SELECT o.trace_id,
|
||||
count(*) as obs_count,
|
||||
min(o.start_time) as min_start_time,
|
||||
max(o.end_time) as max_end_time,
|
||||
sumMap(usage_details) as sum_usage_details,
|
||||
sumMap(cost_details) as sum_cost_details,
|
||||
anyLast(project_id) as project_id
|
||||
FROM deduplicated_observations o
|
||||
WHERE o.project_id = {projectId: String}
|
||||
${traceTimestampFilter ? `AND o.start_time >= {observationsStartTime: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
|
||||
GROUP BY o.trace_id
|
||||
),
|
||||
session_data AS (
|
||||
SELECT
|
||||
t.session_id,
|
||||
anyLast(t.project_id) as project_id,
|
||||
max(t.timestamp) as max_timestamp,
|
||||
min(t.timestamp) as min_timestamp,
|
||||
groupArray(t.id) AS trace_ids,
|
||||
groupUniqArray(t.user_id) AS user_ids,
|
||||
count(*) as trace_count,
|
||||
groupUniqArrayArray(t.tags) as trace_tags
|
||||
-- Aggregate observations data at session level
|
||||
${
|
||||
selectMetrics
|
||||
? `
|
||||
,
|
||||
sum(o.obs_count) as total_observations,
|
||||
date_diff('millisecond', min(min_start_time), max(max_end_time)) as duration,
|
||||
sumMap(o.sum_usage_details) as session_usage_details,
|
||||
sumMap(o.sum_cost_details) as session_cost_details,
|
||||
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sumMap(o.sum_cost_details)))) as session_input_cost,
|
||||
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, sumMap(o.sum_cost_details)))) as session_output_cost,
|
||||
sumMap(o.sum_cost_details)['total'] as session_total_cost,
|
||||
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sumMap(o.sum_usage_details)))) as session_input_usage,
|
||||
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, sumMap(o.sum_usage_details)))) as session_output_usage,
|
||||
sumMap(o.sum_usage_details)['total'] as session_total_usage`
|
||||
: ""
|
||||
}
|
||||
FROM deduplicated_traces t
|
||||
${
|
||||
selectMetrics
|
||||
? `LEFT JOIN observations_agg o
|
||||
ON t.id = o.trace_id AND t.project_id = o.project_id`
|
||||
: ""
|
||||
}
|
||||
WHERE t.session_id IS NOT NULL
|
||||
AND t.project_id = {projectId: String}
|
||||
${singleTraceFilter?.query ? ` AND ${singleTraceFilter.query}` : ""}
|
||||
GROUP BY t.session_id
|
||||
)
|
||||
SELECT ${sqlSelect}
|
||||
FROM session_data s
|
||||
WHERE ${tracesFilterRes.query ? tracesFilterRes.query : ""}
|
||||
${orderByToClickhouseSql(orderBy ?? null, sessionCols)}
|
||||
${limit !== undefined && page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
|
||||
`;
|
||||
|
||||
const obsStartTimeValue = traceTimestampFilter
|
||||
? convertDateToClickhouseDateTime(traceTimestampFilter.value)
|
||||
: null;
|
||||
|
||||
const res = await queryClickhouse<T>({
|
||||
query: query,
|
||||
params: {
|
||||
projectId,
|
||||
limit: limit,
|
||||
offset: limit && page ? limit * page : 0,
|
||||
...tracesFilterRes.params,
|
||||
...singleTraceFilter?.params,
|
||||
...(obsStartTimeValue
|
||||
? { observationsStartTime: obsStartTimeValue }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
return res;
|
||||
};
|
||||
@@ -322,19 +322,28 @@ const getTracesTableGeneric = async <T>(props: FetchTracesTableProps) => {
|
||||
clickhouseSelect: "toDate(t.timestamp)",
|
||||
uiTableName: "timestamp_to_date",
|
||||
uiTableId: "timestamp_to_date",
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseTableName: "traces",
|
||||
},
|
||||
{
|
||||
clickhouseSelect: "t.event_ts",
|
||||
uiTableName: "event_ts",
|
||||
uiTableId: "event_ts",
|
||||
clickhouseTableName: "traces",
|
||||
},
|
||||
];
|
||||
const chOrderBy = orderByToClickhouseSql(
|
||||
[
|
||||
defaultOrder
|
||||
? {
|
||||
column: "timestamp_to_date",
|
||||
order: orderBy.order,
|
||||
}
|
||||
? [
|
||||
{
|
||||
column: "timestamp_to_date",
|
||||
order: orderBy.order,
|
||||
},
|
||||
{ column: "event_ts", order: "DESC" as "DESC" },
|
||||
]
|
||||
: null,
|
||||
orderBy ?? null,
|
||||
],
|
||||
].flat(),
|
||||
orderByCols,
|
||||
);
|
||||
|
||||
@@ -342,7 +351,7 @@ const getTracesTableGeneric = async <T>(props: FetchTracesTableProps) => {
|
||||
// - we only join scores and observations if we really need them to speed up default views
|
||||
// - we use FINAL on traces only in case we not need to order by something different than time. Otherwise we cannot guarantee correct reads.
|
||||
// - we filter the observations and scores as much as possible before joining them to traces.
|
||||
// - we order by todate(timestamp), timestamp per default and do not use FINAL.
|
||||
// - we order by todate(timestamp), event_ts desc per default and do not use FINAL.
|
||||
// In this case, CH is able to read the data only from the latest date from disk and filtering them in memory. No need to read all data e.g. for 1 month from disk.
|
||||
|
||||
const query = `
|
||||
@@ -351,7 +360,7 @@ const getTracesTableGeneric = async <T>(props: FetchTracesTableProps) => {
|
||||
COUNT(*) AS observation_count,
|
||||
sumMap(usage_details) as usage_details,
|
||||
SUM(total_cost) AS total_cost,
|
||||
date_diff('milliseconds', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds,
|
||||
date_diff('millisecond', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds,
|
||||
multiIf(
|
||||
arrayExists(x -> x = 'ERROR', groupArray(level)), 'ERROR',
|
||||
arrayExists(x -> x = 'WARNING', groupArray(level)), 'WARNING',
|
||||
|
||||
@@ -26,6 +26,12 @@ export const observationsTableTraceUiColumnDefinitions: UiColumnMapping[] = [
|
||||
|
||||
export const observationsTableUiColumnDefinitions: UiColumnMapping[] = [
|
||||
...observationsTableTraceUiColumnDefinitions,
|
||||
{
|
||||
uiTableName: "type",
|
||||
uiTableId: "type",
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseSelect: 'o."type"',
|
||||
},
|
||||
{
|
||||
uiTableName: "ID",
|
||||
uiTableId: "id",
|
||||
@@ -68,7 +74,7 @@ export const observationsTableUiColumnDefinitions: UiColumnMapping[] = [
|
||||
uiTableId: "timeToFirstToken",
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseSelect:
|
||||
"if(isNull(completion_start_time), NULL, date_diff('milliseconds', start_time, completion_start_time) / 1000)",
|
||||
"if(isNull(completion_start_time), NULL, date_diff('millisecond', start_time, completion_start_time) / 1000)",
|
||||
// If we use the default of Decimal64(12), we cannot filter for more than ~40min due to an overflow
|
||||
clickhouseTypeOverwrite: "Decimal64(3)",
|
||||
},
|
||||
@@ -77,7 +83,7 @@ export const observationsTableUiColumnDefinitions: UiColumnMapping[] = [
|
||||
uiTableId: "latency",
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseSelect:
|
||||
"if(isNull(end_time), NULL, date_diff('milliseconds', start_time, end_time) / 1000)",
|
||||
"if(isNull(end_time), NULL, date_diff('millisecond', start_time, end_time) / 1000)",
|
||||
// If we use the default of Decimal64(12), we cannot filter for more than ~40min due to an overflow
|
||||
clickhouseTypeOverwrite: "Decimal64(3)",
|
||||
},
|
||||
@@ -86,7 +92,7 @@ export const observationsTableUiColumnDefinitions: UiColumnMapping[] = [
|
||||
uiTableId: "tokensPerSecond",
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseSelect:
|
||||
"(arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, usage_details))) / (date_diff('milliseconds', start_time, end_time) / 1000))",
|
||||
"(arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, usage_details))) / (date_diff('millisecond', start_time, end_time) / 1000))",
|
||||
},
|
||||
{
|
||||
uiTableName: "Input Cost ($)",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { merge } from "lodash";
|
||||
import { JsonNested, jsonSchema, jsonSchemaNullable } from "./zod";
|
||||
import { JsonNested, jsonSchema } from "./zod";
|
||||
|
||||
/**
|
||||
* Deeply parses a JSON string or object for nested stringified JSON
|
||||
@@ -52,29 +52,11 @@ export const mergeJson = (
|
||||
|
||||
export const parseJsonPrioritised = (
|
||||
json: string,
|
||||
): z.infer<typeof jsonSchema> | string | undefined => {
|
||||
): JsonNested | string | undefined => {
|
||||
try {
|
||||
const parsedJson = JSON.parse(json);
|
||||
|
||||
if (Object.keys(parsedJson).length === 0) {
|
||||
return parsedJson;
|
||||
}
|
||||
|
||||
const parsedArray = z.array(jsonSchemaNullable).safeParse(parsedJson);
|
||||
if (parsedArray.success) {
|
||||
return parsedArray.data;
|
||||
}
|
||||
|
||||
const parsedObject = z.record(jsonSchemaNullable).safeParse(parsedJson);
|
||||
if (parsedObject.success) {
|
||||
return parsedObject.data;
|
||||
}
|
||||
|
||||
return jsonSchema.parse(parsedJson);
|
||||
return JSON.parse(json);
|
||||
} catch (error) {
|
||||
const parsed = jsonSchema.safeParse(json);
|
||||
|
||||
return parsed.success ? parsed.data : json;
|
||||
return json;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -23,18 +23,18 @@ type Json = Root | { [key: string]: JsonNested } | JsonNested[];
|
||||
// Here, you define the schema recursively
|
||||
export const jsonSchemaNullable: z.ZodType<JsonNested> = z.lazy(() =>
|
||||
z.union([
|
||||
nestedLiteralSchema,
|
||||
z.array(jsonSchemaNullable),
|
||||
z.record(jsonSchemaNullable),
|
||||
nestedLiteralSchema,
|
||||
]),
|
||||
);
|
||||
|
||||
// Root schema that does not allow nulls at the root level
|
||||
export const jsonSchema: z.ZodType<Json> = z.lazy(() =>
|
||||
z.union([
|
||||
rootLiteralSchema,
|
||||
z.array(jsonSchemaNullable),
|
||||
z.record(jsonSchemaNullable),
|
||||
rootLiteralSchema,
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
Generated
+506
-328
File diff suppressed because it is too large
Load Diff
@@ -95,6 +95,9 @@ ARG BUILDPLATFORM
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG NEXT_PUBLIC_BUILD_ID
|
||||
ENV BUILD_ID=$NEXT_PUBLIC_BUILD_ID
|
||||
|
||||
ENV NODE_ENV production
|
||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
+19
-19
@@ -12,8 +12,8 @@ import { env } from "./src/env.mjs";
|
||||
*/
|
||||
const cspHeader = `
|
||||
default-src 'self' https://*.langfuse.com https://*.langfuse.dev https://*.posthog.com https://*.sentry.io wss://*.crisp.chat https://*.crisp.chat;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' https://*.langfuse.com https://*.langfuse.dev https://client.crisp.chat https://settings.crisp.chat https://challenges.cloudflare.com https://*.sentry.io https://static.cloudflareinsights.com https://*.stripe.com;
|
||||
style-src 'self' 'unsafe-inline' https://client.crisp.chat;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' https://*.langfuse.com https://*.langfuse.dev https://client.crisp.chat https://settings.crisp.chat https://challenges.cloudflare.com https://*.sentry.io https://static.cloudflareinsights.com https://*.stripe.com https://uptime.betterstack.com;
|
||||
style-src 'self' 'unsafe-inline' https://client.crisp.chat https://uptime.betterstack.com;
|
||||
img-src 'self' https: blob: data: http://localhost:* https://client.crisp.chat https://image.crisp.chat https://storage.crisp.chat;
|
||||
font-src 'self' https://client.crisp.chat;
|
||||
frame-src 'self' https://challenges.cloudflare.com https://*.stripe.com https://game.crisp.chat;
|
||||
@@ -22,7 +22,7 @@ const cspHeader = `
|
||||
base-uri 'self';
|
||||
form-action 'self';
|
||||
frame-ancestors 'none';
|
||||
connect-src 'self' https://*.langfuse.com https://*.langfuse.dev https://client.crisp.chat https://storage.crisp.chat wss://client.relay.crisp.chat wss://stream.relay.crisp.chat https://*.ingest.us.sentry.io;
|
||||
connect-src 'self' https://*.langfuse.com https://*.langfuse.dev https://client.crisp.chat https://storage.crisp.chat wss://client.relay.crisp.chat wss://stream.relay.crisp.chat https://*.ingest.us.sentry.io https://uptime.betterstack.com;
|
||||
media-src 'self' https: http://localhost:* https://client.crisp.chat;
|
||||
${env.LANGFUSE_CSP_ENFORCE_HTTPS === "true" ? "upgrade-insecure-requests; block-all-mixed-content;" : ""}
|
||||
${env.SENTRY_CSP_REPORT_URI ? `report-uri ${env.SENTRY_CSP_REPORT_URI}; report-to csp-endpoint;` : ""}
|
||||
@@ -128,22 +128,22 @@ const nextConfig = {
|
||||
// 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
|
||||
{
|
||||
|
||||
+8
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "3.4.0",
|
||||
"version": "3.11.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -70,6 +70,7 @@
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-progress": "^1.1.1",
|
||||
"@radix-ui/react-radio-group": "^1.2.1",
|
||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
@@ -99,16 +100,18 @@
|
||||
"@uiw/react-codemirror": "^4.21.25",
|
||||
"ai": "^3.4.9",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.12.10",
|
||||
"bullmq": "^5.34.10",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"core-js": "^3.38.1",
|
||||
"cors": "^2.8.5",
|
||||
"csv-parse": "^5.6.0",
|
||||
"date-fns": "^3.3.1",
|
||||
"dd-trace": "^5.23.1",
|
||||
"decimal.js": "^10.4.3",
|
||||
"dompurify": "^3.1.5",
|
||||
"fastest-levenshtein": "^1.0.16",
|
||||
"graphql": "^16.9.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"ip-address": "^9.0.5",
|
||||
@@ -117,7 +120,7 @@
|
||||
"langchain": "^0.3.6",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.462.0",
|
||||
"next": "^14.2.15",
|
||||
"next": "^14.2.21",
|
||||
"next-auth": "^4.24.11",
|
||||
"next-query-params": "^5.0.1",
|
||||
"next-themes": "^0.3.0",
|
||||
@@ -175,7 +178,6 @@
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"node-mocks-http": "^1.14.1",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
@@ -193,7 +195,8 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"jsonpath-plus": "10.0.7"
|
||||
"jsonpath-plus": "10.0.7",
|
||||
"nanoid": "^3.3.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,6 +723,9 @@ paths:
|
||||
Notes:
|
||||
|
||||
|
||||
- Introduction to data model:
|
||||
https://langfuse.com/docs/tracing-data-model
|
||||
|
||||
- Batch sizes are limited to 3.5 MB in total. You need to adjust the
|
||||
number of events per batch accordingly.
|
||||
|
||||
@@ -3183,11 +3186,12 @@ components:
|
||||
to exact match, use `(?i)^modelname$`
|
||||
startDate:
|
||||
type: string
|
||||
format: date
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: Apply only to generations which are newer than this ISO date.
|
||||
unit:
|
||||
$ref: '#/components/schemas/ModelUsageUnit'
|
||||
nullable: true
|
||||
description: Unit used by this model.
|
||||
inputPrice:
|
||||
type: number
|
||||
@@ -3223,7 +3227,6 @@ components:
|
||||
- id
|
||||
- modelName
|
||||
- matchPattern
|
||||
- unit
|
||||
- isLangfuseManaged
|
||||
ModelUsageUnit:
|
||||
title: ModelUsageUnit
|
||||
@@ -4276,6 +4279,7 @@ components:
|
||||
description: Apply only to generations which are newer than this ISO date.
|
||||
unit:
|
||||
$ref: '#/components/schemas/ModelUsageUnit'
|
||||
nullable: true
|
||||
description: Unit used by this model.
|
||||
inputPrice:
|
||||
type: number
|
||||
@@ -4308,7 +4312,6 @@ components:
|
||||
required:
|
||||
- modelName
|
||||
- matchPattern
|
||||
- unit
|
||||
Observations:
|
||||
title: Observations
|
||||
type: object
|
||||
|
||||
@@ -558,7 +558,7 @@
|
||||
"_type": "endpoint",
|
||||
"name": "Batch",
|
||||
"request": {
|
||||
"description": "Batched ingestion for Langfuse Tracing. If you want to use tracing via the API, such as to build your own Langfuse client implementation, this is the only API route you need to implement.\n\nNotes:\n\n- Batch sizes are limited to 3.5 MB in total. You need to adjust the number of events per batch accordingly.\n- The API does not return a 4xx status code for input errors. Instead, it responds with a 207 status code, which includes a list of the encountered errors.",
|
||||
"description": "Batched ingestion for Langfuse Tracing. If you want to use tracing via the API, such as to build your own Langfuse client implementation, this is the only API route you need to implement.\n\nNotes:\n\n- Introduction to data model: https://langfuse.com/docs/tracing-data-model\n- Batch sizes are limited to 3.5 MB in total. You need to adjust the number of events per batch accordingly.\n- The API does not return a 4xx status code for input errors. Instead, it responds with a 207 status code, which includes a list of the encountered errors.",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/ingestion",
|
||||
"host": [
|
||||
|
||||
@@ -123,7 +123,7 @@ test("Unauthenticated user should not be redirected to non-relative URLs after l
|
||||
test("Unauthenticated user should be redirected to relative URL after login", async ({
|
||||
page,
|
||||
}) => {
|
||||
const relativeUrl = "/support";
|
||||
const relativeUrl = "/setup";
|
||||
await page.goto(
|
||||
`/auth/sign-in?targetPath=${encodeURIComponent(relativeUrl)}`,
|
||||
);
|
||||
|
||||
@@ -215,6 +215,102 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
|
||||
expect(getDataset.body.items[0].id).toEqual("active-item-id");
|
||||
});
|
||||
|
||||
it("should correctly update dataset items", async () => {
|
||||
const datasetItemId = v4();
|
||||
const datasetName = v4();
|
||||
|
||||
await prisma.dataset.create({
|
||||
data: {
|
||||
name: datasetName,
|
||||
projectId: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
await makeZodVerifiedAPICall(
|
||||
PostDatasetItemsV1Response,
|
||||
"POST",
|
||||
"/api/public/dataset-items",
|
||||
{
|
||||
datasetName: datasetName,
|
||||
id: datasetItemId,
|
||||
input: { key: "value" },
|
||||
expectedOutput: { key: "value" },
|
||||
metadata: null,
|
||||
sourceTraceId: null,
|
||||
sourceObservationId: null,
|
||||
status: null,
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
await makeZodVerifiedAPICall(
|
||||
PostDatasetItemsV1Response,
|
||||
"POST",
|
||||
"/api/public/dataset-items",
|
||||
{
|
||||
datasetName: datasetName,
|
||||
id: datasetItemId,
|
||||
input: { john: "doe" },
|
||||
expectedOutput: { john: "doe" },
|
||||
metadata: null,
|
||||
sourceTraceId: null,
|
||||
sourceObservationId: null,
|
||||
status: null,
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
const databaseDatasetItem = await prisma.datasetItem.findFirst({
|
||||
where: {
|
||||
id: datasetItemId,
|
||||
},
|
||||
});
|
||||
expect(databaseDatasetItem).toMatchObject({
|
||||
input: { john: "doe" },
|
||||
expectedOutput: { john: "doe" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 404 when trying to update dataset item that exists in different dataset of the same project", async () => {
|
||||
const datasetItemId = v4();
|
||||
|
||||
const dataset = await prisma.dataset.create({
|
||||
data: {
|
||||
name: "dataset-name-1",
|
||||
projectId: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.dataset.create({
|
||||
data: {
|
||||
name: "dataset-name-2",
|
||||
projectId: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.datasetItem.create({
|
||||
data: {
|
||||
id: datasetItemId,
|
||||
datasetId: dataset.id,
|
||||
projectId: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/dataset-items",
|
||||
{
|
||||
datasetName: "dataset-name-2",
|
||||
id: datasetItemId,
|
||||
input: { key: "new-value" },
|
||||
expectedOutput: { key: "new-value" },
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("GET datasets (v1 & v2)", async () => {
|
||||
// v1 post
|
||||
await makeZodVerifiedAPICall(
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { makeAPICall } from "@/src/__tests__/test-utils";
|
||||
import waitForExpect from "wait-for-expect";
|
||||
import {
|
||||
getObservationById,
|
||||
getScoreById,
|
||||
getTraceById,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { v4 } from "uuid";
|
||||
|
||||
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
|
||||
|
||||
describe("/api/public/ingestion API Endpoint", () => {
|
||||
it.each([
|
||||
[
|
||||
"plain",
|
||||
{
|
||||
id: randomUUID(),
|
||||
type: "trace-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
id: randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"metadata+io",
|
||||
{
|
||||
id: randomUUID(),
|
||||
type: "trace-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
id: randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
metadata: { hello: "world" },
|
||||
input: "input",
|
||||
output: "output",
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"complex-io",
|
||||
{
|
||||
id: randomUUID(),
|
||||
type: "trace-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
id: randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
metadata: { hello: "world" },
|
||||
input: ["hello", { world: "world" }, [1, 2, 3]],
|
||||
},
|
||||
},
|
||||
],
|
||||
])(
|
||||
"should create traces via the ingestion API (%s)",
|
||||
async (_name: string, entity: any) => {
|
||||
const response = await makeAPICall("POST", "/api/public/ingestion", {
|
||||
batch: [entity],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(207);
|
||||
|
||||
await waitForExpect(async () => {
|
||||
const trace = await getTraceById(entity.body.id, projectId);
|
||||
expect(trace).toBeDefined();
|
||||
expect(trace!.id).toBe(entity.body.id);
|
||||
expect(trace!.projectId).toBe(projectId);
|
||||
expect(trace!.metadata).toEqual(entity.body?.metadata ?? {});
|
||||
expect(trace!.input).toEqual(entity.body?.input ?? null);
|
||||
expect(trace!.output).toEqual(entity.body?.output ?? null);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"generation",
|
||||
"GENERATION",
|
||||
{
|
||||
id: randomUUID(),
|
||||
type: "generation-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
id: randomUUID(),
|
||||
traceId: randomUUID(),
|
||||
parentObservationId: randomUUID(),
|
||||
startTime: new Date().toISOString(),
|
||||
model: "gpt-4",
|
||||
input: { text: "input" },
|
||||
output: { text: "output" },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"span",
|
||||
"SPAN",
|
||||
{
|
||||
id: randomUUID(),
|
||||
type: "span-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
id: randomUUID(),
|
||||
traceId: randomUUID(),
|
||||
startTime: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"span-complex-io",
|
||||
"SPAN",
|
||||
{
|
||||
id: randomUUID(),
|
||||
type: "span-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
id: randomUUID(),
|
||||
traceId: randomUUID(),
|
||||
startTime: new Date().toISOString(),
|
||||
input: ["hello", { world: "world" }, [1, 2, 3]],
|
||||
output: ["hello", { world: [2, 3, "test"] }, [1, 2, 3]],
|
||||
},
|
||||
},
|
||||
],
|
||||
])(
|
||||
"should create observations via the ingestion API (%s)",
|
||||
async (_name: string, type: string, entity: any) => {
|
||||
const response = await makeAPICall("POST", "/api/public/ingestion", {
|
||||
batch: [entity],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(207);
|
||||
|
||||
await waitForExpect(async () => {
|
||||
const observation = await getObservationById(
|
||||
entity.body.id,
|
||||
projectId,
|
||||
true,
|
||||
);
|
||||
expect(observation).toBeDefined();
|
||||
expect(observation!.id).toBe(entity.body.id);
|
||||
expect(observation!.projectId).toBe(projectId);
|
||||
expect(observation!.metadata).toEqual(entity.body?.metadata ?? {});
|
||||
expect(observation!.input).toEqual(entity.body?.input ?? null);
|
||||
expect(observation!.output).toEqual(entity.body?.output ?? null);
|
||||
expect(observation!.type).toBe(type);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"plain",
|
||||
{
|
||||
id: randomUUID(),
|
||||
type: "score-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
id: randomUUID(),
|
||||
name: "score-name",
|
||||
traceId: randomUUID(),
|
||||
value: 100.5,
|
||||
observationId: randomUUID(),
|
||||
},
|
||||
},
|
||||
],
|
||||
])(
|
||||
"should create scores via the ingestion API (%s)",
|
||||
async (_name: string, entity: any) => {
|
||||
const response = await makeAPICall("POST", "/api/public/ingestion", {
|
||||
batch: [entity],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(207);
|
||||
|
||||
await waitForExpect(async () => {
|
||||
const score = await getScoreById(projectId, entity.body.id);
|
||||
expect(score).toBeDefined();
|
||||
expect(score!.id).toBe(entity.body.id);
|
||||
expect(score!.projectId).toBe(projectId);
|
||||
expect(score!.value).toEqual(100.5);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("should fail for long trace name", async () => {
|
||||
const traceId = v4();
|
||||
|
||||
const baseString =
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. ";
|
||||
const repeatCount = Math.ceil(1500 / baseString.length);
|
||||
const name = baseString.repeat(repeatCount);
|
||||
|
||||
const response = await makeAPICall("POST", "/api/public/ingestion", {
|
||||
batch: [
|
||||
{
|
||||
id: v4(),
|
||||
type: "trace-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
id: traceId,
|
||||
name,
|
||||
userId: "user-1",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(207);
|
||||
expect("errors" in response.body).toBe(true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
expect(response.body.errors.length).toBe(1);
|
||||
expect(response.body.errors[0].message).toBe("Invalid request data");
|
||||
});
|
||||
});
|
||||
@@ -204,6 +204,7 @@ describe("/api/public/observations API Endpoint", () => {
|
||||
trace_id: traceId,
|
||||
name: "generation-name",
|
||||
start_time: new Date("2021-01-01T00:00:00.000Z").getTime(),
|
||||
event_ts: 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",
|
||||
@@ -214,6 +215,7 @@ describe("/api/public/observations API Endpoint", () => {
|
||||
trace_id: traceId,
|
||||
name: "generation-name",
|
||||
start_time: new Date("2021-02-01T00:00:00.000Z").getTime(),
|
||||
event_ts: 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",
|
||||
@@ -223,6 +225,7 @@ describe("/api/public/observations API Endpoint", () => {
|
||||
trace_id: traceId,
|
||||
name: "generation-name",
|
||||
start_time: new Date("2021-03-01T00:00:00.000Z").getTime(),
|
||||
event_ts: new Date("2021-03-01T00:00:00.000Z").getTime(),
|
||||
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
type: "EVENT",
|
||||
});
|
||||
@@ -231,6 +234,7 @@ describe("/api/public/observations API Endpoint", () => {
|
||||
trace_id: traceId,
|
||||
name: "generation-name",
|
||||
start_time: new Date("2021-04-01T00:00:00.000Z").getTime(),
|
||||
event_ts: 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",
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
import { v4 } from "uuid";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import {
|
||||
createObservation,
|
||||
createObservationsCh,
|
||||
createOrgProjectAndApiKey,
|
||||
createTracesCh,
|
||||
getSessionsWithMetrics,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { createTrace, getSessionsTable } from "@langfuse/shared/src/server";
|
||||
|
||||
describe("trpc.sessions", () => {
|
||||
describe("GET sessions.all", () => {
|
||||
it("should GET all session", async () => {
|
||||
const { projectId } = await createOrgProjectAndApiKey();
|
||||
const sessionId = v4();
|
||||
|
||||
await prisma.traceSession.create({
|
||||
data: {
|
||||
id: sessionId,
|
||||
projectId: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
const traces = [
|
||||
createTrace({ session_id: sessionId, project_id: projectId }),
|
||||
createTrace({ session_id: sessionId, project_id: projectId }),
|
||||
];
|
||||
|
||||
await createTracesCh(traces);
|
||||
|
||||
const uiSessions = await getSessionsTable({
|
||||
projectId: projectId,
|
||||
filter: [],
|
||||
orderBy: null,
|
||||
limit: 10000,
|
||||
page: 0,
|
||||
});
|
||||
|
||||
expect(uiSessions.length).toBe(1);
|
||||
expect(uiSessions[0].session_id).toBe(sessionId);
|
||||
expect(uiSessions[0].trace_count).toBe(2);
|
||||
expect(uiSessions[0].trace_tags).toEqual(["doe", "john"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should GET all session filtered by trace attribute only", async () => {
|
||||
const { projectId } = await createOrgProjectAndApiKey();
|
||||
const sessionId = v4();
|
||||
|
||||
await prisma.traceSession.create({
|
||||
data: {
|
||||
id: sessionId,
|
||||
projectId: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
const traces = [
|
||||
createTrace({
|
||||
session_id: sessionId,
|
||||
project_id: projectId,
|
||||
user_id: "user1",
|
||||
}),
|
||||
createTrace({
|
||||
session_id: sessionId,
|
||||
project_id: projectId,
|
||||
user_id: undefined,
|
||||
}),
|
||||
];
|
||||
|
||||
await createTracesCh(traces);
|
||||
|
||||
const uiSessions = await getSessionsTable({
|
||||
projectId: projectId,
|
||||
filter: [
|
||||
{
|
||||
column: "userIds",
|
||||
type: "stringOptions",
|
||||
operator: "any of",
|
||||
value: ["user1"],
|
||||
},
|
||||
],
|
||||
orderBy: null,
|
||||
limit: 10000,
|
||||
page: 0,
|
||||
});
|
||||
|
||||
expect(uiSessions.length).toBe(1);
|
||||
expect(uiSessions[0].session_id).toBe(sessionId);
|
||||
expect(uiSessions[0].trace_count).toBe(2);
|
||||
expect(uiSessions[0].trace_tags).toEqual(["doe", "john"]);
|
||||
expect(uiSessions[0].user_ids).toEqual(["user1"]);
|
||||
});
|
||||
|
||||
it("should GET sessions ordered by total cost", async () => {
|
||||
const { projectId } = await createOrgProjectAndApiKey();
|
||||
const sessionId1 = v4();
|
||||
const sessionId2 = v4();
|
||||
|
||||
await prisma.traceSession.createMany({
|
||||
data: [
|
||||
{
|
||||
id: sessionId1,
|
||||
projectId: projectId,
|
||||
},
|
||||
{
|
||||
id: sessionId2,
|
||||
projectId: projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const traces = [
|
||||
createTrace({
|
||||
session_id: sessionId1,
|
||||
project_id: projectId,
|
||||
}),
|
||||
createTrace({
|
||||
session_id: sessionId2,
|
||||
project_id: projectId,
|
||||
}),
|
||||
];
|
||||
|
||||
const observations = [
|
||||
createObservation({
|
||||
trace_id: traces[0].id,
|
||||
project_id: projectId,
|
||||
cost_details: {
|
||||
input: 0.1,
|
||||
output: 0.2,
|
||||
total: 0.3,
|
||||
},
|
||||
total_cost: 0.3,
|
||||
}),
|
||||
createObservation({
|
||||
trace_id: traces[1].id,
|
||||
project_id: projectId,
|
||||
cost_details: {
|
||||
input: 0.3,
|
||||
output: 0.4,
|
||||
total: 0.7,
|
||||
},
|
||||
total_cost: 0.7,
|
||||
}),
|
||||
];
|
||||
|
||||
await createTracesCh(traces);
|
||||
await createObservationsCh(observations);
|
||||
|
||||
const uiSessions = await getSessionsTable({
|
||||
projectId: projectId,
|
||||
filter: [],
|
||||
orderBy: {
|
||||
column: "totalCost",
|
||||
order: "DESC" as const,
|
||||
},
|
||||
limit: 10000,
|
||||
page: 0,
|
||||
});
|
||||
|
||||
expect(uiSessions.length).toBe(2);
|
||||
expect(uiSessions[0].session_id).toBe(sessionId2);
|
||||
expect(uiSessions[1].session_id).toBe(sessionId1);
|
||||
});
|
||||
|
||||
it("should GET sessions ordered by duration", async () => {
|
||||
const { projectId } = await createOrgProjectAndApiKey();
|
||||
const sessionId1 = v4();
|
||||
const sessionId2 = v4();
|
||||
|
||||
await prisma.traceSession.createMany({
|
||||
data: [
|
||||
{
|
||||
id: sessionId1,
|
||||
projectId: projectId,
|
||||
},
|
||||
{
|
||||
id: sessionId2,
|
||||
projectId: projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const traces = [
|
||||
createTrace({
|
||||
session_id: sessionId1,
|
||||
project_id: projectId,
|
||||
}),
|
||||
createTrace({
|
||||
session_id: sessionId2,
|
||||
project_id: projectId,
|
||||
}),
|
||||
];
|
||||
const observations = [
|
||||
createObservation({
|
||||
trace_id: traces[0].id,
|
||||
project_id: projectId,
|
||||
start_time: new Date("2024-01-01T00:00:00Z").getTime(),
|
||||
end_time: new Date("2024-01-01T00:00:10Z").getTime(), // 10 second duration
|
||||
}),
|
||||
createObservation({
|
||||
trace_id: traces[1].id,
|
||||
project_id: projectId,
|
||||
start_time: new Date("2024-01-01T00:00:00Z").getTime(),
|
||||
end_time: new Date("2024-01-01T00:00:20Z").getTime(), // 20 second duration
|
||||
}),
|
||||
];
|
||||
|
||||
await createTracesCh(traces);
|
||||
await createObservationsCh(observations);
|
||||
|
||||
const uiSessions = await getSessionsTable({
|
||||
projectId: projectId,
|
||||
filter: [],
|
||||
orderBy: {
|
||||
column: "sessionDuration",
|
||||
order: "DESC" as const,
|
||||
},
|
||||
limit: 10000,
|
||||
page: 0,
|
||||
});
|
||||
|
||||
expect(uiSessions.length).toBe(2);
|
||||
expect(uiSessions[0].session_id).toBe(sessionId2);
|
||||
expect(uiSessions[1].session_id).toBe(sessionId1);
|
||||
});
|
||||
|
||||
it("should GET metrics for a list of sessions", async () => {
|
||||
const { projectId } = await createOrgProjectAndApiKey();
|
||||
const sessionId1 = v4();
|
||||
const sessionId2 = v4();
|
||||
|
||||
await prisma.traceSession.createMany({
|
||||
data: [
|
||||
{
|
||||
id: sessionId1,
|
||||
projectId: projectId,
|
||||
},
|
||||
{
|
||||
id: sessionId2,
|
||||
projectId: projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const traces = [
|
||||
createTrace({
|
||||
session_id: sessionId1,
|
||||
project_id: projectId,
|
||||
user_id: "user1",
|
||||
}),
|
||||
createTrace({
|
||||
session_id: sessionId1,
|
||||
project_id: projectId,
|
||||
user_id: "user2",
|
||||
}),
|
||||
createTrace({
|
||||
session_id: sessionId2,
|
||||
project_id: projectId,
|
||||
user_id: "user3",
|
||||
}),
|
||||
];
|
||||
|
||||
const observations = traces.flatMap((trace) => [
|
||||
createObservation({
|
||||
trace_id: trace.id,
|
||||
project_id: projectId,
|
||||
}),
|
||||
createObservation({
|
||||
trace_id: trace.id,
|
||||
project_id: projectId,
|
||||
}),
|
||||
]);
|
||||
|
||||
await createObservationsCh(observations);
|
||||
|
||||
await createTracesCh(traces);
|
||||
|
||||
const sessions = await getSessionsWithMetrics({
|
||||
projectId: projectId,
|
||||
filter: [
|
||||
{
|
||||
column: "id",
|
||||
type: "stringOptions",
|
||||
operator: "any of",
|
||||
value: [sessionId1, sessionId2],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(sessions.length).toBe(2);
|
||||
|
||||
// Session 1 checks
|
||||
const session1 = sessions.find((s) => s.session_id === sessionId1);
|
||||
expect(session1).toBeDefined();
|
||||
expect(session1?.trace_count).toBe(2);
|
||||
expect(session1?.user_ids).toEqual(
|
||||
expect.arrayContaining(["user1", "user2"]),
|
||||
);
|
||||
expect(session1?.trace_tags).toEqual(["doe", "john"]);
|
||||
expect(session1?.total_observations).toEqual(4);
|
||||
|
||||
expect(Number(session1?.session_input_cost)).toBeGreaterThan(0);
|
||||
expect(Number(session1?.session_output_cost)).toBeGreaterThan(0);
|
||||
expect(Number(session1?.session_total_cost)).toBeGreaterThan(0);
|
||||
expect(Number(session1?.session_input_usage)).toBeGreaterThan(0);
|
||||
expect(Number(session1?.session_output_usage)).toBeGreaterThan(0);
|
||||
expect(Number(session1?.session_total_usage)).toBeGreaterThan(0);
|
||||
|
||||
// Session 2 checks
|
||||
const session2 = sessions.find((s) => s.session_id === sessionId2);
|
||||
expect(session2).toBeDefined();
|
||||
expect(session2?.trace_count).toBe(1);
|
||||
expect(session2?.user_ids).toEqual(["user3"]);
|
||||
expect(session2?.trace_tags).toEqual(["doe", "john"]);
|
||||
expect(session2?.total_observations).toEqual(2);
|
||||
|
||||
expect(Number(session2?.session_input_cost)).toBeGreaterThan(0);
|
||||
expect(Number(session2?.session_output_cost)).toBeGreaterThan(0);
|
||||
expect(Number(session2?.session_total_cost)).toBeGreaterThan(0);
|
||||
expect(Number(session2?.session_input_usage)).toBeGreaterThan(0);
|
||||
expect(Number(session2?.session_output_usage)).toBeGreaterThan(0);
|
||||
expect(Number(session2?.session_total_usage)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -261,6 +261,7 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
const trace2 = traces.body.data[1];
|
||||
expect(trace2.name).toBe("trace-name1");
|
||||
});
|
||||
|
||||
it("should return 400 error when page=0", async () => {
|
||||
const response = await makeZodVerifiedAPICallSilent(
|
||||
GetTracesV1Response,
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import {
|
||||
makeZodVerifiedAPICall,
|
||||
pruneDatabase,
|
||||
} from "@/src/__tests__/test-utils";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { PostTracesV1Response } from "@/src/features/public-api/types/traces";
|
||||
import { PostEventsV1Response } from "@/src/features/public-api/types/events";
|
||||
|
||||
describe("/api/public/events API Endpoint", () => {
|
||||
beforeEach(async () => await pruneDatabase());
|
||||
|
||||
it("should create event after trace", async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
const traceId = uuidv4();
|
||||
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
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 dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
id: traceId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBeGreaterThan(0);
|
||||
expect(dbTrace[0]?.id).toBe(traceId);
|
||||
|
||||
const eventId = uuidv4();
|
||||
const createEvent = await makeZodVerifiedAPICall(
|
||||
PostEventsV1Response,
|
||||
"POST",
|
||||
"/api/public/events",
|
||||
{
|
||||
id: eventId,
|
||||
traceId: traceId,
|
||||
name: "event-name",
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
output: { output: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
expect(createEvent.status).toBe(200);
|
||||
const dbEvent = await prisma.observation.findUnique({
|
||||
where: {
|
||||
id: eventId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbEvent?.id).toBe(eventId);
|
||||
expect(dbEvent?.traceId).toBe(traceId);
|
||||
expect(dbEvent?.name).toBe("event-name");
|
||||
expect(dbEvent?.startTime).toEqual(new Date("2021-01-01T00:00:00.000Z"));
|
||||
expect(dbEvent?.input).toEqual({ input: "value" });
|
||||
expect(dbEvent?.output).toEqual({ output: "value" });
|
||||
expect(dbEvent?.metadata).toEqual({ meta: "value" });
|
||||
expect(dbEvent?.version).toBe("2.0.0");
|
||||
});
|
||||
|
||||
it("should create event before trace", async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
const traceId = uuidv4();
|
||||
const eventId = uuidv4();
|
||||
|
||||
const createEvent = await makeZodVerifiedAPICall(
|
||||
PostEventsV1Response,
|
||||
"POST",
|
||||
"/api/public/events",
|
||||
{
|
||||
id: eventId,
|
||||
traceId: traceId,
|
||||
name: "event-name",
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
output: { output: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
expect(createEvent.status).toBe(200);
|
||||
const dbEvent = await prisma.observation.findUnique({
|
||||
where: {
|
||||
id: eventId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbEvent?.id).toBe(eventId);
|
||||
expect(dbEvent?.traceId).toBe(traceId);
|
||||
expect(dbEvent?.name).toBe("event-name");
|
||||
expect(dbEvent?.startTime).toEqual(new Date("2021-01-01T00:00:00.000Z"));
|
||||
expect(dbEvent?.input).toEqual({ input: "value" });
|
||||
expect(dbEvent?.output).toEqual({ output: "value" });
|
||||
expect(dbEvent?.metadata).toEqual({ meta: "value" });
|
||||
expect(dbEvent?.version).toBe("2.0.0");
|
||||
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
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 dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
id: traceId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBeGreaterThan(0);
|
||||
expect(dbTrace[0]?.id).toBe(traceId);
|
||||
});
|
||||
|
||||
it("should create trace and ignore externalId and create event afterwards", async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
const traceId = uuidv4();
|
||||
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
externalId: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
name: "trace-name",
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBeGreaterThan(0);
|
||||
expect(dbTrace[0]?.externalId).toBeNull();
|
||||
expect(dbTrace[0]?.id).not.toBe(traceId);
|
||||
|
||||
const eventId = uuidv4();
|
||||
const createEvent = await makeZodVerifiedAPICall(
|
||||
PostEventsV1Response,
|
||||
"POST",
|
||||
"/api/public/events",
|
||||
{
|
||||
id: eventId,
|
||||
traceIdType: "EXTERNAL",
|
||||
traceId: dbTrace[0]?.id,
|
||||
name: "event-name",
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
output: { output: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
expect(createEvent.status).toBe(200);
|
||||
const dbEvent = await prisma.observation.findUnique({
|
||||
where: {
|
||||
id: eventId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbEvent?.id).toBe(eventId);
|
||||
expect(dbEvent?.traceId).toBe(dbTrace[0]?.id);
|
||||
expect(dbEvent?.name).toBe("event-name");
|
||||
expect(dbEvent?.startTime).toEqual(new Date("2021-01-01T00:00:00.000Z"));
|
||||
expect(dbEvent?.input).toEqual({ input: "value" });
|
||||
expect(dbEvent?.output).toEqual({ output: "value" });
|
||||
expect(dbEvent?.metadata).toEqual({ meta: "value" });
|
||||
expect(dbEvent?.version).toBe("2.0.0");
|
||||
});
|
||||
|
||||
it("should create trace when creating span without existing trace", async () => {
|
||||
const eventName = uuidv4();
|
||||
|
||||
const spanId = uuidv4();
|
||||
const createEvent = await makeZodVerifiedAPICall(
|
||||
PostEventsV1Response,
|
||||
"POST",
|
||||
"/api/public/events",
|
||||
{
|
||||
id: spanId,
|
||||
name: eventName,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
output: { output: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
name: eventName,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBe(1);
|
||||
expect(dbTrace[0]?.name).toBe(eventName);
|
||||
|
||||
expect(createEvent.status).toBe(200);
|
||||
const dbEvent = await prisma.observation.findUnique({
|
||||
where: {
|
||||
id: spanId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbEvent?.id).toBe(spanId);
|
||||
expect(dbEvent?.traceId).toBe(dbTrace[0]?.id);
|
||||
expect(dbEvent?.name).toBe(eventName);
|
||||
expect(dbEvent?.startTime).toEqual(new Date("2021-01-01T00:00:00.000Z"));
|
||||
expect(dbEvent?.input).toEqual({ input: "value" });
|
||||
expect(dbEvent?.output).toEqual({ output: "value" });
|
||||
expect(dbEvent?.metadata).toEqual({ meta: "value" });
|
||||
expect(dbEvent?.version).toBe("2.0.0");
|
||||
});
|
||||
|
||||
it("should create event when creating generation without existing trace without traceId", async () => {
|
||||
const generationName = uuidv4();
|
||||
|
||||
const spanId = uuidv4();
|
||||
const createSpan = await makeZodVerifiedAPICall(
|
||||
PostEventsV1Response,
|
||||
"POST",
|
||||
"/api/public/events",
|
||||
{
|
||||
id: spanId,
|
||||
name: generationName,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { key: "value" },
|
||||
metadata: { key: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
const dbEvent = await prisma.observation.findFirstOrThrow({
|
||||
where: {
|
||||
name: generationName,
|
||||
},
|
||||
});
|
||||
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
id: dbEvent.traceId!,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBe(1);
|
||||
expect(dbTrace[0]?.name).toBe(generationName);
|
||||
|
||||
expect(createSpan.status).toBe(200);
|
||||
|
||||
expect(dbEvent.id).toBe(spanId);
|
||||
expect(dbEvent.traceId).toBe(dbTrace[0]?.id);
|
||||
expect(dbEvent.name).toBe(generationName);
|
||||
expect(dbEvent.startTime).toEqual(new Date("2021-01-01T00:00:00.000Z"));
|
||||
expect(dbEvent.input).toEqual({ key: "value" });
|
||||
expect(dbEvent.metadata).toEqual({ key: "value" });
|
||||
expect(dbEvent.version).toBe("2.0.0");
|
||||
});
|
||||
});
|
||||
@@ -1,643 +0,0 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import {
|
||||
makeAPICall,
|
||||
makeZodVerifiedAPICall,
|
||||
pruneDatabase,
|
||||
} from "@/src/__tests__/test-utils";
|
||||
import { ModelUsageUnit } from "@langfuse/shared";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { PostTracesV1Response } from "@/src/features/public-api/types/traces";
|
||||
import {
|
||||
PatchGenerationsV1Response,
|
||||
PostGenerationsV1Body,
|
||||
PostGenerationsV1Response,
|
||||
} from "@/src/features/public-api/types/generations";
|
||||
|
||||
describe("/api/public/generations API Endpoint", () => {
|
||||
beforeEach(async () => await pruneDatabase());
|
||||
|
||||
[
|
||||
{
|
||||
usage: {
|
||||
input: 100,
|
||||
output: 200,
|
||||
total: 100,
|
||||
unit: ModelUsageUnit.Characters,
|
||||
},
|
||||
expectedUnit: ModelUsageUnit.Characters,
|
||||
expectedPromptTokens: 100,
|
||||
expectedCompletionTokens: 200,
|
||||
expectedTotalTokens: 100,
|
||||
},
|
||||
{
|
||||
usage: {
|
||||
total: 100,
|
||||
unit: ModelUsageUnit.Characters,
|
||||
},
|
||||
expectedUnit: ModelUsageUnit.Characters,
|
||||
expectedPromptTokens: 0,
|
||||
expectedCompletionTokens: 0,
|
||||
expectedTotalTokens: 100,
|
||||
},
|
||||
{
|
||||
usage: {
|
||||
total: 100,
|
||||
},
|
||||
expectedUnit: null,
|
||||
expectedPromptTokens: 0,
|
||||
expectedCompletionTokens: 0,
|
||||
expectedTotalTokens: 100,
|
||||
},
|
||||
{
|
||||
usage: {
|
||||
promptTokens: 100,
|
||||
completionTokens: 200,
|
||||
totalTokens: 100,
|
||||
},
|
||||
expectedPromptTokens: 100,
|
||||
expectedCompletionTokens: 200,
|
||||
expectedTotalTokens: 100,
|
||||
expectedUnit: ModelUsageUnit.Tokens,
|
||||
},
|
||||
{
|
||||
usage: {
|
||||
totalTokens: 100,
|
||||
},
|
||||
expectedPromptTokens: 0,
|
||||
expectedCompletionTokens: 0,
|
||||
expectedTotalTokens: 100,
|
||||
expectedUnit: ModelUsageUnit.Tokens,
|
||||
},
|
||||
{
|
||||
usage: undefined,
|
||||
expectedPromptTokens: 0,
|
||||
expectedCompletionTokens: 0,
|
||||
expectedTotalTokens: 0,
|
||||
expectedUnit: null,
|
||||
},
|
||||
{
|
||||
usage: null,
|
||||
expectedPromptTokens: 0,
|
||||
expectedCompletionTokens: 0,
|
||||
expectedTotalTokens: 0,
|
||||
expectedUnit: null,
|
||||
},
|
||||
{
|
||||
usage: {},
|
||||
expectedPromptTokens: 0,
|
||||
expectedCompletionTokens: 0,
|
||||
expectedTotalTokens: 0,
|
||||
expectedUnit: null,
|
||||
},
|
||||
].forEach((testConfig) => {
|
||||
it(`should create generation after trace 1 ${JSON.stringify(
|
||||
testConfig,
|
||||
)}`, async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
const traceId = uuidv4();
|
||||
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
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 dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
id: traceId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBeGreaterThan(0);
|
||||
expect(dbTrace[0]?.id).toBe(traceId);
|
||||
|
||||
const generationId = uuidv4();
|
||||
const createGeneration = await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Response,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
id: generationId,
|
||||
traceId: traceId,
|
||||
name: "generation-name",
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
model: "model-name",
|
||||
modelParameters: { key: "value" },
|
||||
prompt: { key: "value" },
|
||||
metadata: { key: "value" },
|
||||
version: "2.0.0",
|
||||
usage: testConfig.usage,
|
||||
},
|
||||
);
|
||||
|
||||
expect(createGeneration.status).toBe(200);
|
||||
const dbGeneration = await prisma.observation.findUnique({
|
||||
where: {
|
||||
id: generationId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbGeneration?.id).toBe(generationId);
|
||||
expect(dbGeneration?.traceId).toBe(traceId);
|
||||
expect(dbGeneration?.name).toBe("generation-name");
|
||||
expect(dbGeneration?.startTime).toEqual(
|
||||
new Date("2021-01-01T00:00:00.000Z"),
|
||||
);
|
||||
expect(dbGeneration?.endTime).toEqual(
|
||||
new Date("2021-01-01T00:00:00.000Z"),
|
||||
);
|
||||
expect(dbGeneration?.model).toBe("model-name");
|
||||
expect(dbGeneration?.modelParameters).toEqual({ key: "value" });
|
||||
expect(dbGeneration?.input).toEqual({ key: "value" });
|
||||
expect(dbGeneration?.metadata).toEqual({ key: "value" });
|
||||
expect(dbGeneration?.version).toBe("2.0.0");
|
||||
expect(dbGeneration?.unit).toBe(testConfig.expectedUnit);
|
||||
expect(dbGeneration?.promptTokens).toBe(testConfig.expectedPromptTokens);
|
||||
expect(dbGeneration?.completionTokens).toBe(
|
||||
testConfig.expectedCompletionTokens,
|
||||
);
|
||||
expect(dbGeneration?.totalTokens).toBe(testConfig.expectedTotalTokens);
|
||||
});
|
||||
});
|
||||
|
||||
it("should create generation before trace", async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
const traceId = uuidv4();
|
||||
|
||||
const generationId = uuidv4();
|
||||
const createGeneration = await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Body,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
id: generationId,
|
||||
traceId: traceId,
|
||||
name: "generation-name",
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
model: "model-name",
|
||||
modelParameters: { key: "value" },
|
||||
prompt: { key: "value" },
|
||||
metadata: { key: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
expect(createGeneration.status).toBe(200);
|
||||
const dbGeneration = await prisma.observation.findUnique({
|
||||
where: {
|
||||
id: generationId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbGeneration?.id).toBe(generationId);
|
||||
expect(dbGeneration?.traceId).toBe(traceId);
|
||||
expect(dbGeneration?.name).toBe("generation-name");
|
||||
expect(dbGeneration?.startTime).toEqual(
|
||||
new Date("2021-01-01T00:00:00.000Z"),
|
||||
);
|
||||
expect(dbGeneration?.endTime).toEqual(new Date("2021-01-01T00:00:00.000Z"));
|
||||
expect(dbGeneration?.model).toBe("model-name");
|
||||
expect(dbGeneration?.modelParameters).toEqual({ key: "value" });
|
||||
expect(dbGeneration?.input).toEqual({ key: "value" });
|
||||
expect(dbGeneration?.metadata).toEqual({ key: "value" });
|
||||
expect(dbGeneration?.version).toBe("2.0.0");
|
||||
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
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 dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
id: traceId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBeGreaterThan(0);
|
||||
expect(dbTrace[0]?.id).toBe(traceId);
|
||||
});
|
||||
|
||||
it("should create generation after trace ignoring externalId", async () => {
|
||||
const traceId = uuidv4();
|
||||
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
externalId: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
name: "trace-name",
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBeGreaterThan(0);
|
||||
expect(dbTrace[0]?.externalId).toBeNull();
|
||||
expect(dbTrace[0]?.id).not.toBe(traceId);
|
||||
|
||||
const generationId = uuidv4();
|
||||
const createGeneration = await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Body,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
id: generationId,
|
||||
traceIdType: "EXTERNAL",
|
||||
traceId: traceId,
|
||||
name: "generation-name",
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
model: "model-name",
|
||||
modelParameters: { key: "value" },
|
||||
prompt: { key: "value" },
|
||||
metadata: { key: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
expect(createGeneration.status).toBe(200);
|
||||
const dbGeneration = await prisma.observation.findUnique({
|
||||
where: {
|
||||
id: generationId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbGeneration?.id).toBe(generationId);
|
||||
expect(dbGeneration?.traceId).toBe(traceId);
|
||||
expect(dbGeneration?.name).toBe("generation-name");
|
||||
expect(dbGeneration?.startTime).toEqual(
|
||||
new Date("2021-01-01T00:00:00.000Z"),
|
||||
);
|
||||
expect(dbGeneration?.endTime).toEqual(new Date("2021-01-01T00:00:00.000Z"));
|
||||
expect(dbGeneration?.model).toBe("model-name");
|
||||
expect(dbGeneration?.modelParameters).toEqual({ key: "value" });
|
||||
expect(dbGeneration?.input).toEqual({ key: "value" });
|
||||
expect(dbGeneration?.metadata).toEqual({ key: "value" });
|
||||
expect(dbGeneration?.version).toBe("2.0.0");
|
||||
});
|
||||
|
||||
it("should create trace when creating generation without existing trace", async () => {
|
||||
const generationName = uuidv4();
|
||||
|
||||
const generationId = uuidv4();
|
||||
const createGeneration = await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Body,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
id: generationId,
|
||||
name: generationName,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
model: "model-name",
|
||||
modelParameters: { key: "value" },
|
||||
prompt: { key: "value" },
|
||||
metadata: { key: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
name: generationName,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBe(1);
|
||||
expect(dbTrace[0]?.name).toBe(generationName);
|
||||
|
||||
expect(createGeneration.status).toBe(200);
|
||||
const dbGeneration = await prisma.observation.findUnique({
|
||||
where: {
|
||||
id: generationId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbGeneration?.id).toBe(generationId);
|
||||
expect(dbGeneration?.traceId).toBe(dbTrace[0]?.id);
|
||||
expect(dbGeneration?.name).toBe(generationName);
|
||||
expect(dbGeneration?.startTime).toEqual(
|
||||
new Date("2021-01-01T00:00:00.000Z"),
|
||||
);
|
||||
expect(dbGeneration?.endTime).toEqual(new Date("2021-01-01T00:00:00.000Z"));
|
||||
expect(dbGeneration?.model).toBe("model-name");
|
||||
expect(dbGeneration?.modelParameters).toEqual({ key: "value" });
|
||||
expect(dbGeneration?.input).toEqual({ key: "value" });
|
||||
expect(dbGeneration?.metadata).toEqual({ key: "value" });
|
||||
expect(dbGeneration?.version).toBe("2.0.0");
|
||||
});
|
||||
|
||||
it("should create nested generations", async () => {
|
||||
const generationName = uuidv4();
|
||||
|
||||
const generationId = uuidv4();
|
||||
const createGeneration = await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Body,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
id: generationId,
|
||||
name: generationName,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
model: "model-name",
|
||||
modelParameters: { key: "value" },
|
||||
prompt: { key: "value" },
|
||||
metadata: { key: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
expect(createGeneration.status).toBe(200);
|
||||
const dbGeneration = await prisma.observation.findUnique({
|
||||
where: {
|
||||
id: generationId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbGeneration?.id).toBe(generationId);
|
||||
|
||||
const generationId2 = uuidv4();
|
||||
const generationName2 = uuidv4();
|
||||
|
||||
const createGeneration2 = await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Body,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
id: generationId2,
|
||||
name: generationName2,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
model: "model-name",
|
||||
modelParameters: { key: "value" },
|
||||
prompt: { key: "value" },
|
||||
metadata: { key: "value" },
|
||||
version: "2.0.0",
|
||||
parentObservationId: generationId,
|
||||
},
|
||||
);
|
||||
expect(createGeneration2.status).toBe(200);
|
||||
|
||||
const dbGeneration2 = await prisma.observation.findUnique({
|
||||
where: {
|
||||
id: generationId2,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbGeneration2?.id).toBe(generationId2);
|
||||
expect(dbGeneration2?.parentObservationId).toBe(generationId);
|
||||
});
|
||||
|
||||
it("should not create trace when creating generation without existing trace with externalId", async () => {
|
||||
const generationName = uuidv4();
|
||||
|
||||
const generationId = uuidv4();
|
||||
const externalTraceId = uuidv4();
|
||||
const createGeneration = await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Body,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
id: generationId,
|
||||
traceIdType: "EXTERNAL",
|
||||
traceId: externalTraceId,
|
||||
name: generationName,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
model: "model-name",
|
||||
modelParameters: { key: "value" },
|
||||
prompt: { key: "value" },
|
||||
metadata: { key: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
expect(createGeneration.status).toBe(200);
|
||||
|
||||
const dbGeneration = await prisma.observation.findFirstOrThrow({
|
||||
where: {
|
||||
name: generationName,
|
||||
},
|
||||
});
|
||||
expect(dbGeneration.id).toBe(generationId);
|
||||
expect(dbGeneration.traceId).toBe(externalTraceId);
|
||||
|
||||
const dbTraces = await prisma.trace.findMany();
|
||||
expect(dbTraces.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should create trace when creating generation without existing trace without traceId", async () => {
|
||||
const generationName = uuidv4();
|
||||
|
||||
const generationId = uuidv4();
|
||||
const createGeneration = await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Body,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
id: generationId,
|
||||
name: generationName,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
model: "model-name",
|
||||
modelParameters: { key: "value" },
|
||||
prompt: { key: "value" },
|
||||
metadata: { key: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
const dbGeneration = await prisma.observation.findFirstOrThrow({
|
||||
where: {
|
||||
name: generationName,
|
||||
},
|
||||
});
|
||||
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
id: dbGeneration.traceId!,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBe(1);
|
||||
expect(dbTrace[0]?.name).toBe(generationName);
|
||||
|
||||
expect(createGeneration.status).toBe(200);
|
||||
|
||||
expect(dbGeneration.id).toBe(generationId);
|
||||
expect(dbGeneration.traceId).toBe(dbTrace[0]?.id);
|
||||
expect(dbGeneration.name).toBe(generationName);
|
||||
expect(dbGeneration.startTime).toEqual(
|
||||
new Date("2021-01-01T00:00:00.000Z"),
|
||||
);
|
||||
expect(dbGeneration.endTime).toEqual(new Date("2021-01-01T00:00:00.000Z"));
|
||||
expect(dbGeneration.model).toBe("model-name");
|
||||
expect(dbGeneration.modelParameters).toEqual({ key: "value" });
|
||||
expect(dbGeneration.input).toEqual({ key: "value" });
|
||||
expect(dbGeneration.metadata).toEqual({ key: "value" });
|
||||
expect(dbGeneration.version).toBe("2.0.0");
|
||||
});
|
||||
|
||||
it("should update generation", async () => {
|
||||
const generationName = uuidv4();
|
||||
|
||||
const generationId = uuidv4();
|
||||
const createGeneration = await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Body,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
id: generationId,
|
||||
name: generationName,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
model: "model-name",
|
||||
modelParameters: { key: "value" },
|
||||
prompt: { key: "value" },
|
||||
metadata: { key: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
expect(createGeneration.status).toBe(200);
|
||||
|
||||
const updateGeneration = await makeZodVerifiedAPICall(
|
||||
PatchGenerationsV1Response,
|
||||
"PATCH",
|
||||
"/api/public/generations",
|
||||
{
|
||||
generationId: generationId,
|
||||
completion: "this is a great gpt response",
|
||||
},
|
||||
);
|
||||
expect(updateGeneration.status).toBe(200);
|
||||
|
||||
const dbGeneration = await prisma.observation.findUnique({
|
||||
where: {
|
||||
id: generationId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbGeneration?.id).toBe(generationId);
|
||||
expect(dbGeneration?.name).toBe(generationName);
|
||||
expect(dbGeneration?.startTime).toEqual(
|
||||
new Date("2021-01-01T00:00:00.000Z"),
|
||||
);
|
||||
expect(dbGeneration?.endTime).toEqual(new Date("2021-01-01T00:00:00.000Z"));
|
||||
expect(dbGeneration?.model).toBe("model-name");
|
||||
expect(dbGeneration?.modelParameters).toEqual({ key: "value" });
|
||||
expect(dbGeneration?.input).toEqual({ key: "value" });
|
||||
expect(dbGeneration?.output).toEqual("this is a great gpt response");
|
||||
expect(dbGeneration?.metadata).toEqual({ key: "value" });
|
||||
expect(dbGeneration?.version).toBe("2.0.0");
|
||||
});
|
||||
|
||||
it("should accept objects as i/o", async () => {
|
||||
const generationName = uuidv4();
|
||||
|
||||
const generationId = uuidv4();
|
||||
const createGeneration = await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Body,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
id: generationId,
|
||||
name: generationName,
|
||||
prompt: { key: "value" },
|
||||
completion: [
|
||||
{
|
||||
foo: "bar",
|
||||
},
|
||||
],
|
||||
metadata: [
|
||||
{
|
||||
tags: ["example tag", "second tag"],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(createGeneration.status).toBe(200);
|
||||
|
||||
const dbGeneration = await prisma.observation.findUnique({
|
||||
where: {
|
||||
id: generationId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbGeneration?.id).toBe(generationId);
|
||||
expect(dbGeneration?.name).toBe(generationName);
|
||||
expect(dbGeneration?.input).toEqual({ key: "value" });
|
||||
expect(dbGeneration?.output).toEqual([
|
||||
{
|
||||
foo: "bar",
|
||||
},
|
||||
]);
|
||||
expect(dbGeneration?.metadata).toEqual([
|
||||
{
|
||||
tags: ["example tag", "second tag"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should not succeed update if generation does not exist", async () => {
|
||||
const generationId = uuidv4();
|
||||
|
||||
const updateGeneration = await makeAPICall(
|
||||
"PATCH",
|
||||
"/api/public/generations",
|
||||
{
|
||||
generationId: generationId,
|
||||
completion: "this is a great gpt response",
|
||||
},
|
||||
);
|
||||
expect(updateGeneration.status).toBe(404);
|
||||
|
||||
const dbGeneration = await prisma.observation.findUnique({
|
||||
where: {
|
||||
id: generationId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbGeneration).toBeNull();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,198 +0,0 @@
|
||||
import {
|
||||
makeZodVerifiedAPICall,
|
||||
pruneDatabase,
|
||||
} from "@/src/__tests__/test-utils";
|
||||
import { PostGenerationsV1Response } from "@/src/features/public-api/types/generations";
|
||||
import { GetMetricsDailyV1Response } from "@/src/features/public-api/types/metrics";
|
||||
import { PostTracesV1Response } from "@/src/features/public-api/types/traces";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
describe("/api/public/metrics/daily API Endpoint", () => {
|
||||
beforeEach(async () => await pruneDatabase());
|
||||
afterEach(async () => await pruneDatabase());
|
||||
|
||||
it("should handle daily metrics correctly", async () => {
|
||||
// Create traces with observations on different days
|
||||
const traceId1 = uuidv4();
|
||||
const traceId2 = uuidv4();
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: traceId1,
|
||||
timestamp: "2021-01-01T00:00:00.000Z",
|
||||
name: "trace-day-1",
|
||||
userId: "user-daily-metrics",
|
||||
projectId: "project-daily-metrics",
|
||||
},
|
||||
);
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: traceId2,
|
||||
timestamp: "2021-01-02T00:00:00.000Z",
|
||||
name: "trace-day-2",
|
||||
userId: "user-daily-metrics",
|
||||
projectId: "project-daily-metrics",
|
||||
},
|
||||
);
|
||||
|
||||
// Simulate observations with usage metrics on different days
|
||||
await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Response,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
traceId: traceId1,
|
||||
model: "modelA",
|
||||
usage: { input: 100, output: 200, total: 300 },
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:01:00.000Z",
|
||||
},
|
||||
);
|
||||
await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Response,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
traceId: traceId2,
|
||||
model: "modelB",
|
||||
usage: { input: 333 },
|
||||
startTime: "2021-01-02T00:00:00.000Z",
|
||||
endTime: "2021-01-02T00:02:00.000Z",
|
||||
},
|
||||
);
|
||||
await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Response,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
traceId: traceId2,
|
||||
model: "modelC",
|
||||
usage: { input: 666, output: 777, totalCost: 1024.22 },
|
||||
startTime: "2021-01-02T00:00:00.000Z",
|
||||
endTime: "2021-01-02T00:04:00.000Z",
|
||||
},
|
||||
);
|
||||
await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Response,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
traceId: traceId2,
|
||||
usage: { output: 300 },
|
||||
startTime: "2021-01-02T00:00:00.000Z",
|
||||
endTime: "2021-01-02T00:04:00.000Z",
|
||||
},
|
||||
);
|
||||
|
||||
// Retrieve the daily metrics
|
||||
const dailyMetricsResponse = await makeZodVerifiedAPICall(
|
||||
GetMetricsDailyV1Response,
|
||||
"GET",
|
||||
`/api/public/metrics/daily`,
|
||||
);
|
||||
const dailyMetricsData = dailyMetricsResponse.body.data;
|
||||
|
||||
// Check if the daily metrics are calculated correctly
|
||||
expect(dailyMetricsData).toHaveLength(2); // Two days of data
|
||||
if (!dailyMetricsData[0])
|
||||
throw new Error("dailyMetricsData[0] is undefined");
|
||||
expect(dailyMetricsData[0].date).toBe("2021-01-02"); // Latest date first
|
||||
expect(dailyMetricsData[0].countTraces).toBe(1);
|
||||
expect(dailyMetricsData[0].totalCost).toEqual(1024.22);
|
||||
expect(dailyMetricsData[0].usage).toEqual([
|
||||
{
|
||||
model: "modelB",
|
||||
inputUsage: 333,
|
||||
outputUsage: 0,
|
||||
totalUsage: 333,
|
||||
countObservations: 1,
|
||||
countTraces: 1,
|
||||
totalCost: 0,
|
||||
},
|
||||
{
|
||||
model: "modelC",
|
||||
inputUsage: 666,
|
||||
outputUsage: 777,
|
||||
totalUsage: 1443,
|
||||
countObservations: 1,
|
||||
countTraces: 1,
|
||||
totalCost: 1024.22,
|
||||
},
|
||||
{
|
||||
model: null,
|
||||
countObservations: 1,
|
||||
countTraces: 1,
|
||||
inputUsage: 0,
|
||||
outputUsage: 300,
|
||||
totalCost: 0,
|
||||
totalUsage: 300,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!dailyMetricsData[1])
|
||||
throw new Error("dailyMetricsData[1] is undefined");
|
||||
expect(dailyMetricsData[1].date).toBe("2021-01-01");
|
||||
expect(dailyMetricsData[1].countTraces).toBe(1);
|
||||
expect(dailyMetricsData[1].totalCost).toEqual(0);
|
||||
expect(dailyMetricsData[1].usage).toEqual([
|
||||
{
|
||||
model: "modelA",
|
||||
inputUsage: 100,
|
||||
outputUsage: 200,
|
||||
totalUsage: 300,
|
||||
countObservations: 1,
|
||||
countTraces: 1,
|
||||
totalCost: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle daily metrics correctly when there is no data", async () => {
|
||||
// Retrieve the daily metrics
|
||||
const dailyMetricsResponse = await makeZodVerifiedAPICall(
|
||||
GetMetricsDailyV1Response,
|
||||
"GET",
|
||||
`/api/public/metrics/daily`,
|
||||
);
|
||||
const dailyMetricsData = dailyMetricsResponse.body.data;
|
||||
|
||||
// Check if the daily metrics are calculated correctly
|
||||
expect(dailyMetricsData).toHaveLength(0); // No data
|
||||
});
|
||||
|
||||
it("should handle daily metrics correctly when there is just a trace", async () => {
|
||||
const traceId1 = uuidv4();
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: traceId1,
|
||||
timestamp: "2021-01-01T00:00:00.000Z",
|
||||
name: "trace-day-1",
|
||||
userId: "user-daily-metrics",
|
||||
projectId: "project-daily-metrics",
|
||||
},
|
||||
);
|
||||
|
||||
// Retrieve the daily metrics
|
||||
const dailyMetricsResponse = await makeZodVerifiedAPICall(
|
||||
GetMetricsDailyV1Response,
|
||||
"GET",
|
||||
`/api/public/metrics/daily`,
|
||||
);
|
||||
const dailyMetricsData = dailyMetricsResponse.body.data;
|
||||
|
||||
// Check if the daily metrics are calculated correctly
|
||||
expect(dailyMetricsData).toHaveLength(1);
|
||||
expect(dailyMetricsData[0].date).toBe("2021-01-01");
|
||||
expect(dailyMetricsData[0].countTraces).toBe(1);
|
||||
expect(dailyMetricsData[0].totalCost).toEqual(0);
|
||||
expect(dailyMetricsData[0].usage).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
PromptType,
|
||||
type LegacyValidatedPrompt,
|
||||
} from "@/src/features/prompts/server/utils/validation";
|
||||
import { getObservationById } from "@langfuse/shared/src/server";
|
||||
|
||||
describe("/api/public/prompts API Endpoint", () => {
|
||||
beforeEach(async () => await pruneDatabase());
|
||||
@@ -411,6 +412,7 @@ describe("/api/public/prompts API Endpoint", () => {
|
||||
const generationId = v4();
|
||||
|
||||
const promptId = uuidv4();
|
||||
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
|
||||
|
||||
await prisma.prompt.create({
|
||||
data: {
|
||||
@@ -420,7 +422,7 @@ describe("/api/public/prompts API Endpoint", () => {
|
||||
labels: ["production"],
|
||||
version: 1,
|
||||
project: {
|
||||
connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" },
|
||||
connect: { id: projectId },
|
||||
},
|
||||
createdBy: "user-1",
|
||||
},
|
||||
@@ -459,11 +461,10 @@ describe("/api/public/prompts API Endpoint", () => {
|
||||
|
||||
expect(response.status).toBe(207);
|
||||
|
||||
const dbGeneration = await prisma.observation.findUnique({
|
||||
where: {
|
||||
id: generationId,
|
||||
},
|
||||
});
|
||||
// Delay to allow for async processing
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const dbGeneration = await getObservationById(generationId, projectId);
|
||||
|
||||
expect(dbGeneration?.id).toBe(generationId);
|
||||
expect(dbGeneration?.promptId).toBe(promptId);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { nanoid } from "ai";
|
||||
|
||||
import { type PromptsMetaResponse } from "@/src/features/prompts/server/actions/getPromptsMeta";
|
||||
import { getObservationById } from "@langfuse/shared/src/server";
|
||||
|
||||
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
|
||||
const baseURI = "/api/public/v2/prompts";
|
||||
@@ -395,11 +396,10 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
|
||||
expect(response.status).toBe(207);
|
||||
|
||||
const dbGeneration = await prisma.observation.findUnique({
|
||||
where: {
|
||||
id: generationId,
|
||||
},
|
||||
});
|
||||
// Delay to allow for async processing
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const dbGeneration = await getObservationById(generationId, projectId);
|
||||
|
||||
expect(dbGeneration?.id).toBe(generationId);
|
||||
expect(dbGeneration?.promptId).toBe(promptId);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { RateLimitService } from "@/src/features/public-api/server/RateLimitService";
|
||||
import {
|
||||
createHttpHeaderFromRateLimit,
|
||||
RateLimitService,
|
||||
} from "@/src/features/public-api/server/RateLimitService";
|
||||
import { Redis } from "ioredis";
|
||||
|
||||
describe("RateLimitService", () => {
|
||||
@@ -30,6 +33,33 @@ describe("RateLimitService", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("should create correct ratelimit headers", () => {
|
||||
const rateLimitRes = {
|
||||
points: 1000,
|
||||
remainingPoints: 999,
|
||||
msBeforeNext: 1000,
|
||||
resource: "public-api" as const,
|
||||
scope: {
|
||||
orgId: orgId,
|
||||
plan: "cloud:hobby" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [],
|
||||
},
|
||||
consumedPoints: 1,
|
||||
isFirstInDuration: true,
|
||||
};
|
||||
|
||||
const headers = createHttpHeaderFromRateLimit(rateLimitRes);
|
||||
|
||||
expect(headers).toEqual({
|
||||
"Retry-After": 1,
|
||||
"X-RateLimit-Limit": 1000,
|
||||
"X-RateLimit-Remaining": 999,
|
||||
"X-RateLimit-Reset": expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("should rate limit", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
|
||||
@@ -1,791 +0,0 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import {
|
||||
makeAPICall,
|
||||
makeZodVerifiedAPICall,
|
||||
pruneDatabase,
|
||||
} from "@/src/__tests__/test-utils";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { DeleteScoreResponse, GetScoreResponse } from "@langfuse/shared";
|
||||
import { PostTracesV1Response } from "@/src/features/public-api/types/traces";
|
||||
|
||||
const traceId = "de98afa2-89dc-47e9-9924-33f1490fdaf4";
|
||||
|
||||
describe("/api/public/scores API Endpoint", () => {
|
||||
let should_prune_db = true;
|
||||
beforeEach(async () => {
|
||||
if (should_prune_db) await pruneDatabase();
|
||||
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
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",
|
||||
},
|
||||
);
|
||||
}, 10000);
|
||||
afterEach(async () => {
|
||||
if (should_prune_db) await pruneDatabase();
|
||||
});
|
||||
|
||||
it("should create score for a trace", async () => {
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
id: traceId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBeGreaterThan(0);
|
||||
expect(dbTrace[0]?.id).toBe(traceId);
|
||||
|
||||
const scoreId = uuidv4();
|
||||
const createScore = await makeAPICall("POST", "/api/public/scores", {
|
||||
id: scoreId,
|
||||
name: "score-name",
|
||||
value: 100.5,
|
||||
traceId: traceId,
|
||||
comment: "comment",
|
||||
});
|
||||
|
||||
expect(createScore.status).toBe(200);
|
||||
const fetchedScore = await makeZodVerifiedAPICall(
|
||||
GetScoreResponse,
|
||||
"GET",
|
||||
`/api/public/scores/${scoreId}`,
|
||||
);
|
||||
|
||||
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(
|
||||
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
);
|
||||
});
|
||||
|
||||
it("should create score for a trace with int", async () => {
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
id: traceId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBeGreaterThan(0);
|
||||
expect(dbTrace[0]?.id).toBe(traceId);
|
||||
|
||||
const scoreId = uuidv4();
|
||||
const createScore = await makeAPICall("POST", "/api/public/scores", {
|
||||
id: scoreId,
|
||||
name: "score-name",
|
||||
value: 100,
|
||||
traceId: traceId,
|
||||
});
|
||||
|
||||
expect(createScore.status).toBe(200);
|
||||
const fetchedScore = await makeZodVerifiedAPICall(
|
||||
GetScoreResponse,
|
||||
"GET",
|
||||
`/api/public/scores/${scoreId}`,
|
||||
);
|
||||
|
||||
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);
|
||||
expect(fetchedScore.body?.observationId).toBeNull();
|
||||
});
|
||||
|
||||
it("should create score for a generation", async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
const generationId = uuidv4();
|
||||
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
id: generationId,
|
||||
name: "generation-name",
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
model: "model-name",
|
||||
modelParameters: { key: "value" },
|
||||
prompt: { key: "value" },
|
||||
metadata: { key: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
const dbGeneration = await prisma.observation.findMany({
|
||||
where: {
|
||||
id: generationId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbGeneration.length).toBeGreaterThan(0);
|
||||
expect(dbGeneration[0]?.id).toBe(generationId);
|
||||
|
||||
const scoreId = uuidv4();
|
||||
const scoreData = {
|
||||
id: scoreId,
|
||||
name: "score-name",
|
||||
value: 100,
|
||||
traceId: dbGeneration[0]!.traceId!,
|
||||
observationId: dbGeneration[0]!.id,
|
||||
};
|
||||
const createScore = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/scores",
|
||||
scoreData,
|
||||
);
|
||||
|
||||
expect(createScore.status).toBe(200);
|
||||
const fetchedScore = await makeZodVerifiedAPICall(
|
||||
GetScoreResponse,
|
||||
"GET",
|
||||
`/api/public/scores/${scoreId}`,
|
||||
);
|
||||
|
||||
expect(fetchedScore.body).toMatchObject(scoreData);
|
||||
});
|
||||
|
||||
it("should create numeric score if value is integer and no data type is passed", async () => {
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
id: traceId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBeGreaterThan(0);
|
||||
expect(dbTrace[0]?.id).toBe(traceId);
|
||||
|
||||
const scoreId = uuidv4();
|
||||
const scoreData = {
|
||||
id: scoreId,
|
||||
name: "accuracy",
|
||||
value: 1,
|
||||
traceId,
|
||||
};
|
||||
const createScore = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/scores",
|
||||
scoreData,
|
||||
);
|
||||
|
||||
expect(createScore.status).toBe(200);
|
||||
const fetchedScore = await makeZodVerifiedAPICall(
|
||||
GetScoreResponse,
|
||||
"GET",
|
||||
`/api/public/scores/${scoreId}`,
|
||||
);
|
||||
|
||||
expect(fetchedScore.body).toMatchObject({
|
||||
...scoreData,
|
||||
dataType: "NUMERIC",
|
||||
});
|
||||
});
|
||||
|
||||
it("should create categorical score if value is string and no data type is passed", async () => {
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
id: traceId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBeGreaterThan(0);
|
||||
expect(dbTrace[0]?.id).toBe(traceId);
|
||||
|
||||
const scoreId = uuidv4();
|
||||
const scoreData = {
|
||||
id: scoreId,
|
||||
name: "accuracy",
|
||||
value: "Good",
|
||||
traceId,
|
||||
};
|
||||
const createScore = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/scores",
|
||||
scoreData,
|
||||
);
|
||||
|
||||
expect(createScore.status).toBe(200);
|
||||
const fetchedScore = await makeZodVerifiedAPICall(
|
||||
GetScoreResponse,
|
||||
"GET",
|
||||
`/api/public/scores/${scoreId}`,
|
||||
);
|
||||
|
||||
expect(fetchedScore.body).toMatchObject({
|
||||
...scoreData,
|
||||
value: null,
|
||||
stringValue: "Good",
|
||||
dataType: "CATEGORICAL",
|
||||
});
|
||||
});
|
||||
|
||||
it("should create boolean score if boolean data type is passed", async () => {
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
id: traceId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBeGreaterThan(0);
|
||||
expect(dbTrace[0]?.id).toBe(traceId);
|
||||
|
||||
const scoreId = uuidv4();
|
||||
const scoreData = {
|
||||
id: scoreId,
|
||||
name: "score-name",
|
||||
value: 1,
|
||||
dataType: "BOOLEAN",
|
||||
traceId,
|
||||
};
|
||||
const createScore = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/scores",
|
||||
scoreData,
|
||||
);
|
||||
|
||||
expect(createScore.status).toBe(200);
|
||||
|
||||
const fetchedScore = await makeZodVerifiedAPICall(
|
||||
GetScoreResponse,
|
||||
"GET",
|
||||
`/api/public/scores/${scoreId}`,
|
||||
);
|
||||
|
||||
expect(fetchedScore.body).toMatchObject({
|
||||
...scoreData,
|
||||
stringValue: "True",
|
||||
});
|
||||
});
|
||||
|
||||
it("should infer boolean data type from boolean score config", async () => {
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
id: traceId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBeGreaterThan(0);
|
||||
expect(dbTrace[0]?.id).toBe(traceId);
|
||||
|
||||
await makeAPICall("POST", "/api/public/score-configs", {
|
||||
name: "accuracy",
|
||||
dataType: "BOOLEAN",
|
||||
});
|
||||
|
||||
const dbScoreConfig = await prisma.scoreConfig.findMany({
|
||||
where: {
|
||||
name: "accuracy",
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbScoreConfig.length).toBeGreaterThan(0);
|
||||
expect(dbScoreConfig[0]?.name).toBe("accuracy");
|
||||
|
||||
const scoreId = uuidv4();
|
||||
const scoreData = {
|
||||
id: scoreId,
|
||||
name: "accuracy",
|
||||
value: 1,
|
||||
configId: dbScoreConfig[0].id,
|
||||
traceId,
|
||||
};
|
||||
const createScore = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/scores",
|
||||
scoreData,
|
||||
);
|
||||
|
||||
expect(createScore.status).toBe(200);
|
||||
const fetchedScore = await makeZodVerifiedAPICall(
|
||||
GetScoreResponse,
|
||||
"GET",
|
||||
`/api/public/scores/${scoreId}`,
|
||||
);
|
||||
|
||||
expect(fetchedScore.body).toMatchObject({
|
||||
...scoreData,
|
||||
stringValue: "True",
|
||||
dataType: "BOOLEAN",
|
||||
});
|
||||
});
|
||||
|
||||
it("should NOT create categorical score if numeric data type is passed", async () => {
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
id: traceId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBeGreaterThan(0);
|
||||
expect(dbTrace[0]?.id).toBe(traceId);
|
||||
|
||||
const scoreId = uuidv4();
|
||||
const scoreData = {
|
||||
id: scoreId,
|
||||
name: "accuracy",
|
||||
value: 1,
|
||||
dataType: "CATEGORICAL",
|
||||
traceId,
|
||||
};
|
||||
const createScore = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/scores",
|
||||
scoreData,
|
||||
);
|
||||
|
||||
expect(createScore.status).toBe(400);
|
||||
expect(createScore.body).toMatchObject({
|
||||
message: "Invalid request data",
|
||||
error: [
|
||||
{
|
||||
code: "invalid_type",
|
||||
expected: "string",
|
||||
message: "Expected string, received number",
|
||||
path: ["value"],
|
||||
received: "number",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should NOT create numeric score if categorical data type is passed incl numeric config", async () => {
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
id: traceId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBeGreaterThan(0);
|
||||
expect(dbTrace[0]?.id).toBe(traceId);
|
||||
|
||||
await makeAPICall("POST", "/api/public/score-configs", {
|
||||
name: "accuracy",
|
||||
dataType: "NUMERIC",
|
||||
});
|
||||
|
||||
const dbScoreConfig = await prisma.scoreConfig.findMany({
|
||||
where: {
|
||||
name: "accuracy",
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbScoreConfig.length).toBeGreaterThan(0);
|
||||
expect(dbScoreConfig[0]?.name).toBe("accuracy");
|
||||
|
||||
const scoreId = uuidv4();
|
||||
const scoreData = {
|
||||
id: scoreId,
|
||||
name: "accuracy",
|
||||
value: "Good",
|
||||
configId: dbScoreConfig[0].id,
|
||||
dataType: "CATEGORICAL",
|
||||
traceId,
|
||||
};
|
||||
const createScore = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/scores",
|
||||
scoreData,
|
||||
);
|
||||
|
||||
expect(createScore.status).toBe(400);
|
||||
expect(createScore.body).toMatchObject({
|
||||
message:
|
||||
"Data type mismatch based on config: expected NUMERIC, got CATEGORICAL",
|
||||
});
|
||||
});
|
||||
|
||||
it("should NOT create numeric score if config and passed data type mismatch", async () => {
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
id: traceId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBeGreaterThan(0);
|
||||
expect(dbTrace[0]?.id).toBe(traceId);
|
||||
|
||||
await makeAPICall("POST", "/api/public/score-configs", {
|
||||
name: "accuracy",
|
||||
dataType: "CATEGORICAL",
|
||||
categories: [
|
||||
{ label: "One", value: 1 },
|
||||
{ label: "Zero", value: 0 },
|
||||
],
|
||||
});
|
||||
|
||||
const dbScoreConfig = await prisma.scoreConfig.findMany({
|
||||
where: {
|
||||
name: "accuracy",
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbScoreConfig.length).toBeGreaterThan(0);
|
||||
expect(dbScoreConfig[0]?.name).toBe("accuracy");
|
||||
|
||||
const scoreId = uuidv4();
|
||||
const scoreData = {
|
||||
id: scoreId,
|
||||
name: "accuracy",
|
||||
value: 1,
|
||||
configId: dbScoreConfig[0].id,
|
||||
dataType: "NUMERIC",
|
||||
traceId,
|
||||
};
|
||||
const createScore = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/scores",
|
||||
scoreData,
|
||||
);
|
||||
|
||||
expect(createScore.status).toBe(400);
|
||||
expect(createScore.body).toMatchObject({
|
||||
message:
|
||||
"Data type mismatch based on config: expected CATEGORICAL, got NUMERIC",
|
||||
});
|
||||
});
|
||||
|
||||
it("should NOT create boolean score if string value is passed", async () => {
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
id: traceId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBeGreaterThan(0);
|
||||
expect(dbTrace[0]?.id).toBe(traceId);
|
||||
|
||||
await makeAPICall("POST", "/api/public/score-configs", {
|
||||
name: "accuracy",
|
||||
dataType: "BOOLEAN",
|
||||
});
|
||||
|
||||
const dbScoreConfig = await prisma.scoreConfig.findMany({
|
||||
where: {
|
||||
name: "accuracy",
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbScoreConfig.length).toBeGreaterThan(0);
|
||||
expect(dbScoreConfig[0]?.name).toBe("accuracy");
|
||||
|
||||
const scoreId = uuidv4();
|
||||
const scoreData = {
|
||||
id: scoreId,
|
||||
name: "accuracy",
|
||||
value: "True",
|
||||
configId: dbScoreConfig[0].id,
|
||||
dataType: "BOOLEAN",
|
||||
traceId,
|
||||
};
|
||||
const createScore = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/scores",
|
||||
scoreData,
|
||||
);
|
||||
|
||||
expect(createScore.status).toBe(400);
|
||||
expect(createScore.body).toMatchObject({
|
||||
message: "Invalid request data",
|
||||
error: [
|
||||
{
|
||||
code: "invalid_type",
|
||||
expected: "number",
|
||||
message: "Expected number, received string",
|
||||
path: ["value"],
|
||||
received: "string",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should NOT create boolean score with value other than 1 | 0", async () => {
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
id: traceId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBeGreaterThan(0);
|
||||
expect(dbTrace[0]?.id).toBe(traceId);
|
||||
|
||||
await makeAPICall("POST", "/api/public/score-configs", {
|
||||
name: "accuracy",
|
||||
dataType: "BOOLEAN",
|
||||
});
|
||||
|
||||
const dbScoreConfig = await prisma.scoreConfig.findMany({
|
||||
where: {
|
||||
name: "accuracy",
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbScoreConfig.length).toBeGreaterThan(0);
|
||||
expect(dbScoreConfig[0]?.name).toBe("accuracy");
|
||||
|
||||
const scoreId = uuidv4();
|
||||
const scoreData = {
|
||||
id: scoreId,
|
||||
name: "accuracy",
|
||||
value: 0.5,
|
||||
configId: dbScoreConfig[0].id,
|
||||
dataType: "BOOLEAN",
|
||||
traceId,
|
||||
};
|
||||
const createScore = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/scores",
|
||||
scoreData,
|
||||
);
|
||||
|
||||
expect(createScore.status).toBe(400);
|
||||
expect(createScore.body).toMatchObject({
|
||||
message: "Invalid request data",
|
||||
error: [
|
||||
{
|
||||
code: "custom",
|
||||
message:
|
||||
"Value must be a number equal to either 0 or 1 for data type BOOLEAN",
|
||||
path: ["value"],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should NOT create categorical score with value not defined in config.categories", async () => {
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
id: traceId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBeGreaterThan(0);
|
||||
expect(dbTrace[0]?.id).toBe(traceId);
|
||||
|
||||
await makeAPICall("POST", "/api/public/score-configs", {
|
||||
name: "accuracy",
|
||||
dataType: "CATEGORICAL",
|
||||
categories: [
|
||||
{ label: "One", value: 1 },
|
||||
{ label: "Zero", value: 0 },
|
||||
],
|
||||
});
|
||||
|
||||
const dbScoreConfig = await prisma.scoreConfig.findMany({
|
||||
where: {
|
||||
name: "accuracy",
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbScoreConfig.length).toBeGreaterThan(0);
|
||||
expect(dbScoreConfig[0]?.name).toBe("accuracy");
|
||||
|
||||
const scoreId = uuidv4();
|
||||
const scoreData = {
|
||||
id: scoreId,
|
||||
name: "accuracy",
|
||||
value: 1,
|
||||
configId: dbScoreConfig[0].id,
|
||||
dataType: "CATEGORICAL",
|
||||
traceId,
|
||||
};
|
||||
const createScore = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/scores",
|
||||
scoreData,
|
||||
);
|
||||
|
||||
expect(createScore.status).toBe(400);
|
||||
expect(createScore.body).toMatchObject({
|
||||
message: "Invalid request data",
|
||||
error: [
|
||||
{
|
||||
code: "invalid_type",
|
||||
expected: "string",
|
||||
message: "Expected string, received number",
|
||||
path: ["value"],
|
||||
received: "number",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should NOT create numeric score outside of defined range", async () => {
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
id: traceId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBeGreaterThan(0);
|
||||
expect(dbTrace[0]?.id).toBe(traceId);
|
||||
|
||||
await makeAPICall("POST", "/api/public/score-configs", {
|
||||
name: "accuracy",
|
||||
dataType: "NUMERIC",
|
||||
maxValue: 0,
|
||||
});
|
||||
|
||||
const dbScoreConfig = await prisma.scoreConfig.findMany({
|
||||
where: {
|
||||
name: "accuracy",
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbScoreConfig.length).toBeGreaterThan(0);
|
||||
expect(dbScoreConfig[0]?.name).toBe("accuracy");
|
||||
|
||||
const scoreId = uuidv4();
|
||||
const scoreData = {
|
||||
id: scoreId,
|
||||
name: "accuracy",
|
||||
value: 0.5,
|
||||
configId: dbScoreConfig[0].id,
|
||||
dataType: "NUMERIC",
|
||||
traceId,
|
||||
};
|
||||
const createScore = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/scores",
|
||||
scoreData,
|
||||
);
|
||||
|
||||
expect(createScore.status).toBe(400);
|
||||
expect(createScore.body).toMatchObject({
|
||||
message:
|
||||
"Ingested score body not valid against provided config: - Value exceeds maximum value of 0 defined in config",
|
||||
});
|
||||
});
|
||||
|
||||
it("should upsert a score", async () => {
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
id: traceId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBeGreaterThan(0);
|
||||
expect(dbTrace[0]?.id).toBe(traceId);
|
||||
|
||||
const generationId = uuidv4();
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
id: generationId,
|
||||
name: "generation-name",
|
||||
traceId,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
model: "model-name",
|
||||
modelParameters: { key: "value" },
|
||||
prompt: { key: "value" },
|
||||
metadata: { key: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
const dbGeneration = await prisma.observation.findMany({
|
||||
where: {
|
||||
id: generationId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbGeneration.length).toBeGreaterThan(0);
|
||||
expect(dbGeneration[0]?.id).toBe(generationId);
|
||||
|
||||
const scoreId = uuidv4();
|
||||
const createScore = await makeAPICall("POST", "/api/public/scores", {
|
||||
id: scoreId,
|
||||
name: "score-name",
|
||||
value: 100,
|
||||
traceId: traceId,
|
||||
comment: "comment",
|
||||
});
|
||||
expect(createScore.status).toBe(200);
|
||||
|
||||
const upsertScore = await makeAPICall("POST", "/api/public/scores", {
|
||||
id: scoreId,
|
||||
traceId: traceId,
|
||||
name: "score-name-updated",
|
||||
value: 200,
|
||||
comment: "comment-updated",
|
||||
observationId: dbGeneration[0]!.id,
|
||||
});
|
||||
expect(upsertScore.status).toBe(200);
|
||||
|
||||
const fetchedScore = await makeZodVerifiedAPICall(
|
||||
GetScoreResponse,
|
||||
"GET",
|
||||
`/api/public/scores/${scoreId}`,
|
||||
);
|
||||
|
||||
expect(fetchedScore.body?.id).toBe(scoreId);
|
||||
expect(fetchedScore.body?.traceId).toBe(traceId);
|
||||
expect(fetchedScore.body?.name).toBe("score-name-updated");
|
||||
expect(fetchedScore.body?.value).toBe(200);
|
||||
expect(fetchedScore.body?.comment).toBe("comment-updated");
|
||||
expect(fetchedScore.body?.observationId).toBe(dbGeneration[0]!.id);
|
||||
});
|
||||
|
||||
it("should delete a score", async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
const traceId = uuidv4();
|
||||
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: traceId,
|
||||
},
|
||||
);
|
||||
|
||||
const scoreId = uuidv4();
|
||||
const createScore = await makeAPICall("POST", "/api/public/scores", {
|
||||
id: scoreId,
|
||||
name: "score-name",
|
||||
value: 100.5,
|
||||
traceId: traceId,
|
||||
comment: "comment",
|
||||
});
|
||||
|
||||
expect(createScore.status).toBe(200);
|
||||
const fetchedScore = await makeZodVerifiedAPICall(
|
||||
GetScoreResponse,
|
||||
"GET",
|
||||
`/api/public/scores/${scoreId}`,
|
||||
);
|
||||
expect(fetchedScore.body.id).toBe(scoreId);
|
||||
|
||||
const deleteScore = await makeZodVerifiedAPICall(
|
||||
DeleteScoreResponse,
|
||||
"DELETE",
|
||||
`/api/public/scores/${scoreId}`,
|
||||
);
|
||||
expect(deleteScore.status).toBe(200);
|
||||
const deletedScore = await prisma.score.findUnique({
|
||||
where: {
|
||||
id: scoreId,
|
||||
},
|
||||
});
|
||||
expect(deletedScore).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
/** @jest-environment node */
|
||||
import { pruneDatabase } from "@/src/__tests__/test-utils";
|
||||
import { appRouter } from "@/src/server/api/root";
|
||||
import { createInnerTRPCContext } from "@/src/server/api/trpc";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import type { Session } from "next-auth";
|
||||
|
||||
describe("Traces TRPC Router", () => {
|
||||
beforeEach(async () => await pruneDatabase());
|
||||
afterEach(async () => await pruneDatabase());
|
||||
|
||||
const session: Session = {
|
||||
expires: "1",
|
||||
user: {
|
||||
id: "clgb17vnp000008jjere5g15i",
|
||||
name: "John Doe",
|
||||
organizations: [
|
||||
{
|
||||
id: "seed-org-id",
|
||||
role: "OWNER",
|
||||
plan: "cloud:hobby",
|
||||
cloudConfig: undefined,
|
||||
projects: [
|
||||
{
|
||||
id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
role: "ADMIN",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
featureFlags: {
|
||||
templateFlag: true,
|
||||
},
|
||||
admin: true,
|
||||
},
|
||||
environment: {} as any,
|
||||
};
|
||||
|
||||
const ctx = createInnerTRPCContext({ session });
|
||||
const caller = appRouter.createCaller({ ...ctx, prisma });
|
||||
|
||||
test("traces.all RPC returns an array of traces", async () => {
|
||||
const trace = {
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
};
|
||||
await prisma.trace.create({
|
||||
data: { ...trace, projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" },
|
||||
});
|
||||
|
||||
const traces = await caller.traces.all({
|
||||
page: 0,
|
||||
limit: 10,
|
||||
// projectId from `seed.ts`
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
filter: null,
|
||||
searchQuery: "",
|
||||
orderBy: null,
|
||||
});
|
||||
expect(traces).toBeDefined();
|
||||
expect(traces).toMatchObject({ traces: [trace] });
|
||||
});
|
||||
|
||||
test("traces.all RPC must not return input, output, metadata", async () => {
|
||||
const trace = {
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
input: { a: 1 },
|
||||
output: { b: 2 },
|
||||
metadata: { c: 3 },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
};
|
||||
await prisma.trace.create({
|
||||
data: { ...trace, projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" },
|
||||
});
|
||||
|
||||
const traces = await caller.traces.all({
|
||||
page: 0,
|
||||
limit: 10,
|
||||
// projectId from `seed.ts`
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
filter: null,
|
||||
searchQuery: "",
|
||||
orderBy: null,
|
||||
});
|
||||
expect(traces.traces).toBeDefined();
|
||||
expect(traces.traces).toHaveLength(1);
|
||||
|
||||
const returnedTrace = traces.traces[0];
|
||||
expect(returnedTrace).not.toHaveProperty("input");
|
||||
expect(returnedTrace).not.toHaveProperty("output");
|
||||
expect(returnedTrace).not.toHaveProperty("metadata");
|
||||
});
|
||||
|
||||
test("traces.all RPC orders traces by userId", async () => {
|
||||
const traceTmpl = {
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
};
|
||||
const trace1 = traceTmpl;
|
||||
const trace2 = { ...traceTmpl, userId: "user-2" };
|
||||
await prisma.trace.create({
|
||||
data: { ...traceTmpl, projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" },
|
||||
});
|
||||
await prisma.trace.create({
|
||||
data: {
|
||||
...trace2,
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
},
|
||||
});
|
||||
|
||||
const tracesASC = await caller.traces.all({
|
||||
page: 0,
|
||||
limit: 10,
|
||||
// projectId from `seed.ts`
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
filter: null,
|
||||
searchQuery: "",
|
||||
orderBy: {
|
||||
column: "userId",
|
||||
order: "ASC",
|
||||
},
|
||||
});
|
||||
expect(tracesASC).toMatchObject({ traces: [trace1, trace2] });
|
||||
|
||||
const tracesDESC = await caller.traces.all({
|
||||
page: 0,
|
||||
limit: 10,
|
||||
// projectId from `seed.ts`
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
filter: null,
|
||||
searchQuery: "",
|
||||
orderBy: {
|
||||
column: "userId",
|
||||
order: "DESC",
|
||||
},
|
||||
});
|
||||
expect(tracesDESC).toMatchObject({ traces: [trace2, trace1] });
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,9 @@ describe("Create and get sessions", () => {
|
||||
},
|
||||
);
|
||||
|
||||
// Delay to allow for async processing
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const dbSession = await prisma.traceSession.findFirst({
|
||||
where: {
|
||||
id: "session-id",
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import {
|
||||
makeAPICall,
|
||||
makeZodVerifiedAPICall,
|
||||
pruneDatabase,
|
||||
} from "@/src/__tests__/test-utils";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { PostTracesV1Response } from "@/src/features/public-api/types/traces";
|
||||
import {
|
||||
PatchSpansV1Response,
|
||||
PostSpansV1Response,
|
||||
} from "@/src/features/public-api/types/spans";
|
||||
|
||||
describe("/api/public/spans API Endpoint", () => {
|
||||
beforeEach(async () => await pruneDatabase());
|
||||
afterEach(async () => await pruneDatabase());
|
||||
|
||||
it("should create span after trace", async () => {
|
||||
const traceId = uuidv4();
|
||||
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
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 dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
id: traceId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBeGreaterThan(0);
|
||||
expect(dbTrace[0]?.id).toBe(traceId);
|
||||
|
||||
const spanId = uuidv4();
|
||||
const createSpan = await makeZodVerifiedAPICall(
|
||||
PostSpansV1Response,
|
||||
"POST",
|
||||
"/api/public/spans",
|
||||
{
|
||||
id: spanId,
|
||||
traceId: traceId,
|
||||
name: "span-name",
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
expect(createSpan.status).toBe(200);
|
||||
const dbSpan = await prisma.observation.findUnique({
|
||||
where: {
|
||||
id: spanId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbSpan?.id).toBe(spanId);
|
||||
expect(dbSpan?.traceId).toBe(traceId);
|
||||
expect(dbSpan?.name).toBe("span-name");
|
||||
expect(dbSpan?.startTime).toEqual(new Date("2021-01-01T00:00:00.000Z"));
|
||||
expect(dbSpan?.endTime).toEqual(new Date("2021-01-01T00:00:00.000Z"));
|
||||
expect(dbSpan?.input).toEqual({ input: "value" });
|
||||
expect(dbSpan?.metadata).toEqual({ meta: "value" });
|
||||
expect(dbSpan?.version).toBe("2.0.0");
|
||||
});
|
||||
|
||||
it("should create span before trace", async () => {
|
||||
const traceId = uuidv4();
|
||||
const spanId = uuidv4();
|
||||
|
||||
const createSpan = await makeZodVerifiedAPICall(
|
||||
PostSpansV1Response,
|
||||
"POST",
|
||||
"/api/public/spans",
|
||||
{
|
||||
id: spanId,
|
||||
traceId: traceId,
|
||||
name: "span-name",
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
expect(createSpan.status).toBe(200);
|
||||
const dbSpan = await prisma.observation.findUnique({
|
||||
where: {
|
||||
id: spanId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbSpan?.id).toBe(spanId);
|
||||
expect(dbSpan?.traceId).toBe(traceId);
|
||||
expect(dbSpan?.name).toBe("span-name");
|
||||
expect(dbSpan?.startTime).toEqual(new Date("2021-01-01T00:00:00.000Z"));
|
||||
expect(dbSpan?.endTime).toEqual(new Date("2021-01-01T00:00:00.000Z"));
|
||||
expect(dbSpan?.input).toEqual({ input: "value" });
|
||||
expect(dbSpan?.metadata).toEqual({ meta: "value" });
|
||||
expect(dbSpan?.version).toBe("2.0.0");
|
||||
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
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 dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
id: traceId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBeGreaterThan(0);
|
||||
expect(dbTrace[0]?.id).toBe(traceId);
|
||||
});
|
||||
|
||||
it("should create span after trace ignoring externalId", async () => {
|
||||
const traceId = uuidv4();
|
||||
const spanId = uuidv4();
|
||||
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
externalId: uuidv4(),
|
||||
id: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
},
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const createSpan = await makeZodVerifiedAPICall(
|
||||
PostSpansV1Response,
|
||||
"POST",
|
||||
"/api/public/spans",
|
||||
{
|
||||
id: spanId,
|
||||
name: "spanName",
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
traceId: traceId,
|
||||
externalTraceIdType: "EXTERNAL",
|
||||
},
|
||||
);
|
||||
expect(createSpan.status).toBe(200);
|
||||
|
||||
const dbSpan = await prisma.observation.findFirstOrThrow({
|
||||
where: {
|
||||
name: "spanName",
|
||||
},
|
||||
});
|
||||
expect(dbSpan.id).toBe(spanId);
|
||||
expect(dbSpan.traceId).toBe(traceId);
|
||||
});
|
||||
|
||||
it("should create trace when creating span without existing trace", async () => {
|
||||
const spanName = uuidv4();
|
||||
|
||||
const spanId = uuidv4();
|
||||
const createSpan = await makeZodVerifiedAPICall(
|
||||
PostSpansV1Response,
|
||||
"POST",
|
||||
"/api/public/spans",
|
||||
{
|
||||
id: spanId,
|
||||
name: spanName,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
name: spanName,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBe(1);
|
||||
expect(dbTrace[0]?.name).toBe(spanName);
|
||||
|
||||
expect(createSpan.status).toBe(200);
|
||||
const dbSpan = await prisma.observation.findUnique({
|
||||
where: {
|
||||
id: spanId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbSpan?.id).toBe(spanId);
|
||||
expect(dbSpan?.traceId).toBe(dbTrace[0]?.id);
|
||||
expect(dbSpan?.name).toBe(spanName);
|
||||
expect(dbSpan?.startTime).toEqual(new Date("2021-01-01T00:00:00.000Z"));
|
||||
expect(dbSpan?.endTime).toEqual(new Date("2021-01-01T00:00:00.000Z"));
|
||||
expect(dbSpan?.input).toEqual({ input: "value" });
|
||||
expect(dbSpan?.metadata).toEqual({ meta: "value" });
|
||||
expect(dbSpan?.version).toBe("2.0.0");
|
||||
});
|
||||
|
||||
it("should create trace when creating span without existing trace with externalId", async () => {
|
||||
const spanName = uuidv4();
|
||||
|
||||
const spanId = uuidv4();
|
||||
const traceId = uuidv4();
|
||||
const createSpan = await makeZodVerifiedAPICall(
|
||||
PostSpansV1Response,
|
||||
"POST",
|
||||
"/api/public/spans",
|
||||
{
|
||||
id: spanId,
|
||||
traceIdType: "EXTERNAL",
|
||||
traceId: traceId,
|
||||
name: spanName,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
expect(createSpan.status).toBe(200);
|
||||
|
||||
const dbSpan = await prisma.observation.findMany({
|
||||
where: {
|
||||
name: spanName,
|
||||
},
|
||||
});
|
||||
expect(dbSpan.length).toBe(1);
|
||||
expect(dbSpan[0]?.name).toBe(spanName);
|
||||
expect(dbSpan[0]?.id).toBe(spanId);
|
||||
expect(dbSpan[0]?.traceId).toBe(traceId);
|
||||
|
||||
const dbTraces = await prisma.trace.findMany();
|
||||
expect(dbTraces.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should create trace when creating span without existing trace without traceId", async () => {
|
||||
const generationName = uuidv4();
|
||||
|
||||
const spanId = uuidv4();
|
||||
const createSpan = await makeZodVerifiedAPICall(
|
||||
PostSpansV1Response,
|
||||
"POST",
|
||||
"/api/public/spans",
|
||||
{
|
||||
id: spanId,
|
||||
name: generationName,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { key: "value" },
|
||||
metadata: { key: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
const dbSpan = await prisma.observation.findFirstOrThrow({
|
||||
where: {
|
||||
name: generationName,
|
||||
},
|
||||
});
|
||||
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
id: dbSpan.traceId!,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBe(1);
|
||||
expect(dbTrace[0]?.name).toBe(generationName);
|
||||
|
||||
expect(createSpan.status).toBe(200);
|
||||
|
||||
expect(dbSpan.id).toBe(spanId);
|
||||
expect(dbSpan.traceId).toBe(dbTrace[0]?.id);
|
||||
expect(dbSpan.name).toBe(generationName);
|
||||
expect(dbSpan.startTime).toEqual(new Date("2021-01-01T00:00:00.000Z"));
|
||||
expect(dbSpan.endTime).toEqual(new Date("2021-01-01T00:00:00.000Z"));
|
||||
expect(dbSpan.input).toEqual({ key: "value" });
|
||||
expect(dbSpan.metadata).toEqual({ key: "value" });
|
||||
expect(dbSpan.version).toBe("2.0.0");
|
||||
});
|
||||
|
||||
it("should update span", async () => {
|
||||
const spanName = uuidv4();
|
||||
|
||||
const spanId = uuidv4();
|
||||
const createSpan = await makeZodVerifiedAPICall(
|
||||
PostSpansV1Response,
|
||||
"POST",
|
||||
"/api/public/spans",
|
||||
{
|
||||
id: spanId,
|
||||
name: spanName,
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:00:00.000Z",
|
||||
input: { input: "value" },
|
||||
metadata: { meta: "value" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
expect(createSpan.status).toBe(200);
|
||||
|
||||
const updatedSpan = await makeZodVerifiedAPICall(
|
||||
PatchSpansV1Response,
|
||||
"PATCH",
|
||||
"/api/public/spans",
|
||||
{
|
||||
spanId: spanId,
|
||||
output: { key: "this is a great gpt output" },
|
||||
},
|
||||
);
|
||||
expect(updatedSpan.status).toBe(200);
|
||||
|
||||
const dbSpan = await prisma.observation.findUnique({
|
||||
where: {
|
||||
id: spanId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbSpan?.id).toBe(spanId);
|
||||
expect(dbSpan?.name).toBe(spanName);
|
||||
expect(dbSpan?.startTime).toEqual(new Date("2021-01-01T00:00:00.000Z"));
|
||||
expect(dbSpan?.endTime).toEqual(new Date("2021-01-:00:00.000Z"));
|
||||
expect(dbSpan?.input).toEqual({ input: "value" });
|
||||
expect(dbSpan?.output).toEqual({ key: "this is a great gpt output" });
|
||||
expect(dbSpan?.metadata).toEqual({ meta: "value" });
|
||||
expect(dbSpan?.version).toBe("2.0.0");
|
||||
});
|
||||
|
||||
it("should not upsert span if span does not exist", async () => {
|
||||
const spanId = uuidv4();
|
||||
|
||||
const updatedSpan = await makeAPICall("PATCH", "/api/public/spans", {
|
||||
spanId: spanId,
|
||||
output: { key: "this is a great gpt output" },
|
||||
});
|
||||
expect(updatedSpan.status).toBe(404);
|
||||
|
||||
const dbSpan = await prisma.observation.findUnique({
|
||||
where: {
|
||||
id: spanId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbSpan).toBeNull();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,358 +0,0 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import {
|
||||
makeZodVerifiedAPICall,
|
||||
pruneDatabase,
|
||||
} from "@/src/__tests__/test-utils";
|
||||
import { PostGenerationsV1Response } from "@/src/features/public-api/types/generations";
|
||||
import {
|
||||
GetTracesV1Response,
|
||||
GetTraceV1Response,
|
||||
PostTracesV1Response,
|
||||
} from "@/src/features/public-api/types/traces";
|
||||
import { PostScoresResponse } from "@langfuse/shared";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
|
||||
describe("/api/public/traces API Endpoint", () => {
|
||||
beforeEach(async () => await pruneDatabase());
|
||||
afterEach(async () => await pruneDatabase());
|
||||
|
||||
it("should upsert second trace", async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: "trace-id",
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
public: true,
|
||||
},
|
||||
);
|
||||
|
||||
const dbTrace1 = await prisma.trace.findFirst({
|
||||
where: {
|
||||
id: "trace-id",
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace1).not.toBeNull();
|
||||
expect(dbTrace1).toMatchObject({
|
||||
name: "trace-name",
|
||||
release: "1.0.0",
|
||||
externalId: null,
|
||||
version: "2.0.0",
|
||||
public: true,
|
||||
userId: "user-1",
|
||||
});
|
||||
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: "trace-id",
|
||||
metadata: { key: "value" },
|
||||
timestamp: "2021-01-01T00:00:00.000Z",
|
||||
release: "1.0.0",
|
||||
version: "5.0.0",
|
||||
public: false,
|
||||
},
|
||||
);
|
||||
|
||||
const dbTrace2 = await prisma.trace.findFirst({
|
||||
where: {
|
||||
name: "trace-name",
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace2).not.toBeNull();
|
||||
expect(dbTrace2).toMatchObject({
|
||||
name: "trace-name",
|
||||
release: "1.0.0",
|
||||
externalId: null,
|
||||
version: "5.0.0",
|
||||
public: false,
|
||||
userId: "user-1",
|
||||
timestamp: new Date("2021-01-01T00:00:00.000Z"),
|
||||
});
|
||||
});
|
||||
|
||||
it("should use tags correctly on POST and GET", async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: "trace-1",
|
||||
tags: ["tag-1", "tag-2", "tag-3"],
|
||||
},
|
||||
);
|
||||
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: "trace-2",
|
||||
tags: ["tag-1"],
|
||||
},
|
||||
);
|
||||
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: "trace-3",
|
||||
tags: ["tag-2", "tag-3"],
|
||||
},
|
||||
);
|
||||
|
||||
// multiple tags
|
||||
const traces = await makeZodVerifiedAPICall(
|
||||
GetTracesV1Response,
|
||||
"GET",
|
||||
"/api/public/traces?tags=tag-2&tags=tag-3",
|
||||
);
|
||||
const traceIds = traces.body.data.map((t) => t.id);
|
||||
// check for equality ok as ordered by timestamp
|
||||
expect(traceIds).toEqual(["trace-3", "trace-1"]);
|
||||
|
||||
// single tag
|
||||
const traces2 = await makeZodVerifiedAPICall(
|
||||
GetTracesV1Response,
|
||||
"GET",
|
||||
"/api/public/traces?tags=tag-1",
|
||||
);
|
||||
const traceIds2 = traces2.body.data.map((t) => t.id);
|
||||
// check for equality ok as ordered by timestamp
|
||||
expect(traceIds2).toEqual(["trace-2", "trace-1"]);
|
||||
|
||||
// wrong tag
|
||||
const traces3 = await makeZodVerifiedAPICall(
|
||||
GetTracesV1Response,
|
||||
"GET",
|
||||
"/api/public/traces?tags=tag-10",
|
||||
);
|
||||
const traceIds3 = traces3.body.data.map((t) => t.id);
|
||||
// check for equality ok as ordered by timestamp
|
||||
expect(traceIds3).toEqual([]);
|
||||
|
||||
// no tag
|
||||
const traces4 = await makeZodVerifiedAPICall(
|
||||
GetTracesV1Response,
|
||||
"GET",
|
||||
"/api/public/traces?tags=",
|
||||
);
|
||||
const traceIds4 = traces4.body.data.map((t) => t.id);
|
||||
// check for equality ok as ordered by timestamp
|
||||
expect(traceIds4).toEqual(["trace-3", "trace-2", "trace-1"]);
|
||||
});
|
||||
|
||||
it("should handle metrics correctly on GET traces and GET trace", async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
// Create a trace with some observations that have costs and latencies
|
||||
const traceId = uuidv4();
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: traceId,
|
||||
name: "trace-with-costs",
|
||||
userId: "user-costs",
|
||||
projectId: "project-costs",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
console.log(traceId);
|
||||
|
||||
// Simulate observations with costs and latencies
|
||||
const generationId = uuidv4();
|
||||
await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Response,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
traceId: traceId,
|
||||
id: generationId,
|
||||
name: "Generation1",
|
||||
usage: { totalCost: 10.5 },
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:10:00.000Z",
|
||||
},
|
||||
);
|
||||
await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Response,
|
||||
"POST",
|
||||
"/api/public/generations",
|
||||
{
|
||||
traceId: traceId,
|
||||
usage: { totalCost: 5.25 },
|
||||
startTime: "2021-01-01T00:10:00.000Z",
|
||||
endTime: "2021-01-01T00:20:00.000Z",
|
||||
},
|
||||
);
|
||||
|
||||
// Simulate scores on the trace
|
||||
const scoreId1 = uuidv4();
|
||||
await makeZodVerifiedAPICall(
|
||||
PostScoresResponse,
|
||||
"POST",
|
||||
"/api/public/scores",
|
||||
{
|
||||
id: scoreId1,
|
||||
name: "score-1",
|
||||
value: 75.0,
|
||||
traceId: traceId,
|
||||
comment: "First score",
|
||||
},
|
||||
);
|
||||
const scoreId2 = uuidv4();
|
||||
await makeZodVerifiedAPICall(
|
||||
PostScoresResponse,
|
||||
"POST",
|
||||
"/api/public/scores",
|
||||
{
|
||||
id: scoreId2,
|
||||
name: "score-2",
|
||||
value: 85.5,
|
||||
traceId: traceId,
|
||||
comment: "Second score",
|
||||
},
|
||||
);
|
||||
const scoreId3 = uuidv4();
|
||||
await makeZodVerifiedAPICall(
|
||||
PostScoresResponse,
|
||||
"POST",
|
||||
"/api/public/scores",
|
||||
{
|
||||
id: scoreId3,
|
||||
name: "score-3",
|
||||
value: 95.0,
|
||||
traceId: traceId,
|
||||
comment: "Third score",
|
||||
},
|
||||
);
|
||||
|
||||
// GET traces
|
||||
// Retrieve the trace with totalCost and latency
|
||||
const traces = await makeZodVerifiedAPICall(
|
||||
GetTracesV1Response,
|
||||
"GET",
|
||||
`/api/public/traces`,
|
||||
);
|
||||
const traceData = traces.body.data[0];
|
||||
if (!traceData) throw new Error("traceData is undefined");
|
||||
|
||||
// Check if the totalCost and latency are calculated correctly
|
||||
expect(traceData.totalCost).toBeCloseTo(15.75); // Sum of costs
|
||||
expect(traceData.latency).toBeCloseTo(1200); // Difference in seconds between min startTime and max endTime
|
||||
expect(traceData.id).toBe(traceId);
|
||||
expect(traceData.htmlPath).toContain(`/traces/${traceId}`);
|
||||
expect(traceData.htmlPath).toContain(`/project/`); // do not know the projectId
|
||||
|
||||
// GET trace
|
||||
// Retrieve the trace with total
|
||||
const trace = await makeZodVerifiedAPICall(
|
||||
GetTraceV1Response,
|
||||
"GET",
|
||||
`/api/public/traces/${traceId}`,
|
||||
);
|
||||
console.log(trace.body);
|
||||
expect(trace.body.totalCost).toBeCloseTo(15.75);
|
||||
expect(trace.body.id).toBe(traceId);
|
||||
expect(trace.body.id).toBe(traceId);
|
||||
expect(trace.body.htmlPath).toContain(`/traces/${traceId}`);
|
||||
expect(trace.body.htmlPath).toContain(`/project/`); // do not know the projectId
|
||||
expect(trace.body.scores).toHaveLength(3);
|
||||
expect(trace.body.scores[0].id).toBe(scoreId3);
|
||||
expect(trace.body.scores[0].name).toBe("score-3");
|
||||
expect(trace.body.observations).toHaveLength(2);
|
||||
expect(trace.body.observations[0].id).toBe(generationId);
|
||||
expect(trace.body.observations[0].name).toBe("Generation1");
|
||||
});
|
||||
|
||||
it("should filter traces by session ID", async () => {
|
||||
const sessionId = "test-session-id";
|
||||
const anotherSessionId = "another-session-id";
|
||||
|
||||
// Create traces with different session IDs
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: "trace-1",
|
||||
name: "test-trace-1",
|
||||
sessionId,
|
||||
userId: "user-1",
|
||||
projectId: "project-1",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "1.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
"POST",
|
||||
"/api/public/traces",
|
||||
{
|
||||
id: "trace-2",
|
||||
name: "test-trace-2",
|
||||
sessionId: anotherSessionId,
|
||||
userId: "user-2",
|
||||
projectId: "project-1",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "1.0.0",
|
||||
},
|
||||
);
|
||||
|
||||
// Filter by session ID
|
||||
const tracesBySessionId = await makeZodVerifiedAPICall(
|
||||
GetTracesV1Response,
|
||||
"GET",
|
||||
`/api/public/traces?sessionId=${sessionId}`,
|
||||
);
|
||||
|
||||
expect(tracesBySessionId.status).toBe(200);
|
||||
expect(tracesBySessionId.body.data).toHaveLength(1);
|
||||
expect(tracesBySessionId.body.data[0].id).toBe("trace-1");
|
||||
|
||||
// Filter by another session ID
|
||||
const tracesByAnotherSessionId = await makeZodVerifiedAPICall(
|
||||
GetTracesV1Response,
|
||||
"GET",
|
||||
`/api/public/traces?sessionId=${anotherSessionId}`,
|
||||
);
|
||||
|
||||
expect(tracesByAnotherSessionId.status).toBe(200);
|
||||
expect(tracesByAnotherSessionId.body.data).toHaveLength(1);
|
||||
expect(tracesByAnotherSessionId.body.data[0].id).toBe("trace-2");
|
||||
|
||||
// Filter by non-existent session ID
|
||||
const tracesByNonExistentSessionId = await makeZodVerifiedAPICall(
|
||||
GetTracesV1Response,
|
||||
"GET",
|
||||
`/api/public/traces?sessionId=non-existent-session-id`,
|
||||
);
|
||||
|
||||
expect(tracesByNonExistentSessionId.status).toBe(200);
|
||||
expect(tracesByNonExistentSessionId.body.data).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { paginationZod } from "@langfuse/shared";
|
||||
import { paginationZod, parseJsonPrioritised } from "@langfuse/shared";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
// Create test cases
|
||||
@@ -24,3 +24,32 @@ describe("Pagination Zod Schema", () => {
|
||||
expect(() => paginationZod.limit.parse("abc")).toThrowError(ZodError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseJsonPrioritised", () => {
|
||||
it.each([
|
||||
["test", "test"], // Raw string
|
||||
['{"hello": "world"}', { hello: "world" }], // Simple object
|
||||
["[1, 2, 3]", [1, 2, 3]], // Array
|
||||
['{"nested": {"key": "value"}}', { nested: { key: "value" } }], // Nested object
|
||||
["[]", []], // Empty array
|
||||
["{}", {}], // Empty object
|
||||
['"simple string"', "simple string"], // Quoted string
|
||||
["42", 42], // Number
|
||||
["true", true], // Boolean true
|
||||
["false", false], // Boolean false
|
||||
["null", null], // Null
|
||||
["", ""], // Empty string
|
||||
["invalid{json}", "invalid{json}"], // Invalid JSON string
|
||||
['{"hello": "world"', '{"hello": "world"'], // Invalid JSON string
|
||||
['[null, 123, "abc"]', [null, 123, "abc"]], // Mixed array
|
||||
[
|
||||
'{"array": [1, 2], "nested": {"key": "value"}}',
|
||||
{ array: [1, 2], nested: { key: "value" } },
|
||||
], // Complex object
|
||||
])(
|
||||
"should parse input correctly (%s, %s)",
|
||||
(input: string, expectedOutput: any) => {
|
||||
expect(parseJsonPrioritised(input)).toEqual(expectedOutput);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -37,12 +37,17 @@ export const BatchExportTableButton: React.FC<BatchExportTableButtonProps> = (
|
||||
showSuccessToast({
|
||||
title: "Export queued",
|
||||
description: "You will receive an email when the export is ready.",
|
||||
duration: 10000,
|
||||
link: {
|
||||
href: `/project/${props.projectId}/settings/exports`,
|
||||
text: "View exports",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const hasAccess = useHasProjectAccess({
|
||||
projectId: props.projectId,
|
||||
scope: "batchExport:create",
|
||||
scope: "batchExports:create",
|
||||
});
|
||||
|
||||
const handleExport = async (format: BatchExportFileFormat) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/src/components/ui/select";
|
||||
@@ -33,15 +34,12 @@ export type ModelParamsContext = {
|
||||
modelParamsDescription?: string;
|
||||
};
|
||||
|
||||
export const ModelParameters: React.FC<
|
||||
ModelParamsContext & { evalModelsOnly: boolean }
|
||||
> = ({
|
||||
export const ModelParameters: React.FC<ModelParamsContext> = ({
|
||||
modelParams,
|
||||
availableProviders,
|
||||
availableModels,
|
||||
updateModelParamValue,
|
||||
setModelParamEnabled,
|
||||
evalModelsOnly,
|
||||
formDisabled = false,
|
||||
modelParamsDescription,
|
||||
}) => {
|
||||
@@ -54,11 +52,8 @@ export const ModelParameters: React.FC<
|
||||
<p className="font-semibold">Model</p>
|
||||
{availableProviders.length === 0 ? (
|
||||
<>
|
||||
<p className="text-xs">
|
||||
No LLM API key set in project.{" "}
|
||||
{evalModelsOnly && "For evals, only OpenAI models are supported."}
|
||||
</p>
|
||||
<CreateLLMApiKeyDialog evalModelsOnly={evalModelsOnly} />
|
||||
<p className="text-xs">No LLM API key set in project. </p>
|
||||
<CreateLLMApiKeyDialog />
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
@@ -186,6 +181,8 @@ const ModelParamsSelect = ({
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectSeparator />
|
||||
<CreateLLMApiKeyDialog />
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{modelParamsDescription ? (
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/src/components/ui/card";
|
||||
import { chatAvailable, openChat } from "@/src/features/support-chat/chat";
|
||||
import { Book, Github, Mail, MessageSquare, Slack } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { SiDiscord } from "react-icons/si";
|
||||
|
||||
const supportChannels = [
|
||||
{
|
||||
icon: Book,
|
||||
title: "Documentation",
|
||||
description: "Find answers in the documentation.",
|
||||
href: "https://docs.langfuse.com",
|
||||
buttonText: "Visit Docs",
|
||||
primary: true,
|
||||
},
|
||||
{
|
||||
icon: Github,
|
||||
title: "GitHub Issues",
|
||||
description:
|
||||
"Create an issue on Github to report bugs or request new features.",
|
||||
href: "https://github.com/langfuse/langfuse/issues/new/choose",
|
||||
buttonText: "Create Issue",
|
||||
primary: true,
|
||||
},
|
||||
{
|
||||
icon: Github,
|
||||
title: "GitHub Support",
|
||||
description: "Create a support ticket via GitHub discussions.",
|
||||
href: "https://github.com/orgs/langfuse/discussions/categories/support",
|
||||
buttonText: "Submit Question",
|
||||
primary: true,
|
||||
},
|
||||
{
|
||||
icon: Mail,
|
||||
title: "Email",
|
||||
description: "Send email to our shared inbox: help@langfuse.com",
|
||||
href: "mailto:help@langfuse.com",
|
||||
buttonText: "Send Email",
|
||||
available: !chatAvailable,
|
||||
},
|
||||
{
|
||||
icon: MessageSquare,
|
||||
title: "Chat",
|
||||
description: "Get support directly from the team.",
|
||||
onClick: () => openChat(),
|
||||
available: chatAvailable,
|
||||
buttonText: "Launch Chat",
|
||||
},
|
||||
{
|
||||
icon: SiDiscord,
|
||||
title: "Discord",
|
||||
description:
|
||||
"Get support from community. Follow announcements to stay up to date with new features.",
|
||||
href: "https://langfuse.com/discord",
|
||||
buttonText: "Join Discord",
|
||||
},
|
||||
{
|
||||
icon: Slack,
|
||||
title: "Slack Connect",
|
||||
description: "Get a dedicated support channel for you and your team.",
|
||||
href: "mailto:help@langfuse.com?subject=Slack%20Connect%20Request&body=I'd%20like%20to%20request%20a%20dedicated%20Slack%20Connect%20channel%20for%20me%20and%20my%20team.%0D%0A%0D%0AUsers%20(emails)%20to%20include%20besides%20mine%3A%0D%0A%0D%0A",
|
||||
buttonText: "Request via Email",
|
||||
},
|
||||
];
|
||||
|
||||
export const SupportChannels = () => (
|
||||
<div className="mt-10 grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
{supportChannels
|
||||
.filter((channel) => channel.available === undefined || channel.available)
|
||||
.map((channel) => (
|
||||
<Card key={channel.title} className="flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<channel.icon size={20} />
|
||||
{channel.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
<CardDescription>{channel.description}</CardDescription>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{channel.href ? (
|
||||
<Button
|
||||
asChild
|
||||
variant={channel.primary ? "default" : "secondary"}
|
||||
>
|
||||
<Link href={channel.href}>{channel.buttonText}</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={channel.onClick}
|
||||
variant={channel.primary ? "default" : "secondary"}
|
||||
>
|
||||
{channel.buttonText}
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -15,7 +15,7 @@ import { useUiCustomization } from "@/src/ee/features/ui-customization/useUiCust
|
||||
import { hasOrganizationAccess } from "@/src/features/rbac/utils/checkOrganizationAccess";
|
||||
import { ClickhouseAdminToggle } from "@/src/components/layouts/ClickhouseAdminToggle";
|
||||
import { SidebarInset, SidebarProvider } from "@/src/components/ui/sidebar";
|
||||
import { AppSidebar } from "@/src/components/app-sidebar";
|
||||
import { AppSidebar } from "@/src/components/nav/app-sidebar";
|
||||
|
||||
const signOutUser = async () => {
|
||||
localStorage.clear();
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
type LucideIcon,
|
||||
Settings,
|
||||
UsersIcon,
|
||||
LibraryBig,
|
||||
TerminalIcon,
|
||||
Lightbulb,
|
||||
Grid2X2,
|
||||
@@ -20,9 +19,11 @@ import { type Entitlement } from "@/src/features/entitlements/constants/entitlem
|
||||
import { type UiCustomizationOption } from "@/src/ee/features/ui-customization/useUiCustomization";
|
||||
import { type User } from "next-auth";
|
||||
import { type OrganizationScope } from "@/src/features/rbac/constants/organizationAccessRights";
|
||||
import { SupportMenuDropdown } from "@/src/components/nav/support-menu-dropdown";
|
||||
|
||||
export type Route = {
|
||||
title: string;
|
||||
menuNode?: ReactNode;
|
||||
featureFlag?: Flag;
|
||||
label?: string | ReactNode;
|
||||
projectRbacScopes?: ProjectScope[]; // array treated as OR
|
||||
@@ -70,8 +71,8 @@ export const ROUTES: Route[] = [
|
||||
pathname: `/project/[projectId]/sessions`,
|
||||
},
|
||||
{
|
||||
title: "Generations",
|
||||
pathname: `/project/[projectId]/generations`,
|
||||
title: "Observations",
|
||||
pathname: `/project/[projectId]/observations`,
|
||||
},
|
||||
{
|
||||
title: "Scores",
|
||||
@@ -87,7 +88,6 @@ export const ROUTES: Route[] = [
|
||||
title: "Evaluation",
|
||||
icon: Lightbulb,
|
||||
pathname: `/project/[projectId]/annotation-queues`,
|
||||
label: "Beta",
|
||||
entitlements: ["annotation-queues", "model-based-evaluations"],
|
||||
projectRbacScopes: ["annotationQueues:read", "evalJob:read"],
|
||||
items: [
|
||||
@@ -157,19 +157,11 @@ export const ROUTES: Route[] = [
|
||||
icon: Settings,
|
||||
bottom: true,
|
||||
},
|
||||
{
|
||||
title: "Docs",
|
||||
pathname: "https://langfuse.com/docs",
|
||||
icon: LibraryBig,
|
||||
bottom: true,
|
||||
newTab: true,
|
||||
customizableHref: "documentationHref",
|
||||
},
|
||||
{
|
||||
title: "Support",
|
||||
pathname: "/support",
|
||||
icon: LifeBuoy,
|
||||
bottom: true,
|
||||
customizableHref: "supportHref",
|
||||
pathname: "", // Empty pathname since this is a dropdown
|
||||
menuNode: <SupportMenuDropdown />,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -27,19 +27,18 @@ export const StatusBadge = ({
|
||||
let dotPingColor = "bg-muted-foreground";
|
||||
let showDot = isLive;
|
||||
|
||||
if (statusCategories.active.includes(type)) {
|
||||
if (statusCategories.active.includes(type.toLowerCase())) {
|
||||
badgeColor = "bg-light-green text-dark-green";
|
||||
dotColor = "animate-ping bg-dark-green";
|
||||
dotPingColor = "bg-dark-green";
|
||||
} else if (statusCategories.pending.includes(type)) {
|
||||
} else if (statusCategories.pending.includes(type.toLowerCase())) {
|
||||
badgeColor = "bg-light-yellow text-dark-yellow";
|
||||
dotColor = "animate-ping bg-dark-yellow";
|
||||
dotPingColor = "bg-dark-yellow";
|
||||
} else if (statusCategories.error.includes(type)) {
|
||||
} else if (statusCategories.error.includes(type.toLowerCase())) {
|
||||
badgeColor = "bg-light-red text-dark-red";
|
||||
dotColor = "animate-ping bg-dark-red";
|
||||
dotPingColor = "bg-dark-red";
|
||||
} else if (statusCategories.completed.includes(type)) {
|
||||
showDot = false;
|
||||
} else if (statusCategories.completed.includes(type.toLowerCase())) {
|
||||
badgeColor = "bg-light-green text-dark-green";
|
||||
showDot = false;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { NavMain, type NavMainItem } from "@/src/components/nav-main";
|
||||
import { NavUser, type UserNavigationProps } from "@/src/components/nav-user";
|
||||
import { NavMain, type NavMainItem } from "@/src/components/nav/nav-main";
|
||||
import {
|
||||
NavUser,
|
||||
type UserNavigationProps,
|
||||
} from "@/src/components/nav/nav-user";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -15,7 +18,7 @@ import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
import { Alert, AlertDescription } from "@/src/components/ui/alert";
|
||||
import { LangfuseLogo } from "@/src/components/LangfuseLogo";
|
||||
import { SidebarNotifications } from "@/src/components/sidebar-notifications";
|
||||
import { SidebarNotifications } from "@/src/components/nav/sidebar-notifications";
|
||||
import { UsageTracker } from "@/src/ee/features/billing/components/UsageTracker";
|
||||
|
||||
type AppSidebarProps = {
|
||||
@@ -45,7 +48,7 @@ export function AppSidebar({
|
||||
<UsageTracker />
|
||||
<SidebarNotifications />
|
||||
</div>
|
||||
<NavMain items={secondaryNavItems} showFeedbackButton />
|
||||
<NavMain items={secondaryNavItems} />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser {...userNavProps} />
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight, MessageSquarePlus, type LucideIcon } from "lucide-react";
|
||||
import { ChevronRight, type LucideIcon } from "lucide-react";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -20,18 +20,20 @@ import {
|
||||
import Link from "next/link";
|
||||
import { type ReactNode } from "react";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { FeedbackButtonWrapper } from "@/src/features/feedback/component/FeedbackButton";
|
||||
|
||||
export type NavMainItem = {
|
||||
title: string;
|
||||
menuNode?: ReactNode;
|
||||
url: string;
|
||||
icon?: LucideIcon;
|
||||
isActive?: boolean;
|
||||
label?: string | ReactNode;
|
||||
newTab?: boolean;
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
isActive?: boolean;
|
||||
newTab?: boolean;
|
||||
}[];
|
||||
};
|
||||
|
||||
@@ -57,31 +59,11 @@ function NavItemContent({ item }: { item: NavMainItem }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function NavMain({
|
||||
items,
|
||||
showFeedbackButton,
|
||||
}: {
|
||||
items: NavMainItem[];
|
||||
showFeedbackButton?: boolean;
|
||||
}) {
|
||||
export function NavMain({ items }: { items: NavMainItem[] }) {
|
||||
const { open, setOpen } = useSidebar();
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarMenu>
|
||||
{showFeedbackButton && (
|
||||
<FeedbackButtonWrapper
|
||||
title="Provide feedback"
|
||||
description="What do you think about this project? What can be improved?"
|
||||
type="feedback"
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton tooltip="Feedback">
|
||||
<MessageSquarePlus className="h-5 w-5" aria-hidden="true" />
|
||||
Feedback
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</FeedbackButtonWrapper>
|
||||
)}
|
||||
{items.map((item) =>
|
||||
item.items && item.items.length > 0 ? (
|
||||
<Collapsible
|
||||
@@ -115,7 +97,10 @@ export function NavMain({
|
||||
asChild
|
||||
isActive={subItem.isActive}
|
||||
>
|
||||
<Link href={subItem.url}>
|
||||
<Link
|
||||
href={subItem.url}
|
||||
target={subItem.newTab ? "_blank" : undefined}
|
||||
>
|
||||
<span>{subItem.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
@@ -127,15 +112,20 @@ export function NavMain({
|
||||
</Collapsible>
|
||||
) : (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={item.title}
|
||||
isActive={item.isActive}
|
||||
>
|
||||
<Link href={item.url}>
|
||||
<NavItemContent item={item} />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{item.menuNode || (
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={item.title}
|
||||
isActive={item.isActive}
|
||||
>
|
||||
<Link
|
||||
href={item.url}
|
||||
target={item.newTab ? "blank" : undefined}
|
||||
>
|
||||
<NavItemContent item={item} />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
),
|
||||
)}
|
||||
+1
-37
@@ -8,7 +8,7 @@ import {
|
||||
} from "@/src/components/ui/card";
|
||||
import { useEffect, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import useLocalStorage from "./useLocalStorage";
|
||||
import useLocalStorage from "../useLocalStorage";
|
||||
import Link from "next/link";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
|
||||
@@ -23,42 +23,6 @@ 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",
|
||||
description: "All new docs for datasets, experiments, and evaluations",
|
||||
link: "https://langfuse.com/changelog/2024-11-21-all-new-datasets-and-evals-documentation",
|
||||
linkTitle: "Changelog",
|
||||
},
|
||||
{
|
||||
id: "lw2-3",
|
||||
title: "Launch Week 2 – Day 3",
|
||||
description:
|
||||
"Full multi-modal support including images, audio, and attachments",
|
||||
link: "https://langfuse.com/changelog/2024-11-20-full-multi-modal-images-audio-attachments",
|
||||
linkTitle: "Changelog",
|
||||
},
|
||||
{
|
||||
id: "lw2-2",
|
||||
title: "Launch Week 2 – Day 2",
|
||||
description: "LLM-as-a-Judge Evaluators for Dataset Experiments",
|
||||
link: "https://langfuse.com/changelog/2024-11-19-llm-as-a-judge-for-datasets",
|
||||
linkTitle: "Changelog",
|
||||
},
|
||||
{
|
||||
id: "lw2-1",
|
||||
title: "Launch Week 2 – Day 1",
|
||||
description: "New side-by-side comparison view for dataset experiment runs",
|
||||
link: "https://langfuse.com/changelog/2024-11-18-dataset-runs-comparison-view",
|
||||
linkTitle: "Changelog",
|
||||
},
|
||||
{
|
||||
id: "github-star",
|
||||
title: "Star Langfuse",
|
||||
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/src/components/ui/dropdown-menu";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Bug,
|
||||
Github,
|
||||
LibraryBig,
|
||||
LifeBuoy,
|
||||
Lightbulb,
|
||||
MessageCircle,
|
||||
MessageSquarePlus,
|
||||
Sparkles,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
type UiCustomizationOption,
|
||||
useUiCustomization,
|
||||
} from "@/src/ee/features/ui-customization/useUiCustomization";
|
||||
import { SidebarMenuButton, useSidebar } from "@/src/components/ui/sidebar";
|
||||
import {
|
||||
chatAvailable,
|
||||
chatIsVisible,
|
||||
showChat,
|
||||
hideChat,
|
||||
} from "@/src/features/support-chat/chat";
|
||||
import { Switch } from "@/src/components/ui/switch";
|
||||
import { SiDiscord } from "react-icons/si";
|
||||
|
||||
type SupportMenuItem = {
|
||||
title: string;
|
||||
pathname: string;
|
||||
icon: LucideIcon | React.ElementType;
|
||||
menuNode?: ReactNode;
|
||||
customizableHref?: UiCustomizationOption;
|
||||
};
|
||||
|
||||
export const SupportMenuDropdown = () => {
|
||||
const uiCustomization = useUiCustomization();
|
||||
|
||||
const supportMenuItems: (SupportMenuItem | "separator")[] = useMemo(() => {
|
||||
const items: (SupportMenuItem | "separator")[] = [
|
||||
{
|
||||
title: "Ask AI",
|
||||
pathname: "https://langfuse.com/docs/ask-ai",
|
||||
icon: Sparkles,
|
||||
},
|
||||
];
|
||||
|
||||
const chatVisible = chatIsVisible();
|
||||
if (uiCustomization?.supportHref) {
|
||||
items.push({
|
||||
title: "Support",
|
||||
pathname: uiCustomization.supportHref,
|
||||
icon: LifeBuoy,
|
||||
});
|
||||
} else {
|
||||
if (chatAvailable) {
|
||||
items.push({
|
||||
title: "Chat",
|
||||
pathname: "#",
|
||||
menuNode: (
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
<span>Chat</span>
|
||||
<Switch
|
||||
defaultChecked={chatVisible}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
showChat();
|
||||
} else {
|
||||
hideChat();
|
||||
}
|
||||
}}
|
||||
className="ml-auto"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
icon: MessageCircle,
|
||||
});
|
||||
}
|
||||
items.push("separator");
|
||||
items.push({
|
||||
title: "GitHub Support",
|
||||
pathname: "https://langfuse.com/gh-support",
|
||||
icon: Github,
|
||||
});
|
||||
items.push({
|
||||
title: "Discord",
|
||||
pathname: "https://langfuse.com/discord",
|
||||
icon: SiDiscord,
|
||||
});
|
||||
}
|
||||
|
||||
items.push("separator");
|
||||
items.push({
|
||||
title: "Docs",
|
||||
pathname: "https://langfuse.com/docs",
|
||||
icon: LibraryBig,
|
||||
customizableHref: "documentationHref",
|
||||
});
|
||||
items.push("separator");
|
||||
|
||||
if (uiCustomization?.feedbackHref) {
|
||||
items.push({
|
||||
title: "Feedback",
|
||||
pathname: uiCustomization.feedbackHref,
|
||||
icon: MessageSquarePlus,
|
||||
});
|
||||
} else {
|
||||
items.push(
|
||||
...[
|
||||
{
|
||||
title: "Feature Request",
|
||||
pathname: "https://langfuse.com/ideas",
|
||||
icon: Lightbulb,
|
||||
},
|
||||
{
|
||||
title: "Report a Bug",
|
||||
pathname: "https://langfuse.com/issues",
|
||||
icon: Bug,
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [uiCustomization]);
|
||||
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton>
|
||||
<LifeBuoy className="h-4 w-4" />
|
||||
Support
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
{supportMenuItems.map((item, index) => {
|
||||
if (item === "separator") {
|
||||
return <DropdownMenuSeparator key={`separator-${index}`} />;
|
||||
}
|
||||
const url = item.customizableHref
|
||||
? (uiCustomization?.[item.customizableHref] ?? item.pathname)
|
||||
: item.pathname;
|
||||
return (
|
||||
<DropdownMenuItem key={item.title} asChild>
|
||||
{item.menuNode ?? (
|
||||
<a
|
||||
href={url}
|
||||
target={url.startsWith("http") ? "_blank" : undefined}
|
||||
rel={url.startsWith("http") ? "noopener" : undefined}
|
||||
className="flex cursor-pointer items-center"
|
||||
>
|
||||
<item.icon className="mr-2 h-4 w-4" />
|
||||
{item.title}
|
||||
{url.startsWith("http") && (
|
||||
<ArrowUpRight className="ml-1 h-3 w-3" />
|
||||
)}
|
||||
</a>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -160,8 +160,12 @@ function ColumnVisibilityDropdownItem<TData, TValue>({
|
||||
|
||||
function GroupVisibilityDropdownHeader<TData, TValue>({
|
||||
column,
|
||||
groupTotalCount,
|
||||
groupVisibleCount,
|
||||
}: {
|
||||
column: LangfuseColumnDef<TData, TValue>;
|
||||
groupTotalCount: number;
|
||||
groupVisibleCount: number;
|
||||
}) {
|
||||
const { attributes, isDragging, listeners, setNodeRef, transform } =
|
||||
useSortable({
|
||||
@@ -189,6 +193,9 @@ function GroupVisibilityDropdownHeader<TData, TValue>({
|
||||
? column.header
|
||||
: column.accessorKey}
|
||||
</span>
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">
|
||||
({groupVisibleCount}/{groupTotalCount})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
@@ -352,7 +359,11 @@ export function DataTableColumnVisibilityFilter<TData, TValue>({
|
||||
return (
|
||||
<DropdownMenuSub key={index}>
|
||||
{isColumnOrderingEnabled ? (
|
||||
<GroupVisibilityDropdownHeader column={column} />
|
||||
<GroupVisibilityDropdownHeader
|
||||
column={column}
|
||||
groupTotalCount={groupTotalCount}
|
||||
groupVisibleCount={groupVisibleCount}
|
||||
/>
|
||||
) : (
|
||||
<DropdownMenuSubTrigger hasCustomIcon>
|
||||
<Component className="mr-2 h-4 w-4 opacity-50" />
|
||||
|
||||
+19
-16
@@ -71,7 +71,8 @@ export function TraceTableMultiSelectAction({
|
||||
},
|
||||
});
|
||||
|
||||
const hasEntitlement = useHasEntitlement("annotation-queues");
|
||||
const hasAnnotationEntitlement = useHasEntitlement("annotation-queues");
|
||||
const hasTraceDeletionEntitlement = useHasEntitlement("trace-deletion");
|
||||
const hasAddToQueueAccess = useHasProjectAccess({
|
||||
projectId,
|
||||
scope: "annotationQueues:CUD",
|
||||
@@ -97,7 +98,7 @@ export function TraceTableMultiSelectAction({
|
||||
{
|
||||
projectId,
|
||||
},
|
||||
{ enabled: session.status === "authenticated" && hasEntitlement },
|
||||
{ enabled: session.status === "authenticated" && hasAnnotationEntitlement },
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -110,20 +111,22 @@ export function TraceTableMultiSelectAction({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
disabled={!hasDeleteAccess}
|
||||
onClick={() => {
|
||||
capture("trace:delete_form_open", {
|
||||
count: selectedTraceIds.length,
|
||||
source: "table-multi-select",
|
||||
});
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
{hasEntitlement && (
|
||||
{hasTraceDeletionEntitlement && (
|
||||
<DropdownMenuItem
|
||||
disabled={!hasDeleteAccess}
|
||||
onClick={() => {
|
||||
capture("trace:delete_form_open", {
|
||||
count: selectedTraceIds.length,
|
||||
source: "table-multi-select",
|
||||
});
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasAnnotationEntitlement && (
|
||||
<DropdownMenuItem
|
||||
disabled={!hasAddToQueueAccess}
|
||||
onClick={() => {
|
||||
|
||||
@@ -422,9 +422,9 @@ function TableBodyComponent<TData>({
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
<div>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={columns.length} className="relative h-24">
|
||||
<div className="pointer-events-none fixed left-[50%] flex -translate-y-1/2 items-center justify-center">
|
||||
No results.{" "}
|
||||
{help && (
|
||||
<DocPopup description={help.description} href={help.href} />
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type FilterState,
|
||||
type ObservationOptions,
|
||||
BatchExportTableName,
|
||||
type ObservationType,
|
||||
} from "@langfuse/shared";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { LevelColors } from "@/src/components/level-colors";
|
||||
@@ -44,8 +45,9 @@ import { useClickhouse } from "@/src/components/layouts/ClickhouseAdminToggle";
|
||||
import { BreakdownTooltip } from "@/src/components/trace/BreakdownToolTip";
|
||||
import { InfoIcon, PlusCircle } from "lucide-react";
|
||||
import { UpsertModelFormDrawer } from "@/src/features/models/components/UpsertModelFormDrawer";
|
||||
import { ColorCodedObservationType } from "@/src/components/trace/ObservationTree";
|
||||
|
||||
export type GenerationsTableRow = {
|
||||
export type ObservationsTableRow = {
|
||||
id: string;
|
||||
traceId?: string;
|
||||
startTime: Date;
|
||||
@@ -80,7 +82,7 @@ export type GenerationsTableRow = {
|
||||
traceTags?: string[];
|
||||
};
|
||||
|
||||
export type GenerationsTableProps = {
|
||||
export type ObservationsTableProps = {
|
||||
projectId: string;
|
||||
promptName?: string;
|
||||
promptVersion?: number;
|
||||
@@ -88,13 +90,13 @@ export type GenerationsTableProps = {
|
||||
omittedFilter?: string[];
|
||||
};
|
||||
|
||||
export default function GenerationsTable({
|
||||
export default function ObservationsTable({
|
||||
projectId,
|
||||
promptName,
|
||||
promptVersion,
|
||||
modelId,
|
||||
omittedFilter = [],
|
||||
}: GenerationsTableProps) {
|
||||
}: ObservationsTableProps) {
|
||||
const [searchQuery, setSearchQuery] = useQueryParam(
|
||||
"search",
|
||||
withDefault(StringParam, null),
|
||||
@@ -114,7 +116,14 @@ export default function GenerationsTable({
|
||||
useTableDateRange(projectId);
|
||||
|
||||
const [inputFilterState, setInputFilterState] = useQueryFilterState(
|
||||
[],
|
||||
[
|
||||
{
|
||||
column: "type",
|
||||
type: "stringOptions",
|
||||
operator: "any of",
|
||||
value: ["GENERATION"],
|
||||
},
|
||||
],
|
||||
"generations",
|
||||
projectId,
|
||||
);
|
||||
@@ -220,7 +229,7 @@ export default function GenerationsTable({
|
||||
);
|
||||
|
||||
const { scoreColumns, scoreKeysAndProps, isColumnLoading } =
|
||||
useIndividualScoreColumns<GenerationsTableRow>({
|
||||
useIndividualScoreColumns<ObservationsTableRow>({
|
||||
projectId,
|
||||
scoreColumnKey: "scores",
|
||||
selectedFilterOption: selectedOption,
|
||||
@@ -234,7 +243,7 @@ export default function GenerationsTable({
|
||||
);
|
||||
};
|
||||
|
||||
const columns: LangfuseColumnDef<GenerationsTableRow>[] = [
|
||||
const columns: LangfuseColumnDef<ObservationsTableRow>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
id: "id",
|
||||
@@ -261,6 +270,19 @@ export default function GenerationsTable({
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
id: "type",
|
||||
header: "Type",
|
||||
size: 100,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const value: ObservationType = row.getValue("type");
|
||||
return value ? (
|
||||
<ColorCodedObservationType observationType={value} />
|
||||
) : undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "traceId",
|
||||
id: "traceId",
|
||||
@@ -724,22 +746,23 @@ export default function GenerationsTable({
|
||||
];
|
||||
|
||||
const [columnVisibility, setColumnVisibilityState] =
|
||||
useColumnVisibility<GenerationsTableRow>(
|
||||
useColumnVisibility<ObservationsTableRow>(
|
||||
`generationsColumnVisibility-${projectId}`,
|
||||
columns,
|
||||
);
|
||||
|
||||
const [columnOrder, setColumnOrder] = useColumnOrder<GenerationsTableRow>(
|
||||
const [columnOrder, setColumnOrder] = useColumnOrder<ObservationsTableRow>(
|
||||
"generationsColumnOrder",
|
||||
columns,
|
||||
);
|
||||
|
||||
const rows: GenerationsTableRow[] = useMemo(() => {
|
||||
const rows: ObservationsTableRow[] = useMemo(() => {
|
||||
return generations.isSuccess
|
||||
? generations.data.generations.map((generation) => {
|
||||
return {
|
||||
id: generation.id,
|
||||
traceId: generation.traceId ?? undefined,
|
||||
type: generation.type ?? undefined,
|
||||
traceName: generation.traceName ?? "",
|
||||
startTime: generation.startTime,
|
||||
endTime: generation.endTime?.toLocaleString() ?? undefined,
|
||||
|
||||
@@ -34,6 +34,7 @@ export type ModelTableRow = {
|
||||
prices?: Record<string, number>;
|
||||
tokenizerId?: string;
|
||||
config?: Prisma.JsonValue;
|
||||
lastUsed?: Date | null;
|
||||
serverResponse: GetModelResult;
|
||||
};
|
||||
|
||||
@@ -51,6 +52,7 @@ const modelConfigDescriptions = {
|
||||
"Some tokenizers require additional configuration (e.g. openai tiktoken). See docs for details.",
|
||||
maintainer:
|
||||
"Maintainer of the model. Langfuse managed models can be cloned, user managed models can be edited and deleted. To supersede a Langfuse managed model, set the custom model name to the Langfuse model name.",
|
||||
lastUsed: "Start time of the latest generation using this model",
|
||||
} as const;
|
||||
|
||||
export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
@@ -198,6 +200,20 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "lastUsed",
|
||||
id: "lastUsed",
|
||||
header: "Last used",
|
||||
headerTooltip: {
|
||||
description: modelConfigDescriptions.lastUsed,
|
||||
},
|
||||
enableHiding: true,
|
||||
size: 120,
|
||||
cell: ({ row }) => {
|
||||
const value: Date | null | undefined = row.getValue("lastUsed");
|
||||
return value?.toLocaleString() ?? "";
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "actions",
|
||||
header: "Actions",
|
||||
@@ -246,6 +262,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
prices: model.prices,
|
||||
tokenizerId: model.tokenizerId ?? undefined,
|
||||
config: model.tokenizerConfig,
|
||||
lastUsed: model.lastUsed,
|
||||
serverResponse: model,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,910 @@
|
||||
import { api } from "@/src/utils/api";
|
||||
import { DataTable } from "@/src/components/table/data-table";
|
||||
import TableLink from "@/src/components/table/table-link";
|
||||
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
|
||||
import { useMemo } from "react";
|
||||
import { TokenUsageBadge } from "@/src/components/token-usage-badge";
|
||||
import {
|
||||
NumberParam,
|
||||
StringParam,
|
||||
useQueryParam,
|
||||
useQueryParams,
|
||||
withDefault,
|
||||
} from "use-query-params";
|
||||
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
|
||||
import { formatIntervalSeconds } from "@/src/utils/dates";
|
||||
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
|
||||
import { type LangfuseColumnDef } from "@/src/components/table/types";
|
||||
import {
|
||||
type ObservationLevel,
|
||||
type FilterState,
|
||||
type ObservationOptions,
|
||||
BatchExportTableName,
|
||||
type ObservationType,
|
||||
} from "@langfuse/shared";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { LevelColors } from "@/src/components/level-colors";
|
||||
import { numberFormatter, usdFormatter } from "@/src/utils/numbers";
|
||||
import { observationsTableColsWithOptions } from "@langfuse/shared";
|
||||
import { useOrderByState } from "@/src/features/orderBy/hooks/useOrderByState";
|
||||
import type Decimal from "decimal.js";
|
||||
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
|
||||
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
|
||||
import {
|
||||
getScoreGroupColumnProps,
|
||||
verifyAndPrefixScoreDataAgainstKeys,
|
||||
} from "@/src/features/scores/components/ScoreDetailColumnHelpers";
|
||||
import { useTableDateRange } from "@/src/hooks/useTableDateRange";
|
||||
import { useDebounce } from "@/src/hooks/useDebounce";
|
||||
import { type ScoreAggregate } from "@langfuse/shared";
|
||||
import { useIndividualScoreColumns } from "@/src/features/scores/hooks/useIndividualScoreColumns";
|
||||
import TagList from "@/src/features/tag/components/TagList";
|
||||
import useColumnOrder from "@/src/features/column-visibility/hooks/useColumnOrder";
|
||||
import { BatchExportTableButton } from "@/src/components/BatchExportTableButton";
|
||||
import { useClickhouse } from "@/src/components/layouts/ClickhouseAdminToggle";
|
||||
import { BreakdownTooltip } from "@/src/components/trace/BreakdownToolTip";
|
||||
import { InfoIcon, PlusCircle } from "lucide-react";
|
||||
import { UpsertModelFormDrawer } from "@/src/features/models/components/UpsertModelFormDrawer";
|
||||
import { ColorCodedObservationType } from "@/src/components/trace/ObservationTree";
|
||||
|
||||
export type ObservationsTableRow = {
|
||||
id: string;
|
||||
traceId?: string;
|
||||
startTime: Date;
|
||||
level?: ObservationLevel;
|
||||
statusMessage?: string;
|
||||
endTime?: string;
|
||||
completionStartTime?: Date;
|
||||
latency?: number;
|
||||
timeToFirstToken?: number;
|
||||
// scores holds grouped column with individual scores
|
||||
scores: ScoreAggregate;
|
||||
name?: string;
|
||||
model?: string;
|
||||
// i/o and metadata not set explicitly, but fetched from the server from the cell
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
metadata?: unknown;
|
||||
inputCost?: Decimal;
|
||||
outputCost?: Decimal;
|
||||
totalCost?: Decimal;
|
||||
traceName?: string;
|
||||
usage: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
usageDetails: Record<string, number>;
|
||||
costDetails: Record<string, number>;
|
||||
promptId?: string;
|
||||
promptName?: string;
|
||||
promptVersion?: string;
|
||||
traceTags?: string[];
|
||||
};
|
||||
|
||||
export type ObservationsTableProps = {
|
||||
projectId: string;
|
||||
promptName?: string;
|
||||
promptVersion?: number;
|
||||
modelId?: string;
|
||||
omittedFilter?: string[];
|
||||
};
|
||||
|
||||
export default function ObservationsTable({
|
||||
projectId,
|
||||
promptName,
|
||||
promptVersion,
|
||||
modelId,
|
||||
omittedFilter = [],
|
||||
}: ObservationsTableProps) {
|
||||
const [searchQuery, setSearchQuery] = useQueryParam(
|
||||
"search",
|
||||
withDefault(StringParam, null),
|
||||
);
|
||||
|
||||
const [paginationState, setPaginationState] = useQueryParams({
|
||||
pageIndex: withDefault(NumberParam, 0),
|
||||
pageSize: withDefault(NumberParam, 50),
|
||||
});
|
||||
|
||||
const [rowHeight, setRowHeight] = useRowHeightLocalStorage(
|
||||
"generations",
|
||||
"s",
|
||||
);
|
||||
|
||||
const { selectedOption, dateRange, setDateRangeAndOption } =
|
||||
useTableDateRange(projectId);
|
||||
|
||||
const [inputFilterState, setInputFilterState] = useQueryFilterState(
|
||||
[
|
||||
{
|
||||
column: "type",
|
||||
type: "stringOptions",
|
||||
operator: "any of",
|
||||
value: ["GENERATION"],
|
||||
},
|
||||
],
|
||||
"generations",
|
||||
projectId,
|
||||
);
|
||||
|
||||
const [orderByState, setOrderByState] = useOrderByState({
|
||||
column: "startTime",
|
||||
order: "DESC",
|
||||
});
|
||||
|
||||
const promptNameFilter: FilterState = promptName
|
||||
? [
|
||||
{
|
||||
column: "Prompt Name",
|
||||
type: "string",
|
||||
operator: "=",
|
||||
value: promptName,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const promptVersionFilter: FilterState = promptVersion
|
||||
? [
|
||||
{
|
||||
column: "Prompt Version",
|
||||
type: "number",
|
||||
operator: "=",
|
||||
value: promptVersion,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const modelIdFilter: FilterState = modelId
|
||||
? [
|
||||
{
|
||||
column: "Model ID",
|
||||
type: "string",
|
||||
operator: "=",
|
||||
value: modelId,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const dateRangeFilter: FilterState = dateRange
|
||||
? [
|
||||
{
|
||||
column: "Start Time",
|
||||
type: "datetime",
|
||||
operator: ">=",
|
||||
value: dateRange.from,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const filterState = inputFilterState.concat([
|
||||
...dateRangeFilter,
|
||||
...promptNameFilter,
|
||||
...promptVersionFilter,
|
||||
...modelIdFilter,
|
||||
]);
|
||||
|
||||
const getCountPayload = {
|
||||
projectId,
|
||||
filter: filterState,
|
||||
searchQuery,
|
||||
page: 0,
|
||||
limit: 0,
|
||||
orderBy: null,
|
||||
queryClickhouse: useClickhouse(),
|
||||
};
|
||||
|
||||
const getAllPayload = {
|
||||
...getCountPayload,
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
orderBy: orderByState,
|
||||
queryClickhouse: useClickhouse(),
|
||||
};
|
||||
|
||||
const generations = api.generations.all.useQuery(getAllPayload);
|
||||
const totalCountQuery = api.generations.countAll.useQuery(getCountPayload);
|
||||
|
||||
const totalCount = totalCountQuery.data?.totalCount ?? null;
|
||||
|
||||
const startTimeFilter = filterState.find((f) => f.column === "Start Time");
|
||||
const filterOptions = api.generations.filterOptions.useQuery(
|
||||
{
|
||||
projectId,
|
||||
startTimeFilter:
|
||||
startTimeFilter?.type === "datetime" ? startTimeFilter : undefined,
|
||||
queryClickhouse: useClickhouse(),
|
||||
},
|
||||
{
|
||||
trpc: {
|
||||
context: {
|
||||
skipBatch: true,
|
||||
},
|
||||
},
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Infinity,
|
||||
},
|
||||
);
|
||||
|
||||
const { scoreColumns, scoreKeysAndProps, isColumnLoading } =
|
||||
useIndividualScoreColumns<ObservationsTableRow>({
|
||||
projectId,
|
||||
scoreColumnKey: "scores",
|
||||
selectedFilterOption: selectedOption,
|
||||
});
|
||||
|
||||
const transformFilterOptions = (
|
||||
filterOptions: ObservationOptions | undefined,
|
||||
) => {
|
||||
return observationsTableColsWithOptions(filterOptions).filter(
|
||||
(col) => !omittedFilter?.includes(col.name),
|
||||
);
|
||||
};
|
||||
|
||||
const columns: LangfuseColumnDef<ObservationsTableRow>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
id: "id",
|
||||
header: "ID",
|
||||
size: 100,
|
||||
isPinned: true,
|
||||
cell: ({ row }) => {
|
||||
const observationId = row.getValue("id");
|
||||
const traceId = row.getValue("traceId");
|
||||
return typeof observationId === "string" &&
|
||||
typeof traceId === "string" ? (
|
||||
<TableLink
|
||||
path={`/project/${projectId}/traces/${encodeURIComponent(traceId)}?observation=${encodeURIComponent(observationId)}`}
|
||||
value={observationId}
|
||||
/>
|
||||
) : null;
|
||||
},
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
id: "name",
|
||||
header: "Name",
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
id: "type",
|
||||
header: "Type",
|
||||
size: 100,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const value: ObservationType = row.getValue("type");
|
||||
return value ? (
|
||||
<ColorCodedObservationType observationType={value} />
|
||||
) : undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "traceId",
|
||||
id: "traceId",
|
||||
header: "Trace ID",
|
||||
size: 100,
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("traceId");
|
||||
return typeof value === "string" ? (
|
||||
<TableLink
|
||||
path={`/project/${projectId}/traces/${value}`}
|
||||
value={value}
|
||||
/>
|
||||
) : undefined;
|
||||
},
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "traceName",
|
||||
id: "traceName",
|
||||
header: "Trace Name",
|
||||
size: 150,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "startTime",
|
||||
id: "startTime",
|
||||
header: "Start Time",
|
||||
size: 150,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const value: Date = row.getValue("startTime");
|
||||
return value.toLocaleString();
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "endTime",
|
||||
id: "endTime",
|
||||
header: "End Time",
|
||||
size: 150,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "timeToFirstToken",
|
||||
id: "timeToFirstToken",
|
||||
header: "Time to First Token",
|
||||
size: 150,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const timeToFirstToken: number | undefined =
|
||||
row.getValue("timeToFirstToken");
|
||||
|
||||
return (
|
||||
<span>
|
||||
{timeToFirstToken ? formatIntervalSeconds(timeToFirstToken) : "-"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ ...getScoreGroupColumnProps(isColumnLoading), columns: scoreColumns },
|
||||
{
|
||||
accessorKey: "latency",
|
||||
id: "latency",
|
||||
header: "Latency",
|
||||
size: 100,
|
||||
cell: ({ row }) => {
|
||||
const latency: number | undefined = row.getValue("latency");
|
||||
return latency !== undefined ? (
|
||||
<span>{formatIntervalSeconds(latency)}</span>
|
||||
) : undefined;
|
||||
},
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "tokensPerSecond",
|
||||
id: "tokensPerSecond",
|
||||
header: "Tokens per second",
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const latency: number | undefined = row.getValue("latency");
|
||||
const usage: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
} = row.getValue("usage");
|
||||
return latency !== undefined &&
|
||||
(usage.completionTokens !== 0 || usage.totalTokens !== 0) ? (
|
||||
<span>
|
||||
{usage.completionTokens && latency
|
||||
? Number((usage.completionTokens / latency).toFixed(1))
|
||||
: undefined}
|
||||
</span>
|
||||
) : undefined;
|
||||
},
|
||||
defaultHidden: true,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "inputCost",
|
||||
id: "inputCost",
|
||||
header: "Input Cost",
|
||||
size: 120,
|
||||
cell: ({ row }) => {
|
||||
const value: Decimal | undefined = row.getValue("inputCost");
|
||||
|
||||
return value !== undefined ? (
|
||||
<span>{usdFormatter(value.toNumber())}</span>
|
||||
) : undefined;
|
||||
},
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "outputCost",
|
||||
id: "outputCost",
|
||||
header: "Output Cost",
|
||||
size: 120,
|
||||
cell: ({ row }) => {
|
||||
const value: Decimal | undefined = row.getValue("outputCost");
|
||||
|
||||
return value !== undefined ? (
|
||||
<span>{usdFormatter(value.toNumber())}</span>
|
||||
) : undefined;
|
||||
},
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "totalCost",
|
||||
header: "Total Cost",
|
||||
id: "totalCost",
|
||||
size: 120,
|
||||
cell: ({ row }) => {
|
||||
const value: Decimal | undefined = row.getValue("totalCost");
|
||||
|
||||
return value !== undefined ? (
|
||||
<BreakdownTooltip details={row.original.costDetails} isCost>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{usdFormatter(value.toNumber())}</span>
|
||||
<InfoIcon className="h-3 w-3" />
|
||||
</div>
|
||||
</BreakdownTooltip>
|
||||
) : undefined;
|
||||
},
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "level",
|
||||
id: "level",
|
||||
header: "Level",
|
||||
size: 100,
|
||||
headerTooltip: {
|
||||
description:
|
||||
"Use You can differentiate the importance of observations with the level attribute to control the verbosity of your traces and highlight errors and warnings.",
|
||||
href: "https://langfuse.com/docs/tracing-features/log-levels",
|
||||
},
|
||||
enableHiding: true,
|
||||
cell({ row }) {
|
||||
const value: ObservationLevel | undefined = row.getValue("level");
|
||||
return value ? (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-sm p-0.5 text-xs",
|
||||
LevelColors[value].bg,
|
||||
LevelColors[value].text,
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
) : undefined;
|
||||
},
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "statusMessage",
|
||||
header: "Status Message",
|
||||
id: "statusMessage",
|
||||
size: 150,
|
||||
headerTooltip: {
|
||||
description:
|
||||
"Use a statusMessage to e.g. provide additional information on a status such as level=ERROR.",
|
||||
href: "https://langfuse.com/docs/tracing-features/log-levels",
|
||||
},
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "model",
|
||||
id: "model",
|
||||
header: "Model",
|
||||
size: 150,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const model = row.getValue("model") as string;
|
||||
const modelId = row.getValue("modelId") as string | undefined;
|
||||
|
||||
if (!model) return null;
|
||||
|
||||
return modelId ? (
|
||||
<TableLink
|
||||
path={`/project/${projectId}/models/${modelId}`}
|
||||
value={model}
|
||||
/>
|
||||
) : (
|
||||
<UpsertModelFormDrawer
|
||||
action="create"
|
||||
projectId={projectId}
|
||||
prefilledModelData={{
|
||||
modelName: model,
|
||||
prices:
|
||||
Object.keys(row.original.usageDetails).length > 0
|
||||
? Object.keys(row.original.usageDetails)
|
||||
.filter((key) => key != "total")
|
||||
.reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = 0.000001;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
)
|
||||
: undefined,
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<span>{model}</span>
|
||||
<PlusCircle className="h-3 w-3" />
|
||||
</span>
|
||||
</UpsertModelFormDrawer>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: "modelId",
|
||||
id: "modelId",
|
||||
header: "Model ID",
|
||||
size: 100,
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: "inputTokens",
|
||||
id: "inputTokens",
|
||||
header: "Input Tokens",
|
||||
size: 100,
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const value: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
} = row.getValue("usage");
|
||||
return <span>{numberFormatter(value.promptTokens, 0)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "outputTokens",
|
||||
id: "outputTokens",
|
||||
header: "Output Tokens",
|
||||
size: 100,
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const value: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
} = row.getValue("usage");
|
||||
return <span>{numberFormatter(value.completionTokens, 0)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "totalTokens",
|
||||
id: "totalTokens",
|
||||
header: "Total Tokens",
|
||||
size: 100,
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const value: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
} = row.getValue("usage");
|
||||
return <span>{numberFormatter(value.totalTokens, 0)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "usage",
|
||||
header: "Usage",
|
||||
id: "usage",
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
const value: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
} = row.getValue("usage");
|
||||
return (
|
||||
<BreakdownTooltip details={row.original.usageDetails}>
|
||||
<div className="flex items-center gap-1">
|
||||
<TokenUsageBadge
|
||||
promptTokens={value.promptTokens}
|
||||
completionTokens={value.completionTokens}
|
||||
totalTokens={value.totalTokens}
|
||||
inline
|
||||
/>
|
||||
<InfoIcon className="h-3 w-3" />
|
||||
</div>
|
||||
</BreakdownTooltip>
|
||||
);
|
||||
},
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "input",
|
||||
header: "Input",
|
||||
id: "input",
|
||||
size: 300,
|
||||
cell: ({ row }) => {
|
||||
const observationId: string = row.getValue("id");
|
||||
const traceId: string = row.getValue("traceId");
|
||||
return (
|
||||
<GenerationsDynamicCell
|
||||
observationId={observationId}
|
||||
traceId={traceId}
|
||||
projectId={projectId}
|
||||
startTime={row.getValue("startTime")}
|
||||
col="input"
|
||||
singleLine={rowHeight === "s"}
|
||||
/>
|
||||
);
|
||||
},
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "output",
|
||||
id: "output",
|
||||
header: "Output",
|
||||
size: 300,
|
||||
cell: ({ row }) => {
|
||||
const observationId: string = row.getValue("id");
|
||||
const traceId: string = row.getValue("traceId");
|
||||
return (
|
||||
<GenerationsDynamicCell
|
||||
observationId={observationId}
|
||||
traceId={traceId}
|
||||
projectId={projectId}
|
||||
startTime={row.getValue("startTime")}
|
||||
col="output"
|
||||
singleLine={rowHeight === "s"}
|
||||
/>
|
||||
);
|
||||
},
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "metadata",
|
||||
header: "Metadata",
|
||||
size: 300,
|
||||
headerTooltip: {
|
||||
description: "Add metadata to traces to track additional information.",
|
||||
href: "https://langfuse.com/docs/tracing-features/metadata",
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const observationId: string = row.getValue("id");
|
||||
const traceId: string = row.getValue("traceId");
|
||||
return (
|
||||
<GenerationsDynamicCell
|
||||
observationId={observationId}
|
||||
traceId={traceId}
|
||||
projectId={projectId}
|
||||
startTime={row.getValue("startTime")}
|
||||
col="metadata"
|
||||
singleLine={rowHeight === "s"}
|
||||
/>
|
||||
);
|
||||
},
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "version",
|
||||
id: "version",
|
||||
header: "Version",
|
||||
size: 100,
|
||||
headerTooltip: {
|
||||
description: "Track changes via the version tag.",
|
||||
href: "https://langfuse.com/docs/experimentation",
|
||||
},
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "promptName",
|
||||
id: "promptName",
|
||||
header: "Prompt",
|
||||
headerTooltip: {
|
||||
description: "Link to prompt version in Langfuse prompt management.",
|
||||
href: "https://langfuse.com/docs/prompts",
|
||||
},
|
||||
size: 200,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const promptName = row.original.promptName;
|
||||
const promptVersion = row.original.promptVersion;
|
||||
const value = `${promptName} (v${promptVersion})`;
|
||||
return (
|
||||
promptName &&
|
||||
promptVersion && (
|
||||
<TableLink
|
||||
path={`/project/${projectId}/prompts/${encodeURIComponent(promptName)}?version=${promptVersion}`}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "traceTags",
|
||||
id: "traceTags",
|
||||
header: "Trace Tags",
|
||||
size: 250,
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
cell: ({ row }) => {
|
||||
const traceTags: string[] | undefined = row.getValue("traceTags");
|
||||
return (
|
||||
traceTags && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-x-2 gap-y-1",
|
||||
rowHeight !== "s" && "flex-wrap",
|
||||
)}
|
||||
>
|
||||
<TagList selectedTags={traceTags} isLoading={false} viewOnly />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const [columnVisibility, setColumnVisibilityState] =
|
||||
useColumnVisibility<ObservationsTableRow>(
|
||||
`generationsColumnVisibility-${projectId}`,
|
||||
columns,
|
||||
);
|
||||
|
||||
const [columnOrder, setColumnOrder] = useColumnOrder<ObservationsTableRow>(
|
||||
"generationsColumnOrder",
|
||||
columns,
|
||||
);
|
||||
|
||||
const rows: ObservationsTableRow[] = useMemo(() => {
|
||||
return generations.isSuccess
|
||||
? generations.data.generations.map((generation) => {
|
||||
return {
|
||||
id: generation.id,
|
||||
traceId: generation.traceId ?? undefined,
|
||||
type: generation.type ?? undefined,
|
||||
traceName: generation.traceName ?? "",
|
||||
startTime: generation.startTime,
|
||||
endTime: generation.endTime?.toLocaleString() ?? undefined,
|
||||
timeToFirstToken: generation.timeToFirstToken ?? undefined,
|
||||
scores: verifyAndPrefixScoreDataAgainstKeys(
|
||||
scoreKeysAndProps,
|
||||
generation.scores,
|
||||
),
|
||||
latency: generation.latency ?? undefined,
|
||||
totalCost: generation.calculatedTotalCost ?? undefined,
|
||||
inputCost: generation.calculatedInputCost ?? undefined,
|
||||
outputCost: generation.calculatedOutputCost ?? undefined,
|
||||
name: generation.name ?? undefined,
|
||||
version: generation.version ?? "",
|
||||
model: generation.model ?? "",
|
||||
modelId: generation.modelId ?? undefined,
|
||||
level: generation.level,
|
||||
statusMessage: generation.statusMessage ?? undefined,
|
||||
usage: {
|
||||
promptTokens: generation.promptTokens,
|
||||
completionTokens: generation.completionTokens,
|
||||
totalTokens: generation.totalTokens,
|
||||
},
|
||||
promptId: generation.promptId ?? undefined,
|
||||
promptName: generation.promptName ?? undefined,
|
||||
promptVersion: generation.promptVersion?.toString() ?? undefined,
|
||||
traceTags: generation.traceTags ?? undefined,
|
||||
usageDetails: generation.usageDetails ?? {},
|
||||
costDetails: generation.costDetails ?? {},
|
||||
};
|
||||
})
|
||||
: [];
|
||||
}, [generations, scoreKeysAndProps]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTableToolbar
|
||||
columns={columns}
|
||||
filterColumnDefinition={transformFilterOptions(filterOptions.data)}
|
||||
filterState={inputFilterState}
|
||||
setFilterState={useDebounce(setInputFilterState)}
|
||||
searchConfig={{
|
||||
placeholder: "Search by id, name, traceName, model",
|
||||
updateQuery: setSearchQuery,
|
||||
currentQuery: searchQuery ?? undefined,
|
||||
}}
|
||||
columnsWithCustomSelect={["model", "name", "traceName", "promptName"]}
|
||||
columnVisibility={columnVisibility}
|
||||
setColumnVisibility={setColumnVisibilityState}
|
||||
columnOrder={columnOrder}
|
||||
setColumnOrder={setColumnOrder}
|
||||
rowHeight={rowHeight}
|
||||
setRowHeight={setRowHeight}
|
||||
selectedOption={selectedOption}
|
||||
setDateRangeAndOption={setDateRangeAndOption}
|
||||
actionButtons={
|
||||
<BatchExportTableButton
|
||||
{...{ projectId, filterState, orderByState }}
|
||||
tableName={BatchExportTableName.Generations}
|
||||
key="batchExport"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={
|
||||
generations.isLoading
|
||||
? { isLoading: true, isError: false }
|
||||
: generations.error
|
||||
? {
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: generations.error.message,
|
||||
}
|
||||
: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: rows,
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
totalCount,
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
setOrderBy={setOrderByState}
|
||||
orderBy={orderByState}
|
||||
columnOrder={columnOrder}
|
||||
onColumnOrderChange={setColumnOrder}
|
||||
columnVisibility={columnVisibility}
|
||||
onColumnVisibilityChange={setColumnVisibilityState}
|
||||
rowHeight={rowHeight}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const GenerationsDynamicCell = ({
|
||||
traceId,
|
||||
observationId,
|
||||
projectId,
|
||||
startTime,
|
||||
col,
|
||||
singleLine = false,
|
||||
}: {
|
||||
traceId: string;
|
||||
observationId: string;
|
||||
projectId: string;
|
||||
startTime?: Date;
|
||||
col: "input" | "output" | "metadata";
|
||||
singleLine: boolean;
|
||||
}) => {
|
||||
const observation = api.observations.byId.useQuery(
|
||||
{
|
||||
observationId,
|
||||
traceId,
|
||||
projectId,
|
||||
startTime,
|
||||
queryClickhouse: useClickhouse(),
|
||||
},
|
||||
{
|
||||
enabled: typeof traceId === "string" && typeof observationId === "string",
|
||||
trpc: {
|
||||
context: {
|
||||
skipBatch: true,
|
||||
},
|
||||
},
|
||||
refetchOnMount: false, // prevents refetching loops
|
||||
},
|
||||
);
|
||||
return (
|
||||
<IOTableCell
|
||||
isLoading={observation.isLoading}
|
||||
data={
|
||||
col === "output"
|
||||
? observation.data?.output
|
||||
: col === "input"
|
||||
? observation.data?.input
|
||||
: observation.data?.metadata
|
||||
}
|
||||
className={cn(col === "output" && "bg-accent-light-green")}
|
||||
singleLine={singleLine}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -249,7 +249,7 @@ export function ScoreConfigsTable({ projectId }: { projectId: string }) {
|
||||
rowHeight={rowHeight}
|
||||
setRowHeight={setRowHeight}
|
||||
/>
|
||||
<Card className="mb-4 flex max-h-[calc(100dvh-30rem)] flex-col overflow-hidden">
|
||||
<Card className="mb-4 flex max-h-[60dvh] flex-col overflow-hidden">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={
|
||||
|
||||
@@ -52,6 +52,7 @@ import { BatchExportTableButton } from "@/src/components/BatchExportTableButton"
|
||||
import { useClickhouse } from "@/src/components/layouts/ClickhouseAdminToggle";
|
||||
import { BreakdownTooltip } from "@/src/components/trace/BreakdownToolTip";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { useHasEntitlement } from "@/src/features/entitlements/hooks";
|
||||
|
||||
export type TracesTableRow = {
|
||||
bookmarked: boolean;
|
||||
@@ -241,6 +242,8 @@ export default function TracesTable({
|
||||
cellsLoading: !traceMetrics.data,
|
||||
});
|
||||
|
||||
const hasTraceDeletionEntitlement = useHasEntitlement("trace-deletion");
|
||||
|
||||
const columns: LangfuseColumnDef<TracesTableRow>[] = [
|
||||
{
|
||||
id: "select",
|
||||
@@ -708,7 +711,9 @@ export default function TracesTable({
|
||||
isPinned: true,
|
||||
cell: ({ row }) => {
|
||||
const traceId: TracesTableRow["id"] = row.getValue("id");
|
||||
return traceId && typeof traceId === "string" ? (
|
||||
return traceId &&
|
||||
typeof traceId === "string" &&
|
||||
hasTraceDeletionEntitlement ? (
|
||||
<DeleteButton
|
||||
itemId={traceId}
|
||||
projectId={projectId}
|
||||
|
||||
@@ -257,6 +257,7 @@ export const OpenAiMessageView: React.FC<{
|
||||
messages: z.infer<typeof ChatMlArraySchema>;
|
||||
title?: string;
|
||||
shouldRenderMarkdown?: boolean;
|
||||
collapseLongHistory?: boolean;
|
||||
media?: MediaReturnType[];
|
||||
additionalInput?: Record<string, unknown>;
|
||||
}> = ({
|
||||
@@ -264,11 +265,12 @@ export const OpenAiMessageView: React.FC<{
|
||||
messages,
|
||||
shouldRenderMarkdown = false,
|
||||
media,
|
||||
collapseLongHistory = true,
|
||||
additionalInput,
|
||||
}) => {
|
||||
const COLLAPSE_THRESHOLD = 3;
|
||||
const [isCollapsed, setCollapsed] = useState(
|
||||
messages.length > COLLAPSE_THRESHOLD ? true : null,
|
||||
collapseLongHistory && messages.length > COLLAPSE_THRESHOLD ? true : null,
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -152,8 +152,8 @@ export const ObservationPreview = ({
|
||||
<div className="flex w-full flex-col overflow-y-auto">
|
||||
<CardHeader className="flex flex-row flex-wrap justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle>
|
||||
<span className="mr-2 rounded-sm bg-input p-1 text-xs">
|
||||
<CardTitle className="flex flex-row items-center gap-2">
|
||||
<span className="rounded-sm bg-input p-1 text-xs">
|
||||
{preloadedObservation.type}
|
||||
</span>
|
||||
<span>{preloadedObservation.name}</span>
|
||||
@@ -290,55 +290,58 @@ export const ObservationPreview = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{viewType === "detailed" && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<CommentDrawerButton
|
||||
projectId={preloadedObservation.projectId}
|
||||
objectId={preloadedObservation.id}
|
||||
objectType="OBSERVATION"
|
||||
count={commentCounts?.get(preloadedObservation.id)}
|
||||
/>
|
||||
<div className="flex items-start">
|
||||
<AnnotateDrawer
|
||||
key={"annotation-drawer" + preloadedObservation.id}
|
||||
projectId={projectId}
|
||||
traceId={traceId}
|
||||
observationId={preloadedObservation.id}
|
||||
scores={scores}
|
||||
emptySelectedConfigIds={emptySelectedConfigIds}
|
||||
setEmptySelectedConfigIds={setEmptySelectedConfigIds}
|
||||
type="observation"
|
||||
hasGroupedButton={hasEntitlement}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{viewType === "detailed" && (
|
||||
<>
|
||||
<CommentDrawerButton
|
||||
projectId={preloadedObservation.projectId}
|
||||
objectId={preloadedObservation.id}
|
||||
objectType="OBSERVATION"
|
||||
count={commentCounts?.get(preloadedObservation.id)}
|
||||
/>
|
||||
{hasEntitlement && (
|
||||
<CreateNewAnnotationQueueItem
|
||||
<div className="flex items-start">
|
||||
<AnnotateDrawer
|
||||
key={"annotation-drawer" + preloadedObservation.id}
|
||||
projectId={projectId}
|
||||
objectId={preloadedObservation.id}
|
||||
objectType={AnnotationQueueObjectType.OBSERVATION}
|
||||
traceId={traceId}
|
||||
observationId={preloadedObservation.id}
|
||||
scores={scores}
|
||||
emptySelectedConfigIds={emptySelectedConfigIds}
|
||||
setEmptySelectedConfigIds={setEmptySelectedConfigIds}
|
||||
type="observation"
|
||||
hasGroupedButton={hasEntitlement}
|
||||
/>
|
||||
{hasEntitlement && (
|
||||
<CreateNewAnnotationQueueItem
|
||||
projectId={projectId}
|
||||
objectId={preloadedObservation.id}
|
||||
objectType={AnnotationQueueObjectType.OBSERVATION}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{observationWithInputAndOutput.data?.type === "GENERATION" && (
|
||||
<JumpToPlaygroundButton
|
||||
source="generation"
|
||||
generation={observationWithInputAndOutput.data}
|
||||
analyticsEventName="trace_detail:test_in_playground_button_click"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{observationWithInputAndOutput.data?.type === "GENERATION" && (
|
||||
<JumpToPlaygroundButton
|
||||
source="generation"
|
||||
generation={observationWithInputAndOutput.data}
|
||||
analyticsEventName="trace_detail:test_in_playground_button_click"
|
||||
/>
|
||||
)}
|
||||
{observationWithInputAndOutput.data ? (
|
||||
<NewDatasetItemFromTrace
|
||||
traceId={preloadedObservation.traceId}
|
||||
observationId={preloadedObservation.id}
|
||||
projectId={projectId}
|
||||
input={observationWithInputAndOutput.data.input}
|
||||
output={observationWithInputAndOutput.data.output}
|
||||
metadata={observationWithInputAndOutput.data.metadata}
|
||||
key={preloadedObservation.id}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{observationWithInputAndOutput.data ? (
|
||||
<NewDatasetItemFromTrace
|
||||
traceId={preloadedObservation.traceId}
|
||||
observationId={preloadedObservation.id}
|
||||
projectId={projectId}
|
||||
input={observationWithInputAndOutput.data.input}
|
||||
output={observationWithInputAndOutput.data.output}
|
||||
metadata={observationWithInputAndOutput.data.metadata}
|
||||
key={preloadedObservation.id}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{selectedTab === "preview" && (
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { type NestedObservation } from "@/src/utils/types";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { type APIScore, type Trace, type $Enums } from "@langfuse/shared";
|
||||
import {
|
||||
type APIScore,
|
||||
type Trace,
|
||||
type $Enums,
|
||||
ObservationLevel,
|
||||
} from "@langfuse/shared";
|
||||
import { GroupedScoreBadges } from "@/src/components/grouped-score-badge";
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { type ObservationReturnType } from "@/src/server/api/routers/traces";
|
||||
import { LevelColors } from "@/src/components/level-colors";
|
||||
import { formatIntervalSeconds } from "@/src/utils/dates";
|
||||
import { MinusCircle, MinusIcon, PlusCircleIcon, PlusIcon } from "lucide-react";
|
||||
import {
|
||||
InfoIcon,
|
||||
MinusCircle,
|
||||
MinusIcon,
|
||||
PlusCircleIcon,
|
||||
PlusIcon,
|
||||
} from "lucide-react";
|
||||
import { Toggle } from "@/src/components/ui/toggle";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
@@ -39,10 +50,12 @@ export const ObservationTree = ({
|
||||
traceCommentCounts?: Map<string, number>;
|
||||
className?: string;
|
||||
showExpandControls?: boolean;
|
||||
minLevel?: ObservationLevel;
|
||||
setMinLevel?: React.Dispatch<React.SetStateAction<ObservationLevel>>;
|
||||
}) => {
|
||||
const nestedObservations = useMemo(
|
||||
() => nestObservations(props.observations),
|
||||
[props.observations],
|
||||
const { nestedObservations, hiddenObservationsCount } = useMemo(
|
||||
() => nestObservations(props.observations, props.minLevel),
|
||||
[props.observations, props.minLevel],
|
||||
);
|
||||
const totalCost = useMemo(() => {
|
||||
return calculateDisplayTotalCost({
|
||||
@@ -82,6 +95,23 @@ export const ObservationTree = ({
|
||||
props.trace.latency ? props.trace.latency * 1000 : undefined
|
||||
}
|
||||
/>
|
||||
{props.minLevel && hiddenObservationsCount > 0 ? (
|
||||
<span className="flex items-center gap-1 p-2 py-4">
|
||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="flex flex-row gap-1 text-sm text-muted-foreground">
|
||||
<p>
|
||||
{hiddenObservationsCount} observations below {props.minLevel}{" "}
|
||||
level are hidden.
|
||||
</p>
|
||||
<p
|
||||
className="cursor-pointer underline"
|
||||
onClick={() => props.setMinLevel?.(ObservationLevel.DEBUG)}
|
||||
>
|
||||
Show all
|
||||
</p>
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -223,6 +253,7 @@ const ObservationTreeNode = (props: {
|
||||
{props.comments ? (
|
||||
<CommentCountIcon
|
||||
count={props.comments.get(observation.id)}
|
||||
className={treeItemColors.get(observation.type)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -353,7 +384,7 @@ const ObservationTreeNode = (props: {
|
||||
);
|
||||
};
|
||||
|
||||
const ColorCodedObservationType = (props: {
|
||||
export const ColorCodedObservationType = (props: {
|
||||
observationType: $Enums.ObservationType;
|
||||
}) => {
|
||||
return (
|
||||
|
||||
@@ -169,42 +169,45 @@ export const TracePreview = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{viewType === "detailed" && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<CommentDrawerButton
|
||||
projectId={trace.projectId}
|
||||
objectId={trace.id}
|
||||
objectType="TRACE"
|
||||
count={commentCounts?.get(trace.id)}
|
||||
/>
|
||||
<div className="flex items-start">
|
||||
<AnnotateDrawer
|
||||
key={"annotation-drawer" + trace.id}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{viewType === "detailed" && (
|
||||
<>
|
||||
<CommentDrawerButton
|
||||
projectId={trace.projectId}
|
||||
traceId={trace.id}
|
||||
scores={scores}
|
||||
emptySelectedConfigIds={emptySelectedConfigIds}
|
||||
setEmptySelectedConfigIds={setEmptySelectedConfigIds}
|
||||
hasGroupedButton={hasEntitlement}
|
||||
objectId={trace.id}
|
||||
objectType="TRACE"
|
||||
count={commentCounts?.get(trace.id)}
|
||||
/>
|
||||
{hasEntitlement && (
|
||||
<CreateNewAnnotationQueueItem
|
||||
<div className="flex items-start">
|
||||
<AnnotateDrawer
|
||||
key={"annotation-drawer" + trace.id}
|
||||
projectId={trace.projectId}
|
||||
objectId={trace.id}
|
||||
objectType={AnnotationQueueObjectType.TRACE}
|
||||
traceId={trace.id}
|
||||
scores={scores}
|
||||
emptySelectedConfigIds={emptySelectedConfigIds}
|
||||
setEmptySelectedConfigIds={setEmptySelectedConfigIds}
|
||||
hasGroupedButton={hasEntitlement}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<NewDatasetItemFromTrace
|
||||
traceId={trace.id}
|
||||
projectId={trace.projectId}
|
||||
input={trace.input}
|
||||
output={trace.output}
|
||||
metadata={trace.metadata}
|
||||
key={trace.id}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasEntitlement && (
|
||||
<CreateNewAnnotationQueueItem
|
||||
projectId={trace.projectId}
|
||||
objectId={trace.id}
|
||||
objectType={AnnotationQueueObjectType.TRACE}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<NewDatasetItemFromTrace
|
||||
traceId={trace.id}
|
||||
projectId={trace.projectId}
|
||||
input={trace.input}
|
||||
output={trace.output}
|
||||
metadata={trace.metadata}
|
||||
key={trace.id}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{selectedTab === "preview" && (
|
||||
|
||||
@@ -304,7 +304,7 @@ export function TraceTimelineView({
|
||||
};
|
||||
}, [parentRef]);
|
||||
|
||||
const nestedObservations = useMemo(
|
||||
const { nestedObservations } = useMemo(
|
||||
() => nestObservations(observations),
|
||||
[observations],
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user