Compare commits

...
58 Commits
Author SHA1 Message Date
Max Deichmann bc323772c1 chore: release v3.9.0 2025-01-17 12:26:06 +01:00
Marc KlingenandGitHub fcef737dc0 feat(cloud): snapshot usage metering to postgres db (#5075) 2025-01-17 12:13:46 +01:00
marliessophieandGitHub 4bf77d68ed feat(model): add support for o1 model costs (#5073)
* feat(model): add support for o1 model costs

* order cost list by ID

* push

* update contributing

* push
2025-01-17 10:23:25 +00:00
Steffen SchmitzandGitHub d7ec924ed8 chore: remove outdated postgres executions (#5069) 2025-01-17 11:10:20 +01:00
Marc KlingenandGitHub cefaf56613 chore(cloud): region details (#5080) 2025-01-17 00:18:00 +01:00
Max DeichmannandGitHub 6aff9b0f4a fix: quickly recover from metering queue errors (#5077)
fix: quickly reciver from metering queue errors
2025-01-16 22:11:55 +01:00
Max DeichmannandGitHub 815de149aa fix: retry stripe apis (#5076)
push
2025-01-16 21:57:05 +01:00
Max DeichmannandGitHub bc8923485e fix: add race condition check to billing queue (#5074) 2025-01-16 20:40:59 +01:00
Max DeichmannandGitHub 1a60425692 fix: retry stripe in metering logic and meter again if error ocurred (#5072) 2025-01-16 19:42:10 +01:00
marliessophieandGitHub 1d3ac98123 style(ui): improve observation type label colors for readbility, accessibility (#5071)
* style(ui): adjust muted colors for readbility, accessibility

* style(ui): adjust comment labels given type

* margins

* push
2025-01-16 16:27:47 +00:00
Max DeichmannandGitHub de67ec1a02 chore: add cloud meter event trigger (#5068)
* push

* push

* feat: add metering event
2025-01-16 15:22:59 +00:00
marliessophieandGitHub 9510995a67 fix(ui-tables): populate filter operator by default (#5067) 2025-01-16 14:21:48 +00:00
Marc KlingenandGitHub 4623d76155 fix(cloud): remove race condition check in metering cron job (#5066) 2025-01-16 11:31:00 +01:00
Steffen SchmitzandGitHub 6a6abc6bd8 chore: skip zod parse for json parsing (#5063)
* chore: skip zod parse for json parsing

* chore: lint

* chore: add JsonNested type

* chore: add one more test for invalid json
2025-01-16 09:32:41 +00:00
Max DeichmannandGitHub ddee827e22 chore: update architecture diagram (#5055) 2025-01-15 22:07:16 +00:00
Max DeichmannandGitHub e327db8bbd chore: correct data region (#5054) 2025-01-15 22:01:58 +00:00
Max DeichmannandGitHub 9229d759e1 chore: add project_id to get clickhouse query ingestion (#5051) 2025-01-15 20:09:53 +01:00
Hassieb PakzadandGitHub d8d8ab7060 fix(jump-to-playground): hide button if message parsing will fail (#5048) 2025-01-15 16:55:58 +01:00
Steffen SchmitzandGitHub b851359923 chore: remove legacy v3-migration env variables (#5041)
* chore: remove legacy v3-migration env variables

* chore: linting

* chore: move legacy files to current env

* chore: remove outdated tests

* chore: add async ingestion test

* chore: reduce delay for tests

* chore: lint

* chore: skip scores and enable redis on CI

* chore: remove outdated test classes
2025-01-15 15:39:20 +00:00
marliessophieandGitHub b36ea74314 fix(ui-annoation): bug when re-submitting score comments on categorical scores (#5045)
fix(ui-annoation): re-submit score comments on categorical scores
2025-01-15 15:12:46 +00:00
Hassieb PakzadandGitHub b2f103549a feat(trace-tree-iu): add hint for hidden obs (#5044) 2025-01-15 14:47:39 +01:00
marliessophieandGitHub f660b80438 style(ui-tables): fix position of No results. text if table has no data (#5043)
* style(ui-tables): fix position of `No results.` text if table has no data

* fix: dom nesting
2025-01-15 13:18:23 +00:00
Steffen SchmitzandGitHub 5bbfffef50 chore: allow any value for input/output in ingestion endpoint (#5040)
* chore: allow any value for input/output in ingestion endpoint

* chore: add explanation
2025-01-15 10:36:13 +00:00
Hassieb PakzadandGitHub cb6f95dc93 feat(trace-detail-ui): filter by minObservationlvl (#5039) 2025-01-15 11:40:57 +01:00
Max Deichmann 8345ce48fc chore: release v3.8.0 2025-01-15 10:26:15 +01:00
Max DeichmannandGitHub f71d28900a perf: use streaming for observation and trace list endpoints (#5038)
push
2025-01-15 10:25:28 +01:00
Hassieb PakzadandGitHub 14eaf62e45 fix(ingestion): overwriting of observation level (#5027) 2025-01-15 09:43:41 +01:00
Steffen SchmitzandGitHub f8e61c2659 chore: fallback to error.status for eval error handling (#5037) 2025-01-15 07:40:36 +00:00
marliessophieandGitHub 3b205b45ce chore(ui-dashboard): save selected score graphs in the dashboard to localStorage (project-bound) (#5029)
* chore(ui-dashboard): save selected score graphs in the dashboard to `localStorage` (project-bound)

* add comment

* push
2025-01-14 19:14:17 +00:00
Max DeichmannandGitHub 7260c44486 fix: correctly order sessions by time (#5028)
* fix: correctly order sessions by time

* fix: correctly order sessions by time

* fix: correctly order sessions by time
2025-01-14 19:00:53 +00:00
marliessophieandGitHub 92ec2b60e2 feat(ui-tabels): Count of group selection on column visibility (#5024) 2025-01-14 16:26:01 +00:00
Steffen SchmitzandGitHub a856488f2d chore: upgrade bullmq to 5.34.10 (#5022)
* chore: upgrade bullmq to 5.34.10

* chore: adjust tests
2025-01-14 13:59:55 +00:00
Steffen SchmitzandGitHub a18f09fd8d fix: process status codes on eval execution errors (#5013)
* fix: process status codes on eval execution errors

* chore: error handling
2025-01-14 12:29:44 +00:00
Hassieb PakzadandGitHub 2a95dd1da0 fix(openapi-models): modelusageunit to optional (#5015) 2025-01-14 10:25:07 +01:00
Hassieb PakzadandGitHub 04ebd5994f fix(fern-models): unit to optional commons.model (#5014) 2025-01-14 10:22:32 +01:00
Hassieb PakzadandGitHub 268b741a3a chore(models): update fern types startDate unit (#5004) 2025-01-13 19:53:41 +01:00
Steffen SchmitzandGitHub 8b686e9b31 fix: fail open on redis errors in ratelimitservice (#5001) 2025-01-13 17:29:17 +00:00
Max Deichmann e802a5c23a chore: release v3.7.1 2025-01-13 14:39:24 +01:00
Max DeichmannandGitHub 5180e2382e fix: drop dataset run item temp tables from all CH nodes (#4999) 2025-01-13 14:38:11 +01:00
Max DeichmannandGitHub 2cae1253d3 fix: fix session table sorting (#4995)
* fix: fix session table sorting

* push
2025-01-13 12:18:37 +00:00
ClemoandGitHub be2414a82c docs: fix typo 2025-01-13 10:54:43 +01:00
ClemoandGitHub 95e63c6f78 docs: Update careers 2025-01-13 10:53:54 +01:00
Marc Klingen 32b6502d13 chore: release v3.7.0 2025-01-12 09:59:41 +01:00
Marc KlingenandGitHub 1bc33c7b92 fix: telemetry error "invalid input syntax for type interval" (#4991) 2025-01-12 09:57:46 +01:00
Marc KlingenandGitHub 4967b20478 fix(ui): support menu forwardref error, memo list of options, keys for separators (#4989)
fix(ui): support menu forwardref error
2025-01-12 03:33:37 +00:00
Marc KlingenandGitHub 1130894cdc chore: remove old launch week notifications (#4986) 2025-01-12 03:18:13 +00:00
Marc KlingenandGitHub af276e9e78 feat(cloud): add banner for status page announcement (#4988)
* wip

* fix(ui): "chatisvisble not a function"

* csp

* feat(cloud): add banner for status page announcement
2025-01-12 03:17:45 +00:00
Marc KlingenandGitHub d69dbd81a2 fix(ui): chatIsVisible rendering issues (#4987)
fix(ui): "chatisvisble not a function"
2025-01-12 02:43:25 +00:00
Marc KlingenandGitHub 7e807c0de8 feat(annotation-queue): show "add to dataset" button on trace preview (#4985) 2025-01-12 02:24:57 +00:00
Marc KlingenandGitHub cfa2ac6da9 chore(ui): improve button labels for playground and experiments on prompt view (#4984) 2025-01-12 02:11:49 +00:00
Marc KlingenandGitHub 952a550847 feat(ui): add support dropdown menu to replace /support page (#4983) 2025-01-12 02:05:46 +00:00
Marc KlingenandGitHub 6199903117 chore(ui): add descriptions to the setup onboarding flow (#4982) 2025-01-12 00:00:19 +00:00
Marc KlingenandGitHub 88cdb8999d chore(ui): do not collapse chat messages on prompt view (#4981) 2025-01-11 23:48:35 +00:00
Marc KlingenGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
9ad2ef1f91 fix(ui): docs menu item should open in new tab (#4980)
* fix(ui): docs menu item should open in new tab

* Update web/src/components/nav-main.tsx

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-01-11 21:11:37 +00:00
Marc KlingenandGitHub 7a9717cb5e chore(ui): remove introducing orgs banner (#4979) 2025-01-11 20:59:51 +00:00
Marc KlingenandGitHub ce31a806c7 chore(cloud): improve ui of cloud region selection, remove login error notice (#4978)
chore(cloud): imrpove ui of cloud region selection, remove login error notice
2025-01-11 18:03:20 +00:00
Max DeichmannandGitHub e7e6cf1b99 feat: script to replay missing events (#4966)
* feat: script to replay missing events

* feat: script to replay missing events
2025-01-10 15:40:49 +00:00
Steffen SchmitzandGitHub 9f76457f22 chore: stream clickhouse results to posthog to reduce memory consumption (#4967)
* chore: stream clickhouse results to posthog to reduce memory consumption

* chore: end span after streaming all results

* chore: tracing for clickhouse streaming
2025-01-10 13:42:03 +00:00
134 changed files with 3910 additions and 11856 deletions
-86
View File
@@ -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
+6 -3
View File
@@ -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
-1
View File
@@ -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
View File
@@ -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
+9 -3
View File
@@ -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
-4
View File
@@ -63,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
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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>
+3 -2
View File
@@ -1,4 +1,5 @@
{
"organization": "finto",
"organization": "langfuse",
"version": "0.43.7"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.6.2",
"version": "3.9.0",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
+1 -1
View File
@@ -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",
+12
View File
@@ -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;
@@ -611,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;
@@ -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");
+23
View File
@@ -1026,3 +1026,26 @@ model QueueBackUp {
@@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")
}
+3 -9
View File
@@ -33,15 +33,6 @@ const EnvSchema = z.object({
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()
@@ -72,6 +63,9 @@ const EnvSchema = z.object({
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> =
+2 -2
View File
@@ -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);
}
}
+1
View File
@@ -33,6 +33,7 @@ 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";
@@ -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(),
+2
View File
@@ -119,6 +119,7 @@ export enum QueueName {
PostHogIntegrationQueue = "posthog-integration-queue",
PostHogIntegrationProcessingQueue = "posthog-integration-processing-queue",
CoreDataS3ExportQueue = "core-data-s3-export-queue",
MeteringDataPostgresExportQueue = "metering-data-postgres-export-queue",
}
export enum QueueJobs {
@@ -136,6 +137,7 @@ export enum QueueJobs {
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 * * * *" },
},
);
@@ -13,6 +13,7 @@ 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) {
@@ -44,6 +45,8 @@ export function getQueue(queueName: QueueName): Queue | null {
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,64 @@
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 3 * * *" }, // every day at 3:30am UTC
repeat: { pattern: "0 * * * *" }, // initially, run every hour
},
)
.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;
@@ -2,6 +2,7 @@ import {
commandClickhouse,
parseClickhouseUTCDateTimeFormat,
queryClickhouse,
queryClickhouseStream,
upsertClickhouse,
} from "./clickhouse";
import { ObservationLevel } from "@prisma/client";
@@ -1214,11 +1215,11 @@ 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,
@@ -1252,7 +1253,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 +1263,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,
},
};
}
};
@@ -3,6 +3,7 @@ import {
commandClickhouse,
parseClickhouseUTCDateTimeFormat,
queryClickhouse,
queryClickhouseStream,
upsertClickhouse,
} from "./clickhouse";
import { FilterList } from "../queries/clickhouse-sql/clickhouse-filter";
@@ -203,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;
@@ -226,6 +231,9 @@ export const getScoresGroupedByNameSourceType = async (projectId: string) => {
query: query,
params: {
projectId: projectId,
...(timestamp
? { timestamp: convertDateToClickhouseDateTime(timestamp) }
: {}),
},
});
@@ -669,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,
@@ -697,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,
@@ -707,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 {
@@ -627,11 +628,11 @@ 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,
@@ -665,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,
@@ -675,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[]) => {
@@ -189,17 +189,28 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
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),
);
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",
"totalUsage",
"outputUsage",
"inputUsage",
].includes(orderBy?.column));
const selectMetrics = select === "metrics" || hasMetricsFilter;
+4 -6
View File
@@ -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,13 +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);
return jsonSchema.parse(parsedJson);
return JSON.parse(json);
} catch (error) {
const parsed = jsonSchema.safeParse(json);
return parsed.success ? parsed.data : json;
return json;
}
};
+337 -221
View File
@@ -102,7 +102,7 @@ importers:
version: 7.12.0(eslint@8.57.0)(typescript@5.4.5)
'@vercel/style-guide':
specifier: ^6.0.0
version: 6.0.0(@next/eslint-plugin-next@14.2.15)(eslint@8.57.0)(jest@29.7.0)(prettier@3.3.3)(typescript@5.4.5)(vitest@2.1.2)
version: 6.0.0(@next/eslint-plugin-next@14.2.15)(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0))(prettier@3.3.3)(typescript@5.4.5)(vitest@2.1.2(@types/node@20.14.8)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5))(terser@5.36.0))
eslint-config-next:
specifier: ^14.2.15
version: 14.2.15(eslint@8.57.0)(typescript@5.4.5)
@@ -184,8 +184,8 @@ importers:
specifier: ^2.4.3
version: 2.4.3
bullmq:
specifier: ^5.12.10
version: 5.12.10
specifier: ^5.34.10
version: 5.34.10
dd-trace:
specifier: ^5.23.1
version: 5.23.1
@@ -312,7 +312,7 @@ importers:
version: 0.0.4
'@appsignal/opentelemetry-instrumentation-bullmq':
specifier: ^0.7.3
version: 0.7.3(bullmq@5.12.10)
version: 0.7.3(bullmq@5.34.10)
'@baselime/trpc-opentelemetry-middleware':
specifier: ^0.1.2
version: 0.1.2(@opentelemetry/api@1.9.0)(@trpc/server@10.45.2)
@@ -536,8 +536,8 @@ importers:
specifier: ^2.4.3
version: 2.4.3
bullmq:
specifier: ^5.12.10
version: 5.12.10
specifier: ^5.34.10
version: 5.34.10
class-variance-authority:
specifier: ^0.7.0
version: 0.7.0
@@ -582,7 +582,7 @@ importers:
version: 0.27.4
langchain:
specifier: ^0.3.6
version: 0.3.6(@langchain/anthropic@0.3.8(@langchain/core@0.3.18(openai@4.72.0(zod@3.23.8))))(@langchain/aws@0.1.2(@langchain/core@0.3.18(openai@4.72.0(zod@3.23.8))))(@langchain/core@0.3.18(openai@4.72.0(zod@3.23.8)))(@langchain/google-vertexai@0.1.3(@langchain/core@0.3.18(openai@4.72.0(zod@3.23.8)))(zod@3.23.8))(axios@1.7.7)(handlebars@4.7.8)(openai@4.72.0(zod@3.23.8))
version: 0.3.6(@langchain/anthropic@0.3.8(@langchain/core@0.3.18(openai@4.72.0(zod@3.23.8))))(@langchain/aws@0.1.2(@aws-sdk/client-sts@3.675.0)(@langchain/core@0.3.18(openai@4.72.0(zod@3.23.8))))(@langchain/core@0.3.18(openai@4.72.0(zod@3.23.8)))(@langchain/google-vertexai@0.1.3(@langchain/core@0.3.18(openai@4.72.0(zod@3.23.8)))(zod@3.23.8))(axios@1.7.7)(handlebars@4.7.8)(openai@4.72.0(zod@3.23.8))
lodash:
specifier: ^4.17.21
version: 4.17.21
@@ -698,7 +698,7 @@ importers:
version: 0.5.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5)))
'@testing-library/jest-dom':
specifier: ^6.4.6
version: 6.4.6(@jest/globals@29.7.0)(@types/jest@29.5.12)(jest@29.7.0(@types/node@20.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5)))(vitest@2.1.2(@types/node@20.10.5))
version: 6.4.6(@jest/globals@29.7.0)(@types/jest@29.5.12)(jest@29.7.0(@types/node@20.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5)))(vitest@2.1.2(@types/node@20.10.5)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.10.5)(typescript@5.4.5))(terser@5.36.0))
'@testing-library/react':
specifier: ^15.0.7
version: 15.0.7(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -794,7 +794,7 @@ importers:
version: 0.0.4
'@appsignal/opentelemetry-instrumentation-bullmq':
specifier: ^0.7.3
version: 0.7.3(bullmq@5.12.10)
version: 0.7.3(bullmq@5.34.10)
'@clickhouse/client':
specifier: ^1.4.0
version: 1.4.0
@@ -844,11 +844,14 @@ importers:
specifier: ^2.5.0
version: 2.5.0
bullmq:
specifier: ^5.12.10
version: 5.12.10
specifier: ^5.34.10
version: 5.34.10
cors:
specifier: ^2.8.5
version: 2.8.5
csv-parse:
specifier: ^5.6.0
version: 5.6.0
dd-trace:
specifier: ^5.23.1
version: 5.23.1
@@ -5719,8 +5722,8 @@ packages:
builtins@5.1.0:
resolution: {integrity: sha512-SW9lzGTLvWTP1AY8xeAMZimqDrIaSdLQUcVr9DMef51niJ022Ri87SwRRKYm4A6iHfkPaiVUu/Duw2Wc4J7kKg==}
bullmq@5.12.10:
resolution: {integrity: sha512-lXH8Caj+FvYHiBS0QBEpQOq57RcVuEPziBC5cBlWguCVNfn1UMSri22bRrynDKuof8o9XB43ctmYASUpoa0DeQ==}
bullmq@5.34.10:
resolution: {integrity: sha512-ia6EzpQm1ZPq6GUBSLyfvzJrhdBTd1f3Gn2g9pFtLX4hBOob6QHmcmBzGgPlSCyr/i2Qfe4OdjS21bRd02srbw==}
bundle-name@4.1.0:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
@@ -6173,6 +6176,9 @@ packages:
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
csv-parse@5.6.0:
resolution: {integrity: sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==}
cytoscape-cose-bilkent@4.1.0:
resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==}
peerDependencies:
@@ -8946,8 +8952,8 @@ packages:
resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==}
hasBin: true
msgpackr@1.10.1:
resolution: {integrity: sha512-r5VRLv9qouXuLiIBrLpl2d5ZvPt8svdQTl5/vMvE4nzDMyEX4sgW5yWhuBBj5UmgwOTWj8CIdSXn5sAfsHAWIQ==}
msgpackr@1.11.2:
resolution: {integrity: sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g==}
msw@2.6.5:
resolution: {integrity: sha512-PnlnTpUlOrj441kYQzzFhzMzMCGFT6a2jKUBG7zSpLkYS5oh8Arrbc0dL8/rNAtxaoBy0EVs2mFqj2qdmWK7lQ==}
@@ -11651,14 +11657,14 @@ snapshots:
'@types/node': 18.19.25
tiktoken: 1.0.15
'@appsignal/opentelemetry-instrumentation-bullmq@0.7.3(bullmq@5.12.10)':
'@appsignal/opentelemetry-instrumentation-bullmq@0.7.3(bullmq@5.34.10)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.27.0
flat: 5.0.2
optionalDependencies:
bullmq: 5.12.10
bullmq: 5.34.10
transitivePeerDependencies:
- supports-color
@@ -12338,6 +12344,26 @@ snapshots:
- '@aws-sdk/client-sso-oidc'
- aws-crt
'@aws-sdk/credential-provider-ini@3.675.0(@aws-sdk/client-sts@3.675.0)':
dependencies:
'@aws-sdk/client-sts': 3.675.0
'@aws-sdk/core': 3.667.0
'@aws-sdk/credential-provider-env': 3.667.0
'@aws-sdk/credential-provider-http': 3.667.0
'@aws-sdk/credential-provider-process': 3.667.0
'@aws-sdk/credential-provider-sso': 3.675.0(@aws-sdk/client-sso-oidc@3.668.0(@aws-sdk/client-sts@3.668.0))
'@aws-sdk/credential-provider-web-identity': 3.667.0(@aws-sdk/client-sts@3.675.0)
'@aws-sdk/types': 3.667.0
'@smithy/credential-provider-imds': 3.2.4
'@smithy/property-provider': 3.1.7
'@smithy/shared-ini-file-loader': 3.1.8
'@smithy/types': 3.5.0
tslib: 2.6.3
transitivePeerDependencies:
- '@aws-sdk/client-sso-oidc'
- aws-crt
optional: true
'@aws-sdk/credential-provider-node@3.668.0(@aws-sdk/client-sso-oidc@3.668.0(@aws-sdk/client-sts@3.668.0))(@aws-sdk/client-sts@3.668.0)':
dependencies:
'@aws-sdk/credential-provider-env': 3.667.0
@@ -12357,26 +12383,6 @@ snapshots:
- '@aws-sdk/client-sts'
- aws-crt
'@aws-sdk/credential-provider-node@3.675.0':
dependencies:
'@aws-sdk/credential-provider-env': 3.667.0
'@aws-sdk/credential-provider-http': 3.667.0
'@aws-sdk/credential-provider-ini': 3.675.0(@aws-sdk/client-sso-oidc@3.668.0(@aws-sdk/client-sts@3.668.0))(@aws-sdk/client-sts@3.668.0)
'@aws-sdk/credential-provider-process': 3.667.0
'@aws-sdk/credential-provider-sso': 3.675.0(@aws-sdk/client-sso-oidc@3.668.0(@aws-sdk/client-sts@3.668.0))
'@aws-sdk/credential-provider-web-identity': 3.667.0(@aws-sdk/client-sts@3.668.0)
'@aws-sdk/types': 3.667.0
'@smithy/credential-provider-imds': 3.2.4
'@smithy/property-provider': 3.1.7
'@smithy/shared-ini-file-loader': 3.1.8
'@smithy/types': 3.5.0
tslib: 2.6.3
transitivePeerDependencies:
- '@aws-sdk/client-sso-oidc'
- '@aws-sdk/client-sts'
- aws-crt
optional: true
'@aws-sdk/credential-provider-node@3.675.0(@aws-sdk/client-sso-oidc@3.668.0(@aws-sdk/client-sts@3.668.0))(@aws-sdk/client-sts@3.668.0)':
dependencies:
'@aws-sdk/credential-provider-env': 3.667.0
@@ -12415,6 +12421,26 @@ snapshots:
- '@aws-sdk/client-sts'
- aws-crt
'@aws-sdk/credential-provider-node@3.675.0(@aws-sdk/client-sts@3.675.0)':
dependencies:
'@aws-sdk/credential-provider-env': 3.667.0
'@aws-sdk/credential-provider-http': 3.667.0
'@aws-sdk/credential-provider-ini': 3.675.0(@aws-sdk/client-sts@3.675.0)
'@aws-sdk/credential-provider-process': 3.667.0
'@aws-sdk/credential-provider-sso': 3.675.0(@aws-sdk/client-sso-oidc@3.668.0(@aws-sdk/client-sts@3.668.0))
'@aws-sdk/credential-provider-web-identity': 3.667.0(@aws-sdk/client-sts@3.675.0)
'@aws-sdk/types': 3.667.0
'@smithy/credential-provider-imds': 3.2.4
'@smithy/property-provider': 3.1.7
'@smithy/shared-ini-file-loader': 3.1.8
'@smithy/types': 3.5.0
tslib: 2.6.3
transitivePeerDependencies:
- '@aws-sdk/client-sso-oidc'
- '@aws-sdk/client-sts'
- aws-crt
optional: true
'@aws-sdk/credential-provider-process@3.667.0':
dependencies:
'@aws-sdk/core': 3.667.0
@@ -13711,12 +13737,41 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@inquirer/confirm@5.0.2(@types/node@20.10.5)':
dependencies:
'@inquirer/core': 10.1.0(@types/node@20.10.5)
'@inquirer/type': 3.0.1(@types/node@20.10.5)
'@types/node': 20.10.5
optional: true
'@inquirer/confirm@5.0.2(@types/node@20.11.29)':
dependencies:
'@inquirer/core': 10.1.0(@types/node@20.11.29)
'@inquirer/type': 3.0.1(@types/node@20.11.29)
'@types/node': 20.11.29
'@inquirer/confirm@5.0.2(@types/node@20.14.8)':
dependencies:
'@inquirer/core': 10.1.0(@types/node@20.14.8)
'@inquirer/type': 3.0.1(@types/node@20.14.8)
'@types/node': 20.14.8
optional: true
'@inquirer/core@10.1.0(@types/node@20.10.5)':
dependencies:
'@inquirer/figures': 1.0.8
'@inquirer/type': 3.0.1(@types/node@20.10.5)
ansi-escapes: 4.3.2
cli-width: 4.1.0
mute-stream: 2.0.0
signal-exit: 4.1.0
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
yoctocolors-cjs: 2.1.2
transitivePeerDependencies:
- '@types/node'
optional: true
'@inquirer/core@10.1.0(@types/node@20.11.29)':
dependencies:
'@inquirer/figures': 1.0.8
@@ -13731,14 +13786,39 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
'@inquirer/core@10.1.0(@types/node@20.14.8)':
dependencies:
'@inquirer/figures': 1.0.8
'@inquirer/type': 3.0.1(@types/node@20.14.8)
ansi-escapes: 4.3.2
cli-width: 4.1.0
mute-stream: 2.0.0
signal-exit: 4.1.0
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
yoctocolors-cjs: 2.1.2
transitivePeerDependencies:
- '@types/node'
optional: true
'@inquirer/figures@1.0.3': {}
'@inquirer/figures@1.0.8': {}
'@inquirer/type@3.0.1(@types/node@20.10.5)':
dependencies:
'@types/node': 20.10.5
optional: true
'@inquirer/type@3.0.1(@types/node@20.11.29)':
dependencies:
'@types/node': 20.11.29
'@inquirer/type@3.0.1(@types/node@20.14.8)':
dependencies:
'@types/node': 20.14.8
optional: true
'@ioredis/commands@1.2.0': {}
'@isaacs/cliui@8.0.2':
@@ -13771,42 +13851,6 @@ snapshots:
jest-util: 29.7.0
slash: 3.0.0
'@jest/core@29.7.0':
dependencies:
'@jest/console': 29.7.0
'@jest/reporters': 29.7.0
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 20.14.8
ansi-escapes: 4.3.2
chalk: 4.1.2
ci-info: 3.9.0
exit: 0.1.2
graceful-fs: 4.2.11
jest-changed-files: 29.7.0
jest-config: 29.7.0(@types/node@20.14.8)
jest-haste-map: 29.7.0
jest-message-util: 29.7.0
jest-regex-util: 29.6.3
jest-resolve: 29.7.0
jest-resolve-dependencies: 29.7.0
jest-runner: 29.7.0
jest-runtime: 29.7.0
jest-snapshot: 29.7.0
jest-util: 29.7.0
jest-validate: 29.7.0
jest-watcher: 29.7.0
micromatch: 4.0.8
pretty-format: 29.7.0
slash: 3.0.0
strip-ansi: 6.0.1
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
- ts-node
optional: true
'@jest/core@29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5))':
dependencies:
'@jest/console': 29.7.0
@@ -14021,12 +14065,12 @@ snapshots:
- '@aws-sdk/client-sts'
- aws-crt
'@langchain/aws@0.1.2(@langchain/core@0.3.18(openai@4.72.0(zod@3.23.8)))':
'@langchain/aws@0.1.2(@aws-sdk/client-sts@3.675.0)(@langchain/core@0.3.18(openai@4.72.0(zod@3.23.8)))':
dependencies:
'@aws-sdk/client-bedrock-agent-runtime': 3.668.0
'@aws-sdk/client-bedrock-runtime': 3.668.0
'@aws-sdk/client-kendra': 3.668.0
'@aws-sdk/credential-provider-node': 3.675.0
'@aws-sdk/credential-provider-node': 3.675.0(@aws-sdk/client-sts@3.675.0)
'@langchain/core': 0.3.18(openai@4.72.0(zod@3.23.8))
zod: 3.23.8
zod-to-json-schema: 3.23.5(zod@3.23.8)
@@ -16751,7 +16795,7 @@ snapshots:
lz-string: 1.5.0
pretty-format: 27.5.1
'@testing-library/jest-dom@6.4.6(@jest/globals@29.7.0)(@types/jest@29.5.12)(jest@29.7.0(@types/node@20.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5)))(vitest@2.1.2(@types/node@20.10.5))':
'@testing-library/jest-dom@6.4.6(@jest/globals@29.7.0)(@types/jest@29.5.12)(jest@29.7.0(@types/node@20.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5)))(vitest@2.1.2(@types/node@20.10.5)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.10.5)(typescript@5.4.5))(terser@5.36.0))':
dependencies:
'@adobe/css-tools': 4.4.0
'@babel/runtime': 7.24.7
@@ -16765,7 +16809,7 @@ snapshots:
'@jest/globals': 29.7.0
'@types/jest': 29.5.12
jest: 29.7.0(@types/node@20.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5))
vitest: 2.1.2(@types/node@20.10.5)
vitest: 2.1.2(@types/node@20.10.5)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.10.5)(typescript@5.4.5))(terser@5.36.0)
'@testing-library/react@15.0.7(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
@@ -17415,7 +17459,7 @@ snapshots:
'@ungap/structured-clone@1.2.0': {}
'@vercel/style-guide@6.0.0(@next/eslint-plugin-next@14.2.15)(eslint@8.57.0)(jest@29.7.0)(prettier@3.3.3)(typescript@5.4.5)(vitest@2.1.2)':
'@vercel/style-guide@6.0.0(@next/eslint-plugin-next@14.2.15)(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0))(prettier@3.3.3)(typescript@5.4.5)(vitest@2.1.2(@types/node@20.14.8)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5))(terser@5.36.0))':
dependencies:
'@babel/core': 7.24.3
'@babel/eslint-parser': 7.24.1(@babel/core@7.24.3)(eslint@8.57.0)
@@ -17424,18 +17468,18 @@ snapshots:
'@typescript-eslint/parser': 7.12.0(eslint@8.57.0)(typescript@5.4.5)
eslint-config-prettier: 9.1.0(eslint@8.57.0)
eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0))
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0)
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-eslint-comments: 3.2.0(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-jest: 27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0)(typescript@5.4.5)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-jest: 27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0))(typescript@5.4.5)
eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.0)
eslint-plugin-playwright: 1.5.4(eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0)(typescript@5.4.5))(eslint@8.57.0)
eslint-plugin-playwright: 1.5.4(eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0))(typescript@5.4.5))(eslint@8.57.0)
eslint-plugin-react: 7.34.1(eslint@8.57.0)
eslint-plugin-react-hooks: 4.6.0(eslint@8.57.0)
eslint-plugin-testing-library: 6.2.0(eslint@8.57.0)(typescript@5.4.5)
eslint-plugin-tsdoc: 0.2.17
eslint-plugin-unicorn: 51.0.1(eslint@8.57.0)
eslint-plugin-vitest: 0.3.26(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)(vitest@2.1.2)
eslint-plugin-vitest: 0.3.26(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)(vitest@2.1.2(@types/node@20.14.8)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5))(terser@5.36.0))
prettier-plugin-packagejson: 2.4.12(prettier@3.3.3)
optionalDependencies:
'@next/eslint-plugin-next': 14.2.15
@@ -17456,6 +17500,16 @@ snapshots:
chai: 5.1.1
tinyrainbow: 1.2.0
'@vitest/mocker@2.1.2(@vitest/spy@2.1.2)(msw@2.6.5(@types/node@20.10.5)(typescript@5.4.5))(vite@5.2.14(@types/node@20.10.5)(terser@5.36.0))':
dependencies:
'@vitest/spy': 2.1.2
estree-walker: 3.0.3
magic-string: 0.30.11
optionalDependencies:
msw: 2.6.5(@types/node@20.10.5)(typescript@5.4.5)
vite: 5.2.14(@types/node@20.10.5)(terser@5.36.0)
optional: true
'@vitest/mocker@2.1.2(@vitest/spy@2.1.2)(msw@2.6.5(@types/node@20.11.29)(typescript@5.4.5))(vite@5.2.14(@types/node@20.11.29)(terser@5.36.0))':
dependencies:
'@vitest/spy': 2.1.2
@@ -17465,13 +17519,14 @@ snapshots:
msw: 2.6.5(@types/node@20.11.29)(typescript@5.4.5)
vite: 5.2.14(@types/node@20.11.29)(terser@5.36.0)
'@vitest/mocker@2.1.2(@vitest/spy@2.1.2)(vite@5.2.14(@types/node@20.10.5))':
'@vitest/mocker@2.1.2(@vitest/spy@2.1.2)(msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5))(vite@5.2.14(@types/node@20.14.8)(terser@5.36.0))':
dependencies:
'@vitest/spy': 2.1.2
estree-walker: 3.0.3
magic-string: 0.30.11
optionalDependencies:
vite: 5.2.14(@types/node@20.10.5)
msw: 2.6.5(@types/node@20.14.8)(typescript@5.4.5)
vite: 5.2.14(@types/node@20.14.8)(terser@5.36.0)
optional: true
'@vitest/pretty-format@2.1.2':
@@ -18124,11 +18179,11 @@ snapshots:
dependencies:
semver: 7.6.3
bullmq@5.12.10:
bullmq@5.34.10:
dependencies:
cron-parser: 4.9.0
ioredis: 5.4.1
msgpackr: 1.10.1
msgpackr: 1.11.2
node-abort-controller: 3.1.1
semver: 7.6.3
tslib: 2.6.3
@@ -18521,22 +18576,6 @@ snapshots:
crc-32: 1.2.2
readable-stream: 3.6.2
create-jest@29.7.0:
dependencies:
'@jest/types': 29.6.3
chalk: 4.1.2
exit: 0.1.2
graceful-fs: 4.2.11
jest-config: 29.7.0(@types/node@20.14.8)
jest-util: 29.7.0
prompts: 2.4.2
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
- supports-color
- ts-node
optional: true
create-jest@29.7.0(@types/node@20.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5)):
dependencies:
'@jest/types': 29.6.3
@@ -18552,6 +18591,22 @@ snapshots:
- supports-color
- ts-node
create-jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0):
dependencies:
'@jest/types': 29.6.3
chalk: 4.1.2
exit: 0.1.2
graceful-fs: 4.2.11
jest-config: 29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5))
jest-util: 29.7.0
prompts: 2.4.2
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
- supports-color
- ts-node
optional: true
create-require@1.1.1: {}
crelt@1.0.6: {}
@@ -18610,6 +18665,8 @@ snapshots:
csstype@3.1.3: {}
csv-parse@5.6.0: {}
cytoscape-cose-bilkent@4.1.0(cytoscape@3.30.2):
dependencies:
cose-base: 1.0.3
@@ -19326,7 +19383,7 @@ snapshots:
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.0)
eslint-plugin-react: 7.34.1(eslint@8.57.0)
eslint-plugin-react-hooks: 4.6.0(eslint@8.57.0)
@@ -19343,7 +19400,7 @@ snapshots:
eslint-config-standard@17.1.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint-plugin-n@16.6.2(eslint@8.57.0))(eslint-plugin-promise@6.4.0(eslint@8.57.0))(eslint@8.57.0):
dependencies:
eslint: 8.57.0
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-n: 16.6.2(eslint@8.57.0)
eslint-plugin-promise: 6.4.0(eslint@8.57.0)
@@ -19354,7 +19411,7 @@ snapshots:
eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)):
dependencies:
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
eslint-import-resolver-node@0.3.9:
dependencies:
@@ -19370,7 +19427,7 @@ snapshots:
enhanced-resolve: 5.17.1
eslint: 8.57.0
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
fast-glob: 3.3.2
get-tsconfig: 4.8.1
is-core-module: 2.15.1
@@ -19381,13 +19438,13 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0):
eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0))(eslint@8.57.0):
dependencies:
debug: 4.3.7(supports-color@5.5.0)
enhanced-resolve: 5.17.1
eslint: 8.57.0
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
fast-glob: 3.3.2
get-tsconfig: 4.8.1
is-core-module: 2.15.1
@@ -19409,14 +19466,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.8.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0):
eslint-module-utils@2.8.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 7.12.0(eslint@8.57.0)(typescript@5.4.5)
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0)
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0))(eslint@8.57.0)
transitivePeerDependencies:
- supports-color
@@ -19433,7 +19490,7 @@ snapshots:
eslint: 8.57.0
ignore: 5.3.1
eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0):
eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0):
dependencies:
array-includes: 3.1.7
array.prototype.findlastindex: 1.2.4
@@ -19443,7 +19500,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
hasown: 2.0.2
is-core-module: 2.15.1
is-glob: 4.0.3
@@ -19460,40 +19517,13 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0):
dependencies:
array-includes: 3.1.7
array.prototype.findlastindex: 1.2.4
array.prototype.flat: 1.3.2
array.prototype.flatmap: 1.3.2
debug: 3.2.7
doctrine: 2.1.0
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
hasown: 2.0.2
is-core-module: 2.15.1
is-glob: 4.0.3
minimatch: 3.1.2
object.fromentries: 2.0.8
object.groupby: 1.0.3
object.values: 1.2.0
semver: 6.3.1
tsconfig-paths: 3.15.0
optionalDependencies:
'@typescript-eslint/parser': 7.12.0(eslint@8.57.0)(typescript@5.4.5)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
- supports-color
eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0)(typescript@5.4.5):
eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0))(typescript@5.4.5):
dependencies:
'@typescript-eslint/utils': 5.62.0(eslint@8.57.0)(typescript@5.4.5)
eslint: 8.57.0
optionalDependencies:
'@typescript-eslint/eslint-plugin': 7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)
jest: 29.7.0
jest: 29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0)
transitivePeerDependencies:
- supports-color
- typescript
@@ -19535,12 +19565,12 @@ snapshots:
eslint-plugin-only-warn@1.1.0: {}
eslint-plugin-playwright@1.5.4(eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0)(typescript@5.4.5))(eslint@8.57.0):
eslint-plugin-playwright@1.5.4(eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0))(typescript@5.4.5))(eslint@8.57.0):
dependencies:
eslint: 8.57.0
globals: 13.24.0
optionalDependencies:
eslint-plugin-jest: 27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0)(typescript@5.4.5)
eslint-plugin-jest: 27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0))(typescript@5.4.5)
eslint-plugin-prettier@5.1.3(@types/eslint@8.56.12)(eslint-config-prettier@9.1.0(eslint@8.57.0))(eslint@8.57.0)(prettier@3.3.3):
dependencies:
@@ -19622,13 +19652,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-vitest@0.3.26(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)(vitest@2.1.2):
eslint-plugin-vitest@0.3.26(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)(vitest@2.1.2(@types/node@20.14.8)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5))(terser@5.36.0)):
dependencies:
'@typescript-eslint/utils': 7.3.1(eslint@8.57.0)(typescript@5.4.5)
eslint: 8.57.0
optionalDependencies:
'@typescript-eslint/eslint-plugin': 7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)
vitest: 2.1.2(@types/node@20.11.29)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.11.29)(typescript@5.4.5))(terser@5.36.0)
vitest: 2.1.2(@types/node@20.14.8)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5))(terser@5.36.0)
transitivePeerDependencies:
- supports-color
- typescript
@@ -20900,26 +20930,6 @@ snapshots:
- babel-plugin-macros
- supports-color
jest-cli@29.7.0:
dependencies:
'@jest/core': 29.7.0
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
create-jest: 29.7.0
exit: 0.1.2
import-local: 3.1.0
jest-config: 29.7.0(@types/node@20.14.8)
jest-util: 29.7.0
jest-validate: 29.7.0
yargs: 17.7.2
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
- supports-color
- ts-node
optional: true
jest-cli@29.7.0(@types/node@20.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5)):
dependencies:
'@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5))
@@ -20939,6 +20949,26 @@ snapshots:
- supports-color
- ts-node
jest-cli@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0):
dependencies:
'@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5))
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
create-jest: 29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0)
exit: 0.1.2
import-local: 3.1.0
jest-config: 29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5))
jest-util: 29.7.0
jest-validate: 29.7.0
yargs: 17.7.2
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
- supports-color
- ts-node
optional: true
jest-config@29.7.0(@types/node@20.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5)):
dependencies:
'@babel/core': 7.24.3
@@ -20970,37 +21000,6 @@ snapshots:
- babel-plugin-macros
- supports-color
jest-config@29.7.0(@types/node@20.14.8):
dependencies:
'@babel/core': 7.24.3
'@jest/test-sequencer': 29.7.0
'@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.24.3)
chalk: 4.1.2
ci-info: 3.9.0
deepmerge: 4.3.1
glob: 7.2.3
graceful-fs: 4.2.11
jest-circus: 29.7.0(babel-plugin-macros@3.1.0)
jest-environment-node: 29.7.0
jest-get-type: 29.6.3
jest-regex-util: 29.6.3
jest-resolve: 29.7.0
jest-runner: 29.7.0
jest-util: 29.7.0
jest-validate: 29.7.0
micromatch: 4.0.8
parse-json: 5.2.0
pretty-format: 29.7.0
slash: 3.0.0
strip-json-comments: 3.1.1
optionalDependencies:
'@types/node': 20.14.8
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
optional: true
jest-config@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5)):
dependencies:
'@babel/core': 7.24.3
@@ -21268,19 +21267,6 @@ snapshots:
merge-stream: 2.0.0
supports-color: 8.1.1
jest@29.7.0:
dependencies:
'@jest/core': 29.7.0
'@jest/types': 29.6.3
import-local: 3.1.0
jest-cli: 29.7.0
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
- supports-color
- ts-node
optional: true
jest@29.7.0(@types/node@20.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5)):
dependencies:
'@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5))
@@ -21293,6 +21279,19 @@ snapshots:
- supports-color
- ts-node
jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0):
dependencies:
'@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5))
'@jest/types': 29.6.3
import-local: 3.1.0
jest-cli: 29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0)
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
- supports-color
- ts-node
optional: true
jiti@1.21.7: {}
jju@1.4.0: {}
@@ -21485,7 +21484,7 @@ snapshots:
- encoding
- openai
langchain@0.3.6(@langchain/anthropic@0.3.8(@langchain/core@0.3.18(openai@4.72.0(zod@3.23.8))))(@langchain/aws@0.1.2(@langchain/core@0.3.18(openai@4.72.0(zod@3.23.8))))(@langchain/core@0.3.18(openai@4.72.0(zod@3.23.8)))(@langchain/google-vertexai@0.1.3(@langchain/core@0.3.18(openai@4.72.0(zod@3.23.8)))(zod@3.23.8))(axios@1.7.7)(handlebars@4.7.8)(openai@4.72.0(zod@3.23.8)):
langchain@0.3.6(@langchain/anthropic@0.3.8(@langchain/core@0.3.18(openai@4.72.0(zod@3.23.8))))(@langchain/aws@0.1.2(@aws-sdk/client-sts@3.675.0)(@langchain/core@0.3.18(openai@4.72.0(zod@3.23.8))))(@langchain/core@0.3.18(openai@4.72.0(zod@3.23.8)))(@langchain/google-vertexai@0.1.3(@langchain/core@0.3.18(openai@4.72.0(zod@3.23.8)))(zod@3.23.8))(axios@1.7.7)(handlebars@4.7.8)(openai@4.72.0(zod@3.23.8)):
dependencies:
'@langchain/core': 0.3.18(openai@4.72.0(zod@3.23.8))
'@langchain/openai': 0.3.14(@langchain/core@0.3.18(openai@4.72.0(zod@3.23.8)))
@@ -21502,7 +21501,7 @@ snapshots:
zod-to-json-schema: 3.23.5(zod@3.23.8)
optionalDependencies:
'@langchain/anthropic': 0.3.8(@langchain/core@0.3.18(openai@4.72.0(zod@3.23.8)))
'@langchain/aws': 0.1.2(@langchain/core@0.3.18(openai@4.72.0(zod@3.23.8)))
'@langchain/aws': 0.1.2(@aws-sdk/client-sts@3.675.0)(@langchain/core@0.3.18(openai@4.72.0(zod@3.23.8)))
'@langchain/google-vertexai': 0.1.3(@langchain/core@0.3.18(openai@4.72.0(zod@3.23.8)))(zod@3.23.8)
axios: 1.7.7
handlebars: 4.7.8
@@ -22227,10 +22226,36 @@ snapshots:
'@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3
optional: true
msgpackr@1.10.1:
msgpackr@1.11.2:
optionalDependencies:
msgpackr-extract: 3.0.3
msw@2.6.5(@types/node@20.10.5)(typescript@5.4.5):
dependencies:
'@bundled-es-modules/cookie': 2.0.1
'@bundled-es-modules/statuses': 1.0.1
'@bundled-es-modules/tough-cookie': 0.1.6
'@inquirer/confirm': 5.0.2(@types/node@20.10.5)
'@mswjs/interceptors': 0.37.1
'@open-draft/deferred-promise': 2.2.0
'@open-draft/until': 2.1.0
'@types/cookie': 0.6.0
'@types/statuses': 2.0.5
chalk: 4.1.2
graphql: 16.9.0
headers-polyfill: 4.0.3
is-node-process: 1.2.0
outvariant: 1.4.3
path-to-regexp: 6.3.0
strict-event-emitter: 0.5.1
type-fest: 4.27.0
yargs: 17.7.2
optionalDependencies:
typescript: 5.4.5
transitivePeerDependencies:
- '@types/node'
optional: true
msw@2.6.5(@types/node@20.11.29)(typescript@5.4.5):
dependencies:
'@bundled-es-modules/cookie': 2.0.1
@@ -22256,6 +22281,32 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5):
dependencies:
'@bundled-es-modules/cookie': 2.0.1
'@bundled-es-modules/statuses': 1.0.1
'@bundled-es-modules/tough-cookie': 0.1.6
'@inquirer/confirm': 5.0.2(@types/node@20.14.8)
'@mswjs/interceptors': 0.37.1
'@open-draft/deferred-promise': 2.2.0
'@open-draft/until': 2.1.0
'@types/cookie': 0.6.0
'@types/statuses': 2.0.5
chalk: 4.1.2
graphql: 16.9.0
headers-polyfill: 4.0.3
is-node-process: 1.2.0
outvariant: 1.4.3
path-to-regexp: 6.3.0
strict-event-emitter: 0.5.1
type-fest: 4.27.0
yargs: 17.7.2
optionalDependencies:
typescript: 5.4.5
transitivePeerDependencies:
- '@types/node'
optional: true
mustache@4.2.0: {}
mute-stream@1.0.0: {}
@@ -24700,12 +24751,12 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
vite-node@2.1.2(@types/node@20.10.5):
vite-node@2.1.2(@types/node@20.10.5)(terser@5.36.0):
dependencies:
cac: 6.7.14
debug: 4.3.7(supports-color@5.5.0)
pathe: 1.1.2
vite: 5.2.14(@types/node@20.10.5)
vite: 5.2.14(@types/node@20.10.5)(terser@5.36.0)
transitivePeerDependencies:
- '@types/node'
- less
@@ -24733,7 +24784,24 @@ snapshots:
- supports-color
- terser
vite@5.2.14(@types/node@20.10.5):
vite-node@2.1.2(@types/node@20.14.8)(terser@5.36.0):
dependencies:
cac: 6.7.14
debug: 4.3.7(supports-color@5.5.0)
pathe: 1.1.2
vite: 5.2.14(@types/node@20.14.8)(terser@5.36.0)
transitivePeerDependencies:
- '@types/node'
- less
- lightningcss
- sass
- stylus
- sugarss
- supports-color
- terser
optional: true
vite@5.2.14(@types/node@20.10.5)(terser@5.36.0):
dependencies:
esbuild: 0.20.2
postcss: 8.4.49
@@ -24741,6 +24809,7 @@ snapshots:
optionalDependencies:
'@types/node': 20.10.5
fsevents: 2.3.3
terser: 5.36.0
optional: true
vite@5.2.14(@types/node@20.11.29)(terser@5.36.0):
@@ -24753,10 +24822,21 @@ snapshots:
fsevents: 2.3.3
terser: 5.36.0
vitest@2.1.2(@types/node@20.10.5):
vite@5.2.14(@types/node@20.14.8)(terser@5.36.0):
dependencies:
esbuild: 0.20.2
postcss: 8.4.49
rollup: 4.13.0
optionalDependencies:
'@types/node': 20.14.8
fsevents: 2.3.3
terser: 5.36.0
optional: true
vitest@2.1.2(@types/node@20.10.5)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.10.5)(typescript@5.4.5))(terser@5.36.0):
dependencies:
'@vitest/expect': 2.1.2
'@vitest/mocker': 2.1.2(@vitest/spy@2.1.2)(vite@5.2.14(@types/node@20.10.5))
'@vitest/mocker': 2.1.2(@vitest/spy@2.1.2)(msw@2.6.5(@types/node@20.10.5)(typescript@5.4.5))(vite@5.2.14(@types/node@20.10.5)(terser@5.36.0))
'@vitest/pretty-format': 2.1.2
'@vitest/runner': 2.1.2
'@vitest/snapshot': 2.1.2
@@ -24771,11 +24851,12 @@ snapshots:
tinyexec: 0.3.0
tinypool: 1.0.1
tinyrainbow: 1.2.0
vite: 5.2.14(@types/node@20.10.5)
vite-node: 2.1.2(@types/node@20.10.5)
vite: 5.2.14(@types/node@20.10.5)(terser@5.36.0)
vite-node: 2.1.2(@types/node@20.10.5)(terser@5.36.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 20.10.5
jsdom: 20.0.3
transitivePeerDependencies:
- less
- lightningcss
@@ -24821,6 +24902,41 @@ snapshots:
- supports-color
- terser
vitest@2.1.2(@types/node@20.14.8)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5))(terser@5.36.0):
dependencies:
'@vitest/expect': 2.1.2
'@vitest/mocker': 2.1.2(@vitest/spy@2.1.2)(msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5))(vite@5.2.14(@types/node@20.14.8)(terser@5.36.0))
'@vitest/pretty-format': 2.1.2
'@vitest/runner': 2.1.2
'@vitest/snapshot': 2.1.2
'@vitest/spy': 2.1.2
'@vitest/utils': 2.1.2
chai: 5.1.1
debug: 4.3.7(supports-color@5.5.0)
magic-string: 0.30.11
pathe: 1.1.2
std-env: 3.7.0
tinybench: 2.9.0
tinyexec: 0.3.0
tinypool: 1.0.1
tinyrainbow: 1.2.0
vite: 5.2.14(@types/node@20.14.8)(terser@5.36.0)
vite-node: 2.1.2(@types/node@20.14.8)(terser@5.36.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 20.14.8
jsdom: 20.0.3
transitivePeerDependencies:
- less
- lightningcss
- msw
- sass
- stylus
- sugarss
- supports-color
- terser
optional: true
vscode-jsonrpc@8.2.0: {}
vscode-languageserver-protocol@3.17.5:
+3 -3
View File
@@ -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;` : ""}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.6.2",
"version": "3.9.0",
"private": true,
"license": "MIT",
"engines": {
@@ -99,7 +99,7 @@
"@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",
+3 -3
View File
@@ -3186,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
@@ -3226,7 +3227,6 @@ components:
- id
- modelName
- matchPattern
- unit
- isLangfuseManaged
ModelUsageUnit:
title: ModelUsageUnit
@@ -4279,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
@@ -4311,7 +4312,6 @@ components:
required:
- modelName
- matchPattern
- unit
Observations:
title: Observations
type: object
+1 -1
View File
@@ -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)}`,
);
@@ -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");
});
});
@@ -92,6 +92,139 @@ describe("trpc.sessions", () => {
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();
-297
View File
@@ -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");
});
});
-643
View File
@@ -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
-198
View File
@@ -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([]);
});
});
+7 -6
View File
@@ -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);
+5 -5
View File
@@ -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);
-791
View File
@@ -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] });
});
});
+3
View File
@@ -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",
-376
View File
@@ -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
-358
View File
@@ -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);
});
});
+2 -1
View File
@@ -37,9 +37,10 @@ describe("parseJsonPrioritised", () => {
["42", 42], // Number
["true", true], // Boolean true
["false", false], // Boolean false
["null", "null"], // Null
["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"}}',
-109
View File
@@ -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>
);
+1 -1
View File
@@ -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();
+4 -11
View File
@@ -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
@@ -156,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 />,
},
];
@@ -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,27 +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>
<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
@@ -111,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>
@@ -123,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>
),
)}
@@ -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,182 @@
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}>
{item.menuNode ?? (
<a
href={url}
target={url.startsWith("http") ? "_blank" : undefined}
rel={
url.startsWith("http") ? "noopener noreferrer" : 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" />
+3 -3
View File
@@ -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} />
@@ -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={
+3 -1
View File
@@ -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 (
+50 -47
View File
@@ -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" && (
+36 -5
View File
@@ -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>
+35 -32
View File
@@ -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],
);
+45 -1
View File
@@ -5,6 +5,14 @@ import { TracePreview } from "./TracePreview";
import Header from "@/src/components/layouts/header";
import { Badge } from "@/src/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectLabel,
SelectGroup,
} from "@/src/components/ui/select";
import { AggUsageBadge } from "@/src/components/token-usage-badge";
import { StringParam, useQueryParam, withDefault } from "use-query-params";
import { PublishTraceSwitch } from "@/src/components/publish-object-switch";
@@ -22,6 +30,7 @@ import {
Award,
ChevronsDownUp,
ChevronsUpDown,
FilterIcon,
ListTree,
Network,
Percent,
@@ -31,7 +40,7 @@ import { useCallback, useState } from "react";
import { DeleteButton } from "@/src/components/deleteButton";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { TraceTimelineView } from "@/src/components/trace/TraceTimelineView";
import { type APIScore } from "@langfuse/shared";
import { type APIScore, ObservationLevel } from "@langfuse/shared";
import { FullScreenPage } from "@/src/components/layouts/full-screen-page";
import { calculateDisplayTotalCost } from "@/src/components/trace/lib/helpers";
import { useIsAuthenticatedAndProjectMember } from "@/src/features/auth/hooks";
@@ -74,6 +83,9 @@ export function Trace(props: {
[],
);
const [minObservationLevel, setMinObservationLevel] =
useState<ObservationLevel>(ObservationLevel.DEFAULT);
const isAuthenticatedAndProjectMember = useIsAuthenticatedAndProjectMember(
props.projectId,
);
@@ -223,6 +235,36 @@ export function Trace(props: {
>
<Percent className="h-4 w-4" />
</Toggle>
<Select
onValueChange={(v: ObservationLevel) => setMinObservationLevel(v)}
value={minObservationLevel}
>
<SelectTrigger
hideDownIcon
className="focus:ring-none h-auto w-auto border-none px-0 py-0 text-sm focus:outline-none focus:ring-0 focus:ring-offset-0"
>
<Toggle
pressed={colorCodeMetricsOnObservationTree}
onPressedChange={(e) => setColorCodeMetricsOnObservationTree(e)}
size="xs"
title="Color code metrics (>50% yellow, >75% red)"
>
<FilterIcon className="h-4 w-4" />
</Toggle>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel className="py-1 text-sm font-semibold">
Min. Level
</SelectLabel>
{Object.values(ObservationLevel).map((level) => (
<SelectItem value={level} key={level}>
{level}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<ObservationTree
@@ -240,6 +282,8 @@ export function Trace(props: {
colorCodeMetrics={colorCodeMetricsOnObservationTree}
observationCommentCounts={observationCommentCounts.data}
traceCommentCounts={traceCommentCounts.data}
minLevel={minObservationLevel}
setMinLevel={setMinObservationLevel}
className="flex w-full flex-col overflow-y-auto"
/>
</div>
+39 -8
View File
@@ -1,4 +1,4 @@
import { ObservationType } from "@langfuse/shared";
import { ObservationLevel, ObservationType } from "@langfuse/shared";
import { type NestedObservation } from "@/src/utils/types";
import { type ObservationReturnType } from "@/src/server/api/routers/traces";
import Decimal from "decimal.js";
@@ -7,18 +7,29 @@ export type TreeItemType = ObservationType | "TRACE";
export const treeItemColors: Map<TreeItemType, string> = new Map([
[ObservationType.SPAN, "bg-muted-blue"],
[ObservationType.GENERATION, "bg-muted-orange"],
[ObservationType.GENERATION, "bg-muted-magenta"],
[ObservationType.EVENT, "bg-muted-green"],
["TRACE", "bg-input"],
]);
export function nestObservations(
list: ObservationReturnType[],
): NestedObservation[] {
if (list.length === 0) return [];
minLevel?: ObservationLevel,
): {
nestedObservations: NestedObservation[];
hiddenObservationsCount: number;
} {
if (list.length === 0)
return { nestedObservations: [], hiddenObservationsCount: 0 };
// Data prep:
// - Filter for observations with minimum level
// - Remove parentObservationId attribute from observations if the id does not exist in the list of observations
const mutableList = list.filter((o) =>
getObservationLevels(minLevel).includes(o.level),
);
const hiddenObservationsCount = list.length - mutableList.length;
// Data prep: Remove parentObservationId attribute from observations if the id does not exist in the list of observations
const mutableList = list.map((o) => ({ ...o }));
mutableList.forEach((observation) => {
if (
observation.parentObservationId &&
@@ -61,7 +72,10 @@ export function nestObservations(
}
// Step 5: Return the roots.
return Array.from(roots.values());
return {
nestedObservations: Array.from(roots.values()),
hiddenObservationsCount,
};
}
export function calculateDisplayTotalCost(p: {
@@ -114,7 +128,9 @@ export function calculateDisplayTotalCost(p: {
curr.calculatedOutputCost ?? new Decimal(0),
),
)
: curr.calculatedInputCost ?? curr.calculatedOutputCost ?? undefined;
: (curr.calculatedInputCost ??
curr.calculatedOutputCost ??
undefined);
}
if (!curr.calculatedTotalCost) return prev;
@@ -129,3 +145,18 @@ export function calculateDisplayTotalCost(p: {
return totalCost;
}
function getObservationLevels(minLevel: ObservationLevel | undefined) {
const ascendingLevels = [
ObservationLevel.DEBUG,
ObservationLevel.DEFAULT,
ObservationLevel.WARNING,
ObservationLevel.ERROR,
];
if (!minLevel) return ascendingLevels;
const minLevelIndex = ascendingLevels.indexOf(minLevel);
return ascendingLevels.slice(minLevelIndex);
}
+8 -4
View File
@@ -14,7 +14,9 @@ const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
hideDownIcon?: boolean;
}
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
@@ -25,9 +27,11 @@ const SelectTrigger = React.forwardRef<
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
{props.hideDownIcon ? null : (
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
)}
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.6.2";
export const VERSION = "v3.9.0";
@@ -5,7 +5,6 @@ import {
createTRPCRouter,
protectedProjectProcedure,
} from "@/src/server/api/trpc";
import { measureAndReturnApi } from "@/src/server/utils/checkClickhouseAccess";
import {
type AnnotationQueueItem,
AnnotationQueueObjectType,
@@ -79,38 +78,14 @@ export const queueItemRouter = createTRPCRouter({
};
if (item.objectType === AnnotationQueueObjectType.OBSERVATION) {
await measureAndReturnApi({
input: { projectId: input.projectId, queryClickhouse: false },
operation: "annotations.byId",
user: ctx.session.user,
pgExecution: async () => {
const observation = await ctx.prisma.observation.findUnique({
where: {
id: item.objectId,
projectId: input.projectId,
},
select: {
id: true,
traceId: true,
},
});
return {
...inflatedItem,
parentTraceId: observation?.traceId,
};
},
clickhouseExecution: async () => {
const clickhouseObservation = await getObservationById(
item.objectId,
input.projectId,
);
return {
...inflatedItem,
parentTraceId: clickhouseObservation?.traceId,
};
},
});
const clickhouseObservation = await getObservationById(
item.objectId,
input.projectId,
);
return {
...inflatedItem,
parentTraceId: clickhouseObservation?.traceId,
};
}
return inflatedItem;
@@ -147,131 +122,68 @@ export const queueItemRouter = createTRPCRouter({
scope: "annotationQueues:read",
});
return await measureAndReturnApi({
input: { projectId: input.projectId, queryClickhouse: false },
operation: "annotationQueueItems.itemsByQueue",
user: ctx.session.user,
pgExecution: async () => {
const [queueItems, totalItems] = await Promise.all([
// queueItems
ctx.prisma.$queryRaw<
Array<{
id: string;
status: AnnotationQueueStatus;
objectId: string;
objectType: AnnotationQueueObjectType;
parentTraceId: string | null;
completedAt: string | null;
annotatorUserId: string | null;
annotatorUserImage: string | null;
annotatorUserName: string | null;
}>
>(Prisma.sql`
SELECT
aqi.id,
aqi.status,
aqi.object_id AS "objectId",
aqi.object_type AS "objectType",
o.trace_id AS "parentTraceId",
aqi.completed_at AS "completedAt",
aqi.annotator_user_id AS "annotatorUserId",
u.image AS "annotatorUserImage",
u.name AS "annotatorUserName"
FROM
annotation_queue_items aqi
LEFT JOIN
observations o ON o.id = aqi.object_id AND aqi.object_type = 'OBSERVATION' AND o.project_id = ${input.projectId}
LEFT JOIN
users u ON u.id = aqi.annotator_user_id AND u.id in (SELECT user_id FROM organization_memberships WHERE org_id = ${ctx.session.orgId})
WHERE
aqi.project_id = ${input.projectId} AND aqi.queue_id = ${input.queueId}
ORDER BY
aqi.created_at ASC,
aqi.object_id ASC,
aqi.object_type ASC
${input.limit ? Prisma.sql`LIMIT ${input.limit}` : Prisma.empty}
${input.page && input.limit ? Prisma.sql`OFFSET ${input.page * input.limit}` : Prisma.empty}
`),
// totalItems
ctx.prisma.annotationQueueItem.count({
where: {
queueId: input.queueId,
projectId: input.projectId,
},
}),
]);
const [queueItems, totalItems] = await Promise.all([
await ctx.prisma.$queryRaw<
Array<{
id: string;
status: AnnotationQueueStatus;
objectId: string;
objectType: AnnotationQueueObjectType;
completedAt: string | null;
annotatorUserId: string | null;
annotatorUserImage: string | null;
annotatorUserName: string | null;
}>
>(Prisma.sql`
SELECT
aqi.id,
aqi.status,
aqi.object_id AS "objectId",
aqi.object_type AS "objectType",
aqi.completed_at AS "completedAt",
aqi.annotator_user_id AS "annotatorUserId",
u.image AS "annotatorUserImage",
u.name AS "annotatorUserName"
FROM
annotation_queue_items aqi
LEFT JOIN
users u ON u.id = aqi.annotator_user_id AND u.id in (SELECT user_id FROM organization_memberships WHERE org_id = ${ctx.session.orgId})
WHERE
aqi.project_id = ${input.projectId} AND aqi.queue_id = ${input.queueId}
ORDER BY
aqi.created_at ASC,
aqi.object_id ASC,
aqi.object_type ASC
${input.limit ? Prisma.sql`LIMIT ${input.limit}` : Prisma.empty}
${input.page && input.limit ? Prisma.sql`OFFSET ${input.page * input.limit}` : Prisma.empty}
`),
ctx.prisma.annotationQueueItem.count({
where: {
queueId: input.queueId,
projectId: input.projectId,
},
}),
]);
return { queueItems, totalItems };
},
clickhouseExecution: async () => {
const [queueItems, totalItems] = await Promise.all([
await ctx.prisma.$queryRaw<
Array<{
id: string;
status: AnnotationQueueStatus;
objectId: string;
objectType: AnnotationQueueObjectType;
completedAt: string | null;
annotatorUserId: string | null;
annotatorUserImage: string | null;
annotatorUserName: string | null;
}>
>(Prisma.sql`
SELECT
aqi.id,
aqi.status,
aqi.object_id AS "objectId",
aqi.object_type AS "objectType",
aqi.completed_at AS "completedAt",
aqi.annotator_user_id AS "annotatorUserId",
u.image AS "annotatorUserImage",
u.name AS "annotatorUserName"
FROM
annotation_queue_items aqi
LEFT JOIN
users u ON u.id = aqi.annotator_user_id AND u.id in (SELECT user_id FROM organization_memberships WHERE org_id = ${ctx.session.orgId})
WHERE
aqi.project_id = ${input.projectId} AND aqi.queue_id = ${input.queueId}
ORDER BY
aqi.created_at ASC,
aqi.object_id ASC,
aqi.object_type ASC
${input.limit ? Prisma.sql`LIMIT ${input.limit}` : Prisma.empty}
${input.page && input.limit ? Prisma.sql`OFFSET ${input.page * input.limit}` : Prisma.empty}
`),
ctx.prisma.annotationQueueItem.count({
where: {
queueId: input.queueId,
projectId: input.projectId,
},
}),
]);
const observationIds = queueItems
.filter(
(item) => item.objectType === AnnotationQueueObjectType.OBSERVATION,
)
.map((item) => item.objectId);
const observationIds = queueItems
.filter(
(item) =>
item.objectType === AnnotationQueueObjectType.OBSERVATION,
)
.map((item) => item.objectId);
const traceIds =
observationIds.length > 0
? await getTraceIdsForObservations(input.projectId, observationIds)
: [];
const traceIds =
observationIds.length > 0
? await getTraceIdsForObservations(
input.projectId,
observationIds,
)
: [];
return {
queueItems: queueItems.map((item) => ({
...item,
parentTraceId:
traceIds.find((t) => t.id === item.objectId)?.traceId || null,
})),
totalItems,
};
},
});
return {
queueItems: queueItems.map((item) => ({
...item,
parentTraceId:
traceIds.find((t) => t.id === item.objectId)?.traceId || null,
})),
totalItems,
};
} catch (error) {
logger.error(error);
if (error instanceof TRPCError) {
@@ -5,7 +5,6 @@ import {
createTRPCRouter,
protectedProjectProcedure,
} from "@/src/server/api/trpc";
import { measureAndReturnApi } from "@/src/server/utils/checkClickhouseAccess";
import {
AnnotationQueueObjectType,
AnnotationQueueStatus,
@@ -530,38 +529,14 @@ export const queueRouter = createTRPCRouter({
};
if (item.objectType === AnnotationQueueObjectType.OBSERVATION) {
await measureAndReturnApi({
input: { projectId: input.projectId, queryClickhouse: false },
operation: "fetchAndLockNext",
user: ctx.session.user,
pgExecution: async () => {
const observation = await ctx.prisma.observation.findUnique({
where: {
id: item.objectId,
projectId: input.projectId,
},
select: {
id: true,
traceId: true,
},
});
return {
...inflatedUpdatedItem,
parentTraceId: observation?.traceId,
};
},
clickhouseExecution: async () => {
const clickhouseObservation = await getObservationById(
item.objectId,
input.projectId,
);
return {
...inflatedUpdatedItem,
parentTraceId: clickhouseObservation?.traceId,
};
},
});
const clickhouseObservation = await getObservationById(
item.objectId,
input.projectId,
);
return {
...inflatedUpdatedItem,
parentTraceId: clickhouseObservation?.traceId,
};
}
return inflatedUpdatedItem;
+1 -17
View File
@@ -30,7 +30,6 @@ import { EvalReferencedEvaluators } from "@/src/ee/features/evals/types";
import { EvaluatorStatus } from "../types";
import { traceException } from "@langfuse/shared/src/server";
import { isNotNullOrUndefined } from "@/src/utils/types";
import { measureAndReturnApi } from "@/src/server/utils/checkClickhouseAccess";
const APIEvaluatorSchema = z.object({
id: z.string(),
@@ -730,22 +729,7 @@ export const evalRouter = createTRPCRouter({
const scores =
scoreIds.length > 0
? await measureAndReturnApi({
input: { projectId: input.projectId, queryClickhouse: false },
operation: "get-scores-eval-log",
user: ctx.session.user,
pgExecution: async () => {
return await ctx.prisma.score.findMany({
where: {
projectId: input.projectId,
id: { in: scoreIds },
},
});
},
clickhouseExecution: async () => {
return await getScoresByIds(input.projectId, scoreIds);
},
})
? await getScoresByIds(input.projectId, scoreIds)
: [];
return {
@@ -42,7 +42,8 @@ export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
const projectId = useProjectIdFromURL();
const { setPlaygroundCache } = usePlaygroundCache();
const [capturedState, setCapturedState] = useState<PlaygroundCache>(null);
const available = useHasEntitlement("playground");
const [isAvailable, setIsAvailable] = useState<boolean>(false);
const isEntitled = useHasEntitlement("playground");
useEffect(() => {
if (props.source === "prompt") {
@@ -52,26 +53,33 @@ export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
}
}, [props]);
useEffect(() => {
if (capturedState && isEntitled) {
setIsAvailable(true);
} else {
setIsAvailable(false);
}
}, [capturedState, isEntitled, setIsAvailable]);
const handleClick = () => {
capture(props.analyticsEventName);
setPlaygroundCache(capturedState);
};
if (!available) return null;
if (!isAvailable) return null;
return (
<Button
variant={props.variant ?? "secondary"}
size={props.source === "prompt" ? "icon" : "default"}
title="Test in LLM playground"
onClick={handleClick}
asChild
>
<Link href={`/project/${projectId}/playground`}>
<Terminal className="h-4 w-4" />
{props.source === "generation" && (
<span className="ml-2">Test in playground</span>
)}
<span className="ml-2">
{props.source === "generation" ? "Test in playground" : "Playground"}
</span>
</Link>
</Button>
);
-32
View File
@@ -232,24 +232,6 @@ export const env = createEnv({
);
}, "LANGFUSE_ALLOWED_ORGANIZATION_CREATORS must be a comma separated list of valid email addresses"),
// TODO: Remove entire block during V3 clean up
// Settings to toggle Clickhouse vs Postgres behaviour
LANGFUSE_READ_FROM_POSTGRES_ONLY: z
.enum(["true", "false"])
.default("false"),
LANGFUSE_RETURN_FROM_CLICKHOUSE: z.enum(["true", "false"]).default("true"),
LANGFUSE_EXPERIMENT_EXCLUDED_PROJECT_IDS: z.string().optional(),
LANGFUSE_EXPERIMENT_EXCLUDED_OPERATIONS: z.string().optional(),
LANGFUSE_READ_DASHBOARDS_FROM_CLICKHOUSE: z
.enum(["true", "false"])
.default("true"),
LANGFUSE_READ_FROM_CLICKHOUSE_ONLY: z
.enum(["true", "false"])
.default("true"),
LANGFUSE_POSTGRES_INGESTION_ENABLED: z
.enum(["true", "false"])
.default("false"),
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_WEBHOOK_SIGNING_SECRET: z.string().optional(),
SENTRY_AUTH_TOKEN: z.string().optional(),
@@ -314,8 +296,6 @@ export const env = createEnv({
NEXT_PUBLIC_SIGN_UP_DISABLED: process.env.NEXT_PUBLIC_SIGN_UP_DISABLED,
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:
process.env.LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES,
LANGFUSE_EXPERIMENT_EXCLUDED_OPERATIONS:
process.env.LANGFUSE_EXPERIMENT_EXCLUDED_OPERATIONS,
LANGFUSE_DISABLE_EXPENSIVE_POSTGRES_QUERIES:
process.env.LANGFUSE_DISABLE_EXPENSIVE_POSTGRES_QUERIES,
LANGFUSE_TEAM_SLACK_WEBHOOK: process.env.LANGFUSE_TEAM_SLACK_WEBHOOK,
@@ -484,18 +464,6 @@ export const env = createEnv({
process.env.LANGFUSE_CACHE_API_KEY_TTL_SECONDS,
LANGFUSE_ALLOWED_ORGANIZATION_CREATORS:
process.env.LANGFUSE_ALLOWED_ORGANIZATION_CREATORS,
LANGFUSE_READ_FROM_POSTGRES_ONLY:
process.env.LANGFUSE_READ_FROM_POSTGRES_ONLY,
LANGFUSE_POSTGRES_INGESTION_ENABLED:
process.env.LANGFUSE_POSTGRES_INGESTION_ENABLED,
LANGFUSE_READ_FROM_CLICKHOUSE_ONLY:
process.env.LANGFUSE_READ_FROM_CLICKHOUSE_ONLY,
LANGFUSE_RETURN_FROM_CLICKHOUSE:
process.env.LANGFUSE_RETURN_FROM_CLICKHOUSE,
LANGFUSE_EXPERIMENT_EXCLUDED_PROJECT_IDS:
process.env.LANGFUSE_EXPERIMENT_EXCLUDED_PROJECT_IDS,
LANGFUSE_READ_DASHBOARDS_FROM_CLICKHOUSE:
process.env.LANGFUSE_READ_DASHBOARDS_FROM_CLICKHOUSE,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SIGNING_SECRET: process.env.STRIPE_WEBHOOK_SIGNING_SECRET,
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
@@ -59,8 +59,8 @@ export function CloudRegionSwitch({
);
return (
<div>
<div className="mb-6 flex w-full flex-col gap-2">
<div className="-mb-10 mt-8 rounded-lg bg-card px-6 py-6 text-sm sm:mx-auto sm:w-full sm:max-w-[480px] sm:rounded-lg sm:px-10">
<div className="flex w-full flex-col gap-2">
<div>
<span className="text-sm font-medium leading-none">
Data Region
@@ -126,8 +126,12 @@ const DataRegionInfo = () => (
<div className="flex flex-col gap-2">
<p>Langfuse Cloud is available in two data regions:</p>
<ul className="list-disc pl-5">
<li>US: Northern California (AWS us-west-1) & Virginia (AWS us-east-1)</li>
<li>EU: Germany/Frankfurt (AWS eu-central-1) & Ireland (AWS eu-west-1)</li>
<li>
US: Oregon (AWS us-west-2)
</li>
<li>
EU: Ireland (AWS eu-west-1)
</li>
</ul>
<p>
Regions are strictly separated, and no data is shared across regions.
+14 -2
View File
@@ -1,12 +1,24 @@
import { cn } from "@/src/utils/tailwind";
import { MessageCircleMore } from "lucide-react";
export function CommentCountIcon({ count }: { count?: number }) {
export function CommentCountIcon({
count,
className,
}: {
count?: number;
className?: string;
}) {
if (!count) return null;
return (
<span className="relative mr-1 text-xs">
<MessageCircleMore className="h-4 w-4" />
<span className="absolute -top-0.5 left-2.5 flex max-h-[0.8rem] min-w-[0.8rem] items-center justify-center rounded-full border border-muted-foreground bg-accent-light-blue px-[0.2rem] text-[8px]">
<span
className={cn(
"absolute -top-0.5 left-2.5 flex max-h-[0.8rem] min-w-[0.8rem] items-center justify-center rounded-full border border-muted-foreground bg-accent-light-blue px-[0.2rem] text-[8px]",
className,
)}
>
{count > 99 ? "99+" : count}
</span>
</span>
@@ -4,7 +4,7 @@ import { DashboardCard } from "@/src/features/dashboard/components/cards/Dashboa
import { type FilterState } from "@langfuse/shared";
import { type DashboardDateRangeAggregationOption } from "@/src/utils/date-range-utils";
import { MultiSelectKeyValues } from "@/src/features/scores/components/multi-select-key-values";
import React, { useMemo, useState } from "react";
import React, { useMemo } from "react";
import { Separator } from "@/src/components/ui/separator";
import {
isBooleanDataType,
@@ -20,6 +20,7 @@ import DocPopup from "@/src/components/layouts/doc-popup";
import { NoDataOrLoading } from "@/src/components/NoDataOrLoading";
import { Flex, Text } from "@tremor/react";
import { useClickhouse } from "@/src/components/layouts/ClickhouseAdminToggle";
import useLocalStorage from "@/src/components/useLocalStorage";
export function ScoreAnalytics(props: {
className?: string;
@@ -27,9 +28,12 @@ export function ScoreAnalytics(props: {
globalFilterState: FilterState;
projectId: string;
}) {
const [selectedDashboardScoreKeys, setSelectedDashboardScoreKeys] = useState<
string[]
>([]);
// Stale score selections in localStorage are ignored as we only show scores that exist in scoreAnalyticsOptions
const [selectedDashboardScoreKeys, setSelectedDashboardScoreKeys] =
useLocalStorage<string[]>(
`selectedDashboardScoreKeys-${props.projectId}`,
[],
);
const scoreKeysAndProps = api.scores.getScoreKeysAndProps.useQuery(
{
@@ -1,10 +1,8 @@
import { z } from "zod";
import { isClickhouseAdminEligible } from "@/src/server/utils/checkClickhouseAccess";
import {
createTRPCRouter,
protectedProjectProcedure,
} from "@/src/server/api/trpc";
import { executeQuery } from "@/src/server/api/services/queryBuilder";
import {
filterInterface,
type groupByInterface,
@@ -33,7 +31,6 @@ import {
} from "@langfuse/shared/src/server";
import { type DatabaseRow } from "@/src/server/api/services/queryBuilder";
import { dashboardColumnDefinitions } from "@langfuse/shared";
import { env } from "@/src/env.mjs";
export const dashboardRouter = createTRPCRouter({
chart: protectedProjectProcedure
@@ -64,243 +61,227 @@ export const dashboardRouter = createTRPCRouter({
.nullish(),
}),
)
.query(async ({ input, ctx }) => {
if (
input.queryClickhouse &&
!isClickhouseAdminEligible(ctx.session.user)
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Not eligible to query clickhouse",
});
}
if (
input.queryClickhouse ||
env.LANGFUSE_READ_DASHBOARDS_FROM_CLICKHOUSE === "true"
) {
switch (input.queryName) {
case "traces-total":
const count = await getTotalTraces(
.query(async ({ input }) => {
switch (input.queryName) {
case "traces-total":
const count = await getTotalTraces(
input.projectId,
input.filter ?? [],
);
return count as DatabaseRow[];
case "traces-grouped-by-name":
return (
await getTracesGroupedByName(
input.projectId,
input.filter ?? [],
);
return count as DatabaseRow[];
case "traces-grouped-by-name":
return (
await getTracesGroupedByName(
input.projectId,
dashboardColumnDefinitions,
input.filter,
)
).map(
(row) =>
({
traceName: row.name,
countTraceId: row.count,
}) as DatabaseRow,
);
case "observations-model-cost":
const cost = await getObservationsCostGroupedByName(
input.projectId,
input.filter ?? [],
);
return cost.map((row) => ({
model: row.name,
sumCalculatedTotalCost: row.sum_cost_details,
sumTotalTokens: row.sum_usage_details,
})) as DatabaseRow[];
case "score-aggregate":
const scores = await getScoreAggregate(
input.projectId,
input.filter ?? [],
);
return scores.map((row) => ({
scoreName: row.name,
scoreSource: row.source,
scoreDataType: row.data_type,
avgValue: row.avg_value,
countScoreId: Number(row.count),
})) as DatabaseRow[];
case "traces-timeseries":
const dateTrunc = extractTimeSeries(input.groupBy);
if (!dateTrunc) {
return [];
}
const rows = await groupTracesByTime(
input.projectId,
input.filter ?? [],
dateTrunc,
);
return rows as DatabaseRow[];
case "observations-usage-timeseries":
const dateTruncObs = extractTimeSeries(input.groupBy);
if (!dateTruncObs) {
return [];
}
const rowsObs = await getObservationUsageByTime(
input.projectId,
input.filter ?? [],
dateTruncObs,
);
return rowsObs.map((row) => ({
startTime: row.start_time,
units: row.units,
cost: row.cost,
model: row.provided_model_name,
})) as DatabaseRow[];
case "distinct-models":
const models = await getDistinctModels(
input.projectId,
input.filter ?? [],
);
return models as DatabaseRow[];
case "scores-aggregate-timeseries":
const dateTruncScores = extractTimeSeries(input.groupBy);
if (!dateTruncScores) {
return [];
}
const aggregatedScores = await getScoresAggregateOverTime(
input.projectId,
input.filter ?? [],
dateTruncScores,
);
return aggregatedScores as DatabaseRow[];
case "observations-usage-by-users":
const rowsUsers = await getModelUsageByUser(
input.projectId,
input.filter ?? [],
);
return rowsUsers.map((row) => ({
sumTotalTokens: row.sumUsageDetails,
sumCalculatedTotalCost: row.sumCostDetails,
user: row.userId,
})) as DatabaseRow[];
case "traces-grouped-by-user":
const traces = await getTracesGroupedByUsers(
input.projectId,
input.filter ?? [],
undefined,
1000,
0,
dashboardColumnDefinitions,
);
input.filter,
)
).map(
(row) =>
({
traceName: row.name,
countTraceId: row.count,
}) as DatabaseRow,
);
case "observations-model-cost":
const cost = await getObservationsCostGroupedByName(
input.projectId,
input.filter ?? [],
);
return traces.map((row) => ({
user: row.user,
countTraceId: Number(row.count),
})) as DatabaseRow[];
case "observation-latencies-aggregated":
const latencies = await getObservationLatencies(
return cost.map((row) => ({
model: row.name,
sumCalculatedTotalCost: row.sum_cost_details,
sumTotalTokens: row.sum_usage_details,
})) as DatabaseRow[];
case "score-aggregate":
const scores = await getScoreAggregate(
input.projectId,
input.filter ?? [],
);
return scores.map((row) => ({
scoreName: row.name,
scoreSource: row.source,
scoreDataType: row.data_type,
avgValue: row.avg_value,
countScoreId: Number(row.count),
})) as DatabaseRow[];
case "traces-timeseries":
const dateTrunc = extractTimeSeries(input.groupBy);
if (!dateTrunc) {
return [];
}
const rows = await groupTracesByTime(
input.projectId,
input.filter ?? [],
dateTrunc,
);
return rows as DatabaseRow[];
case "observations-usage-timeseries":
const dateTruncObs = extractTimeSeries(input.groupBy);
if (!dateTruncObs) {
return [];
}
const rowsObs = await getObservationUsageByTime(
input.projectId,
input.filter ?? [],
dateTruncObs,
);
return rowsObs.map((row) => ({
startTime: row.start_time,
units: row.units,
cost: row.cost,
model: row.provided_model_name,
})) as DatabaseRow[];
case "distinct-models":
const models = await getDistinctModels(
input.projectId,
input.filter ?? [],
);
return models as DatabaseRow[];
case "scores-aggregate-timeseries":
const dateTruncScores = extractTimeSeries(input.groupBy);
if (!dateTruncScores) {
return [];
}
const aggregatedScores = await getScoresAggregateOverTime(
input.projectId,
input.filter ?? [],
dateTruncScores,
);
return aggregatedScores as DatabaseRow[];
case "observations-usage-by-users":
const rowsUsers = await getModelUsageByUser(
input.projectId,
input.filter ?? [],
);
return rowsUsers.map((row) => ({
sumTotalTokens: row.sumUsageDetails,
sumCalculatedTotalCost: row.sumCostDetails,
user: row.userId,
})) as DatabaseRow[];
case "traces-grouped-by-user":
const traces = await getTracesGroupedByUsers(
input.projectId,
input.filter ?? [],
undefined,
1000,
0,
dashboardColumnDefinitions,
);
return traces.map((row) => ({
user: row.user,
countTraceId: Number(row.count),
})) as DatabaseRow[];
case "observation-latencies-aggregated":
const latencies = await getObservationLatencies(
input.projectId,
input.filter ?? [],
);
return latencies.map((row) => ({
name: row.name,
percentile50Duration: row.p50,
percentile90Duration: row.p90,
percentile95Duration: row.p95,
percentile99Duration: row.p99,
})) as DatabaseRow[];
case "model-latencies-over-time":
const dateTruncModels = extractTimeSeries(input.groupBy);
if (!dateTruncModels) {
return [];
}
const modelLatencies = await getModelLatenciesOverTime(
input.projectId,
input.filter ?? [],
dateTruncModels,
);
return modelLatencies.map((row) => ({
model: row.model,
startTime: row.start_time,
percentile50Duration: row.p50,
percentile75Duration: row.p75,
percentile90Duration: row.p90,
percentile95Duration: row.p95,
percentile99Duration: row.p99,
})) as DatabaseRow[];
case "traces-latencies-aggregated":
const traceLatencies = await getTracesLatencies(
input.projectId,
input.filter ?? [],
);
return traceLatencies.map((row) => ({
traceName: row.name,
percentile50Duration: row.p50,
percentile90Duration: row.p90,
percentile95Duration: row.p95,
percentile99Duration: row.p99,
})) as DatabaseRow[];
case "numeric-score-time-series":
const dateTruncNumericScoreTimeSeries = extractTimeSeries(
input.groupBy,
);
if (!dateTruncNumericScoreTimeSeries) {
return [];
}
const numericScoreTimeSeries = await getNumericScoreTimeSeries(
input.projectId,
input.filter ?? [],
dateTruncNumericScoreTimeSeries,
);
return numericScoreTimeSeries.map((row) => ({
scoreTimestamp: row.score_timestamp,
scoreName: row.score_name,
avgValue: row.avg_value,
})) as DatabaseRow[];
case "categorical-score-chart":
const dateTruncCategoricalScoreTimeSeries = extractTimeSeries(
input.groupBy,
);
const categoricalScoreTimeSeries =
await getCategoricalScoreTimeSeries(
input.projectId,
input.filter ?? [],
dateTruncCategoricalScoreTimeSeries,
);
return categoricalScoreTimeSeries.map((row) => ({
...(row.score_timestamp
? { scoreTimestamp: row.score_timestamp }
: {}),
scoreName: row.score_name || null,
scoreDataType: row.score_data_type || null,
scoreSource: row.score_source || null,
stringValue: row.score_value || null,
countStringValue: Number(row.count) || 0,
})) as DatabaseRow[];
case "observations-status-timeseries":
const timeSeriesGroupBy = extractTimeSeries(input.groupBy);
return latencies.map((row) => ({
name: row.name,
percentile50Duration: row.p50,
percentile90Duration: row.p90,
percentile95Duration: row.p95,
percentile99Duration: row.p99,
})) as DatabaseRow[];
case "model-latencies-over-time":
const dateTruncModels = extractTimeSeries(input.groupBy);
if (!dateTruncModels) {
return [];
}
const modelLatencies = await getModelLatenciesOverTime(
input.projectId,
input.filter ?? [],
dateTruncModels,
);
return (await getObservationsStatusTimeSeries(
input.projectId,
input.filter ?? [],
timeSeriesGroupBy,
)) as DatabaseRow[];
return modelLatencies.map((row) => ({
model: row.model,
startTime: row.start_time,
percentile50Duration: row.p50,
percentile75Duration: row.p75,
percentile90Duration: row.p90,
percentile95Duration: row.p95,
percentile99Duration: row.p99,
})) as DatabaseRow[];
case "traces-latencies-aggregated":
const traceLatencies = await getTracesLatencies(
input.projectId,
input.filter ?? [],
);
return traceLatencies.map((row) => ({
traceName: row.name,
percentile50Duration: row.p50,
percentile90Duration: row.p90,
percentile95Duration: row.p95,
percentile99Duration: row.p99,
})) as DatabaseRow[];
case "numeric-score-time-series":
const dateTruncNumericScoreTimeSeries = extractTimeSeries(
input.groupBy,
);
if (!dateTruncNumericScoreTimeSeries) {
return [];
}
const numericScoreTimeSeries = await getNumericScoreTimeSeries(
input.projectId,
input.filter ?? [],
dateTruncNumericScoreTimeSeries,
);
return numericScoreTimeSeries.map((row) => ({
scoreTimestamp: row.score_timestamp,
scoreName: row.score_name,
avgValue: row.avg_value,
})) as DatabaseRow[];
case "categorical-score-chart":
const dateTruncCategoricalScoreTimeSeries = extractTimeSeries(
input.groupBy,
);
const categoricalScoreTimeSeries =
await getCategoricalScoreTimeSeries(
input.projectId,
input.filter ?? [],
dateTruncCategoricalScoreTimeSeries,
);
return categoricalScoreTimeSeries.map((row) => ({
...(row.score_timestamp
? { scoreTimestamp: row.score_timestamp }
: {}),
scoreName: row.score_name || null,
scoreDataType: row.score_data_type || null,
scoreSource: row.score_source || null,
stringValue: row.score_value || null,
countStringValue: Number(row.count) || 0,
})) as DatabaseRow[];
case "observations-status-timeseries":
const timeSeriesGroupBy = extractTimeSeries(input.groupBy);
return (await getObservationsStatusTimeSeries(
input.projectId,
input.filter ?? [],
timeSeriesGroupBy,
)) as DatabaseRow[];
default:
throw new TRPCError({
code: "NOT_FOUND",
message: "Query not found",
});
}
default:
throw new TRPCError({
code: "NOT_FOUND",
message: "Query not found",
});
}
return await executeQuery(ctx.prisma, input.projectId, input);
}),
scoreHistogram: protectedProjectProcedure
.input(
@@ -310,30 +291,12 @@ export const dashboardRouter = createTRPCRouter({
queryClickhouse: z.boolean().default(false),
}),
)
.query(async ({ input, ctx }) => {
if (
input.queryClickhouse &&
!isClickhouseAdminEligible(ctx.session.user)
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Not eligible to query clickhouse",
});
}
if (
input.queryClickhouse ||
env.LANGFUSE_READ_DASHBOARDS_FROM_CLICKHOUSE === "true"
) {
const data = await getNumericScoreHistogram(
input.projectId,
input.filter ?? [],
input.limit ?? 10000,
);
return createHistogramData(data);
}
const data = await executeQuery(ctx.prisma, input.projectId, input);
.query(async ({ input }) => {
const data = await getNumericScoreHistogram(
input.projectId,
input.filter ?? [],
input.limit ?? 10000,
);
return createHistogramData(data);
}),
});
@@ -4,23 +4,11 @@ import {
createTRPCRouter,
protectedProjectProcedure,
} from "@/src/server/api/trpc";
import {
type DatasetRuns,
Prisma,
type Dataset,
} from "@langfuse/shared/src/db";
import { Prisma, type Dataset } from "@langfuse/shared/src/db";
import { throwIfNoProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { auditLog } from "@/src/features/audit-logs/auditLog";
import { DB } from "@/src/server/db";
import {
type PrismaClient,
type ScoreAggregate,
type ScoreSimplified,
filterAndValidateDbScoreList,
paginationZod,
} from "@langfuse/shared";
import { aggregateScores } from "@/src/features/scores/lib/aggregateScores";
import { measureAndReturnApi } from "@/src/server/utils/checkClickhouseAccess";
import { paginationZod } from "@langfuse/shared";
import {
createDatasetRunsTable,
createDatasetRunsTableWithoutMetrics,
@@ -28,7 +16,6 @@ import {
fetchDatasetItems,
getRunItemsByRunIdOrItemId,
} from "@/src/features/datasets/server/service";
import { traceException } from "@langfuse/shared/src/server";
export const datasetRouter = createTRPCRouter({
allDatasetMeta: protectedProjectProcedure
@@ -163,64 +150,42 @@ export const datasetRouter = createTRPCRouter({
runsByDatasetId: protectedProjectProcedure
.input(datasetRunsTableSchema)
.query(async ({ input, ctx }) => {
return await measureAndReturnApi({
input,
operation: "datasets.runsByDatasetId",
user: ctx.session.user,
pgExecution: async () => {
return await runsByDatasetIdPg(ctx.prisma, input);
},
clickhouseExecution: async () => {
// we cannot easily join all the tracing data with the dataset run items
// hence, we pull the trace_ids and observation_ids separately for all run items
// afterwards, we aggregate them per run
// we cannot easily join all the tracing data with the dataset run items
// hence, we pull the trace_ids and observation_ids separately for all run items
// afterwards, we aggregate them per run
const runs = await createDatasetRunsTableWithoutMetrics(input);
const runs = await createDatasetRunsTableWithoutMetrics(input);
const totalRuns = await ctx.prisma.datasetRuns.count({
where: {
datasetId: input.datasetId,
projectId: input.projectId,
},
});
return {
totalRuns,
runs,
};
const totalRuns = await ctx.prisma.datasetRuns.count({
where: {
datasetId: input.datasetId,
projectId: input.projectId,
},
});
return {
totalRuns,
runs,
};
}),
runsByDatasetIdMetrics: protectedProjectProcedure
.input(datasetRunsTableSchema)
.query(async ({ input, ctx }) => {
return await measureAndReturnApi({
input,
operation: "datasets.runsByDatasetId",
user: ctx.session.user,
pgExecution: async () => {
return await runsByDatasetIdPg(ctx.prisma, input);
},
clickhouseExecution: async () => {
// we cannot easily join all the tracing data with the dataset run items
// hence, we pull the trace_ids and observation_ids separately for all run items
// afterwards, we aggregate them per run
// we cannot easily join all the tracing data with the dataset run items
// hence, we pull the trace_ids and observation_ids separately for all run items
// afterwards, we aggregate them per run
const runs = await createDatasetRunsTable(input);
const runs = await createDatasetRunsTable(input);
const totalRuns = await ctx.prisma.datasetRuns.count({
where: {
datasetId: input.datasetId,
projectId: input.projectId,
},
});
return {
totalRuns,
runs,
};
const totalRuns = await ctx.prisma.datasetRuns.count({
where: {
datasetId: input.datasetId,
projectId: input.projectId,
},
});
return {
totalRuns,
runs,
};
}),
itemById: protectedProjectProcedure
.input(
@@ -686,107 +651,11 @@ export const datasetRouter = createTRPCRouter({
},
});
return await measureAndReturnApi({
input,
operation: "datasets.runitemsByRunIdOrItemId",
user: ctx.session.user,
pgExecution: async () => {
const traceScores = await ctx.prisma.score.findMany({
where: {
projectId: ctx.session.projectId,
traceId: {
in: runItems.map((ri) => ri.traceId),
},
},
});
const observations = await ctx.prisma.observationView.findMany({
where: {
id: {
in: runItems
.map((ri) => ri.observationId)
.filter(Boolean) as string[],
},
projectId: ctx.session.projectId,
},
select: {
id: true,
latency: true,
calculatedTotalCost: true,
},
});
// Directly access 'traces' table and calculate duration via lateral join
// Previously used 'traces_view' was not performant enough
const traceIdsSQL = Prisma.sql`ARRAY[${Prisma.join(runItems.map((ri) => ri.traceId))}]`;
const traces = await ctx.prisma.$queryRaw<
{
id: string;
duration: number;
totalCost: number;
}[]
>(
Prisma.sql`
SELECT
t.id,
o.duration,
o.total_cost as "totalCost"
FROM
traces t
LEFT JOIN LATERAL (
SELECT
EXTRACT(epoch FROM COALESCE(max(o1.end_time), max(o1.start_time)))::double precision - EXTRACT(epoch FROM min(o1.start_time))::double precision AS duration,
SUM(COALESCE(o1.calculated_total_cost, 0))::double precision AS total_cost
FROM
-- Use observations_view as cost are not backfilled for self-hosters. Once V3 is migration is done, we can use observations instead
observations_view o1
WHERE
o1.project_id = ${input.projectId}
AND o1.trace_id = t.id
GROUP BY
o1.project_id,
o1.trace_id) o ON TRUE
WHERE
t.project_id = ${input.projectId}
AND t.id = ANY(${traceIdsSQL})
`,
);
const validatedTraceScores = filterAndValidateDbScoreList(
traceScores,
traceException,
);
const items = runItems.map((ri) => {
return {
id: ri.id,
createdAt: ri.createdAt,
datasetItemId: ri.datasetItemId,
observation: observations.find((o) => o.id === ri.observationId),
trace: traces.find((t) => t.id === ri.traceId),
scores: aggregateScores(
validatedTraceScores.filter((s) => s.traceId === ri.traceId),
),
};
});
// Note: We early return in case of no run items, when adding parameters here, make sure to update the early return above
return {
totalRunItems,
runItems: items,
};
},
clickhouseExecution: async () => {
// Note: We early return in case of no run items, when adding parameters here, make sure to update the early return above
return {
totalRunItems,
runItems: await getRunItemsByRunIdOrItemId(
input.projectId,
runItems,
),
};
},
});
// Note: We early return in case of no run items, when adding parameters here, make sure to update the early return above
return {
totalRunItems,
runItems: await getRunItemsByRunIdOrItemId(input.projectId, runItems),
};
}),
datasetItemsBasedOnTraceOrObservation: protectedProjectProcedure
.input(
@@ -851,161 +720,3 @@ export const datasetRouter = createTRPCRouter({
return deletedDatasetRun;
}),
});
async function runsByDatasetIdPg(
prisma: PrismaClient,
input: {
projectId: string;
datasetId: string;
queryClickhouse: boolean;
page?: number;
limit?: number;
runIds?: string[];
},
) {
const scoresByRunId = await prisma.$queryRaw<
Array<{ scores: Array<ScoreSimplified>; runId: string }>
>(Prisma.sql`
SELECT
runs.id "runId",
array_agg(s.score) AS "scores"
FROM
dataset_runs runs
JOIN datasets ON datasets.id = runs.dataset_id AND datasets.project_id = ${input.projectId}
LEFT JOIN LATERAL (
SELECT
jsonb_build_object ('name', s.name, 'stringValue', s.string_value, 'value', s.value, 'source', s."source", 'dataType', s.data_type, 'comment', s.comment) AS "score"
FROM
dataset_run_items ri
JOIN scores s
ON s.trace_id = ri.trace_id
AND (ri.observation_id IS NULL OR s.observation_id = ri.observation_id)
AND s.project_id = ${input.projectId}
JOIN traces t ON t.id = s.trace_id AND t.project_id = ${input.projectId}
WHERE
ri.project_id = ${input.projectId}
AND ri.dataset_run_id = runs.id
) s ON true
WHERE
runs.dataset_id = ${input.datasetId}
AND runs.project_id = ${input.projectId}
AND s.score IS NOT NULL
${input.runIds ? Prisma.sql`AND runs.id IN (${Prisma.join(input.runIds)})` : Prisma.empty}
GROUP BY
runs.id
${input.limit ? Prisma.sql`LIMIT ${input.limit}` : Prisma.empty}
${input.page && input.limit ? Prisma.sql`OFFSET ${input.page * input.limit}` : Prisma.empty}
`);
const runs = await prisma.$queryRaw<
Array<
DatasetRuns & {
avgLatency: number | undefined;
avgTotalCost: Prisma.Decimal | undefined;
countRunItems: number;
}
>
>(Prisma.sql`
SELECT
runs.id,
runs.name,
runs.description,
runs.metadata,
runs.created_at "createdAt",
runs.updated_at "updatedAt",
COALESCE(o_latency_and_total_cost. "o_avgLatency", t_latency_and_total_cost."t_avgLatency", 0) "avgLatency",
COALESCE(o_latency_and_total_cost. "o_avgTotalCost", t_latency_and_total_cost."t_avgTotalCost", 0) "avgTotalCost",
COALESCE(run_items_count.count, 0)::int "countRunItems"
FROM
dataset_runs runs
JOIN datasets ON datasets.id = runs.dataset_id
AND datasets.project_id = ${input.projectId}
-- Add average latency and cost if a run's items are linked to observations
-- LIMITATION: this will only work if all items for a given run are linked to either observations or traces
-- If a run has items linked to both observations and traces, the average latency and cost will be incorrect as only those from the observations will be used
LEFT JOIN LATERAL (
SELECT
AVG(o.latency) AS "o_avgLatency",
AVG(COALESCE(o.calculated_total_cost, 0)) AS "o_avgTotalCost"
FROM
dataset_run_items ri
JOIN observations_view o ON o.id = ri.observation_id
AND o.project_id = ${input.projectId}
WHERE
ri.project_id = ${input.projectId}
AND ri.dataset_run_id = runs.id) o_latency_and_total_cost ON TRUE
-- Add average latency and cost if run's items are linked to traces
LEFT JOIN LATERAL (
-- Average across run items. One run has many items
SELECT
AVG(trace_latency_cost.duration) AS "t_avgLatency",
AVG(trace_latency_cost.total_cost) AS "t_avgTotalCost"
FROM
dataset_run_items ri
LEFT JOIN LATERAL (
-- Latency and cost for a run item's trace
SELECT
t.id,
o.duration,
o.total_cost
FROM
traces t
LEFT JOIN LATERAL (
-- Latency and cost across a trace's observations
SELECT
EXTRACT(epoch FROM COALESCE(max(o1.end_time), max(o1.start_time)))::double precision - EXTRACT(epoch FROM min(o1.start_time))::double precision AS duration,
SUM(COALESCE(o1.calculated_total_cost, 0)) AS total_cost
FROM
-- Use observations_view as cost are not backfilled for self-hosters. Once V3 is migration is done, we can use observations instead
observations_view o1
WHERE
o1.project_id = ${input.projectId}
AND o1.trace_id = t.id
GROUP BY
o1.project_id,
o1.trace_id) o ON TRUE
WHERE
t.project_id = ${input.projectId}
AND t.id = ri.trace_id) trace_latency_cost ON TRUE
WHERE
ri.project_id = ${input.projectId}
AND ri.dataset_run_id = runs.id) t_latency_and_total_cost ON TRUE
-- Add run item counts
LEFT JOIN LATERAL (
SELECT
count(*) AS count
FROM
dataset_run_items ri
WHERE
ri.dataset_run_id = runs.id
AND ri.project_id = ${input.projectId}) run_items_count ON TRUE
WHERE
runs.dataset_id = ${input.datasetId}
AND runs.project_id = ${input.projectId}
${input.runIds ? Prisma.sql`AND runs.id IN (${Prisma.join(input.runIds)})` : Prisma.empty}
ORDER BY
runs.created_at DESC
${input.limit ? Prisma.sql`LIMIT ${input.limit}` : Prisma.empty}
${input.page && input.limit ? Prisma.sql`OFFSET ${input.page * input.limit}` : Prisma.empty}
`);
const totalRuns = await prisma.datasetRuns.count({
where: {
datasetId: input.datasetId,
projectId: input.projectId,
},
});
return {
totalRuns,
runs: runs.map((run) => ({
...run,
scores: aggregateScores(
scoresByRunId.flatMap((s) => (s.runId === run.id ? s.scores : [])),
) as ScoreAggregate | undefined,
})),
};
}
+22 -63
View File
@@ -25,7 +25,6 @@ import {
} from "@langfuse/shared/src/server";
import { aggregateScores } from "@/src/features/scores/lib/aggregateScores";
import Decimal from "decimal.js";
import { measureAndReturnApi } from "@/src/server/utils/checkClickhouseAccess";
import { env } from "@/src/env.mjs";
export const datasetRunsTableSchema = z.object({
@@ -71,8 +70,8 @@ export const createDatasetRunsTableWithoutMetrics = async (
createdAt: run.run_created_at,
updatedAt: run.run_updated_at,
// return metric fields as undefined
avgTotalCost: undefined,
avgLatency: undefined,
avgTotalCost: undefined as Decimal | undefined,
avgLatency: undefined as number | undefined,
scores: undefined,
}));
};
@@ -187,8 +186,6 @@ export const createTempTableInClickhouse = async (
)
ENGINE = ${env.CLICKHOUSE_CLUSTER_ENABLED === "true" ? "ReplicatedMergeTree()" : "MergeTree()"}
PRIMARY KEY (project_id, dataset_id, run_id, trace_id)
`;
await commandClickhouse({
query,
@@ -202,7 +199,7 @@ export const deleteTempTableInClickhouse = async (
sessionId: string,
) => {
const query = `
DROP TABLE IF EXISTS ${tableName}
DROP TABLE IF EXISTS ${tableName} ${env.CLICKHOUSE_CLUSTER_ENABLED === "true" ? "ON CLUSTER " + env.CLICKHOUSE_CLUSTER_NAME : ""}
`;
await commandClickhouse({
query,
@@ -425,65 +422,27 @@ export const fetchDatasetItems = async (input: DatasetRunItemsTableInput) => {
});
// check in clickhouse if the traces already exist. They arrive delayed.
const tracingData = await measureAndReturnApi({
input: { queryClickhouse: false, projectId: input.projectId },
operation: "datasets.itemsByDatasetId",
user: null,
pgExecution: async () => {
const traces = await input.prisma.trace.findMany({
where: {
id: {
in: datasetItems
.map((item) => item.sourceTraceId)
.filter((id): id is string => Boolean(id)),
},
projectId: input.projectId,
},
});
const traces = await getTracesByIds(
datasetItems
.map((item) => item.sourceTraceId)
.filter((id): id is string => Boolean(id)),
input.projectId,
);
const observations = await input.prisma.observation.findMany({
where: {
id: {
in: datasetItems
.map((item) => item.sourceObservationId)
.filter((id): id is string => Boolean(id)),
},
projectId: input.projectId,
},
});
const observations = await getObservationsById(
datasetItems
.map((item) => item.sourceObservationId)
.filter((id): id is string => Boolean(id)),
input.projectId,
);
return {
traceIds: traces.map((t) => t.id),
observationIds: observations.map((o) => ({
id: o.id,
traceId: o.traceId,
})),
};
},
clickhouseExecution: async () => {
const traces = await getTracesByIds(
datasetItems
.map((item) => item.sourceTraceId)
.filter((id): id is string => Boolean(id)),
input.projectId,
);
const observations = await getObservationsById(
datasetItems
.map((item) => item.sourceObservationId)
.filter((id): id is string => Boolean(id)),
input.projectId,
);
return {
traceIds: traces.map((t) => t.id),
observationIds: observations.map((o) => ({
id: o.id,
traceId: o.traceId,
})),
};
},
});
const tracingData = {
traceIds: traces.map((t) => t.id),
observationIds: observations.map((o) => ({
id: o.id,
traceId: o.traceId,
})),
};
return {
totalDatasetItems,
@@ -6,10 +6,11 @@ import {
DialogTitle,
DialogTrigger,
DialogDescription,
DialogPortal,
} from "@/src/components/ui/dialog";
import { useState } from "react";
import Link from "next/link";
import { Bug, LifeBuoy, Sparkles } from "lucide-react";
import { Bug, Sparkles } from "lucide-react";
interface FeedbackDialogProps {
className?: string;
@@ -28,32 +29,33 @@ export function FeedbackButtonWrapper({
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className={className} asChild>
<DialogTrigger
className={className}
asChild
onClick={(e) => e.stopPropagation()}
>
{children}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="flex flex-row flex-wrap items-center justify-center gap-3 sm:justify-start">
<Link href="https://langfuse.com/ideas" target="_blank">
<Button variant="secondary">
<Sparkles className="mr-2 h-4 w-4" /> Submit Feature Request
</Button>
</Link>
<Link href="https://langfuse.com/issues" target="_blank">
<Button variant="secondary">
<Bug className="mr-2 h-4 w-4" /> Report a Bug
</Button>
</Link>
<Link href="/support" target="_blank">
<Button variant="outline">
<LifeBuoy className="mr-2 h-4 w-4" /> Support
</Button>
</Link>
</div>
</DialogContent>
<DialogPortal>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="flex flex-row flex-wrap items-center justify-center gap-3 sm:justify-start">
<Link href="https://langfuse.com/ideas" target="_blank">
<Button variant="secondary">
<Sparkles className="mr-2 h-4 w-4" /> Submit Feature Request
</Button>
</Link>
<Link href="https://langfuse.com/issues" target="_blank">
<Button variant="secondary">
<Bug className="mr-2 h-4 w-4" /> Report a Bug
</Button>
</Link>
</div>
</DialogContent>
</DialogPortal>
</Dialog>
);
}
@@ -227,6 +227,14 @@ export function InlineFilterBuilder({
);
}
const getOperator = (
type: NonNullable<WipFilterCondition["type"]>,
): WipFilterCondition["operator"] => {
return filterOperators[type]?.length > 0
? filterOperators[type][0]
: undefined;
};
function FilterBuilderForm({
columns,
filterState,
@@ -312,19 +320,18 @@ function FilterBuilderForm({
const col = columns.find(
(c) => c.id === value,
);
const defaultOperator = col?.type
? getOperator(col.type)
: undefined;
handleFilterChange(
{
column: col?.name,
type: col?.type,
operator:
// does not work as expected on eval-template form when embedded into form via InlineFilterBuilder
// col?.type !== undefined &&
// filterOperators[col.type]?.length > 0
// ? (filterOperators[col.type][0] as any) // operator matches type
undefined,
operator: defaultOperator,
value: undefined,
key: undefined,
},
} as WipFilterCondition,
i,
);
}}
@@ -391,6 +398,8 @@ function FilterBuilderForm({
<Select
disabled={!filter.column || disabled}
onValueChange={(value) => {
// protect against invalid empty operator values
if (value === "") return;
handleFilterChange(
{
...filter,
@@ -1,4 +1,10 @@
import { Building2, LifeBuoy, LockIcon, Settings, Users } from "lucide-react";
import {
BookOpen,
LockIcon,
MessageSquareText,
Settings,
Users,
} from "lucide-react";
import {
Card,
CardContent,
@@ -13,7 +19,6 @@ import { PlusIcon } from "lucide-react";
import Link from "next/link";
import { StringParam, useQueryParams } from "use-query-params";
import { Input } from "@/src/components/ui/input";
import { Alert, AlertTitle, AlertDescription } from "@/src/components/ui/alert";
import { useHasOrganizationAccess } from "@/src/features/rbac/utils/checkOrganizationAccess";
import { env } from "@/src/env.mjs";
import { Divider } from "@tremor/react";
@@ -178,7 +183,12 @@ export const OrganizationProjectOverview = () => {
{!queryOrgId && (
<>
<Header
title="Home"
title="Organizations"
help={{
description:
"Organizations help you manage access to projects. Each organization can have multiple projects and team members with different roles.",
href: "https://langfuse.com/docs/rbac",
}}
actionButtons={
<>
<Input
@@ -197,7 +207,6 @@ export const OrganizationProjectOverview = () => {
</>
}
/>
{!showOnboarding && <IntroducingOrganizations />}
</>
)}
{showOnboarding && <Onboarding />}
@@ -227,29 +236,11 @@ export const OrganizationProjectOverview = () => {
);
};
const IntroducingOrganizations = () => (
<Alert className="mb-10 mt-5">
<Building2 className="h-4 w-4" />
<AlertTitle>Introducing Organizations</AlertTitle>
<AlertDescription>
Organizations are a way to group projects and manage access to them. See{" "}
<Link
href="https://langfuse.com/changelog/2024-08-13-organizations"
className="underline"
target="_blank"
>
changelog
</Link>{" "}
to learn more.
</AlertDescription>
</Alert>
);
const Onboarding = () => {
const session = useSession();
const canCreateOrgs = session.data?.user?.canCreateOrganizations;
return (
<Card>
<Card className="mt-5">
<CardHeader>
<CardTitle data-testid="create-new-project-title">
Get Started
@@ -272,9 +263,15 @@ const Onboarding = () => {
</Button>
)}
<Button variant="secondary" asChild>
<Link href="/support">
<LifeBuoy className="mr-2 h-4 w-4" aria-hidden="true" />
Support
<Link href="https://langfuse.com/docs" target="_blank">
<BookOpen className="mr-2 h-4 w-4" aria-hidden="true" />
Docs
</Link>
</Button>
<Button variant="secondary" asChild>
<Link href="https://langfuse.com/docs/ask-ai" target="_blank">
<MessageSquareText className="mr-2 h-4 w-4" aria-hidden="true" />
Ask AI
</Link>
</Button>
</CardFooter>
@@ -159,6 +159,12 @@ export const PromptDetail = () => {
]}
actionButtons={
<>
<JumpToPlaygroundButton
source="prompt"
prompt={prompt}
analyticsEventName="prompt_detail:test_in_playground_button_click"
variant="outline"
/>
{hasAccess ? (
<>
{hasEntitlement && (
@@ -168,11 +174,11 @@ export const PromptDetail = () => {
>
<DialogTrigger asChild disabled={!hasExperimentWriteAccess}>
<Button
variant="secondary"
variant="outline"
disabled={!hasExperimentWriteAccess}
>
<FlaskConical className="h-4 w-4" />
<span className="ml-2">New experiment</span>
<span className="ml-2">Experiment</span>
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto">
@@ -194,7 +200,7 @@ export const PromptDetail = () => {
)}
<Button
variant="secondary"
variant="default"
onClick={() => {
capture("prompts:update_form_open");
}}
@@ -217,12 +223,6 @@ export const PromptDetail = () => {
</div>
</Button>
)}
<JumpToPlaygroundButton
source="prompt"
prompt={prompt}
analyticsEventName="prompt_detail:test_in_playground_button_click"
variant="outline"
/>
<DetailPageNav
key="nav"
currentId={promptName}
@@ -262,7 +262,11 @@ export const PromptDetail = () => {
</div>
<div className="col-span-2 md:h-full">
{prompt.type === PromptType.Chat && chatMessages ? (
<OpenAiMessageView title="Chat prompt" messages={chatMessages} />
<OpenAiMessageView
title="Chat prompt"
messages={chatMessages}
collapseLongHistory={false}
/>
) : typeof prompt.prompt === "string" ? (
<CodeView content={prompt.prompt} title="Text prompt" />
) : (
@@ -12,7 +12,6 @@ import {
} from "@/src/server/api/trpc";
import { type Prompt, Prisma } from "@langfuse/shared/src/db";
import { createPrompt } from "../actions/createPrompt";
import { type ScoreSimplified } from "@langfuse/shared";
import { promptsTableCols } from "@/src/server/api/definitions/promptsTable";
import { optionalPaginationZod, paginationZod } from "@langfuse/shared";
import { orderBy, singleFilter } from "@langfuse/shared";
@@ -28,7 +27,6 @@ import {
getAggregatedScoresForPrompts,
} from "@langfuse/shared/src/server";
import { aggregateScores } from "@/src/features/scores/lib/aggregateScores";
import { measureAndReturnApi } from "@/src/server/utils/checkClickhouseAccess";
const PromptFilterOptions = z.object({
projectId: z.string(), // Required for protectedProjectProcedure
@@ -107,63 +105,16 @@ export const promptRouter = createTRPCRouter({
queryClickhouse: z.boolean().default(false),
}),
)
.query(async ({ input, ctx }) => {
.query(async ({ input }) => {
if (input.promptNames.length === 0) return [];
return await measureAndReturnApi({
input,
operation: "prompts.metrics",
user: ctx.session.user,
pgExecution: async () => {
const promptCounts = await ctx.prisma.$queryRaw<
{
promptName: string;
observationCount: bigint;
}[]
>(
Prisma.sql`
WITH prompt_ids AS (
SELECT
p.id,
p.name
FROM
prompts p
WHERE
p.project_id = ${input.projectId}
AND p.name IN (${Prisma.join(input.promptNames)})
)
SELECT
p.name AS "promptName", SUM(oc.observation_count) AS "observationCount"
FROM
prompt_ids p
LEFT JOIN LATERAL (
SELECT
COUNT(*) AS observation_count
FROM
observations o
WHERE
o.project_id = ${input.projectId}
AND o.prompt_id = p.id) oc ON TRUE
GROUP BY
p.name
`,
);
return promptCounts.map(({ promptName, observationCount }) => ({
promptName,
observationCount: Number(observationCount),
}));
},
clickhouseExecution: async () => {
const res = await getObservationsWithPromptName(
input.projectId,
input.promptNames,
);
return res.map(({ promptName, count }) => ({
promptName,
observationCount: count,
}));
},
});
const res = await getObservationsWithPromptName(
input.projectId,
input.promptNames,
);
return res.map(({ promptName, count }) => ({
promptName,
observationCount: count,
}));
}),
byId: protectedProjectProcedure
.input(
@@ -708,171 +659,34 @@ export const promptRouter = createTRPCRouter({
scope: "prompts:read",
});
return await measureAndReturnApi({
input,
operation: "prompts.versionMetrics",
user: ctx.session.user,
pgExecution: async () => {
if (input.promptIds.length === 0) return [];
const [metrics, generationScores, traceScores] = await Promise.all([
// metrics
ctx.prisma.$queryRaw<
Array<{
id: string;
observationCount: bigint;
firstUsed: Date | null;
lastUsed: Date | null;
medianOutputTokens: number | null;
medianInputTokens: number | null;
medianTotalCost: number | null;
medianLatency: number | null;
}>
>(
Prisma.sql`
select p.id, p.version, observation_metrics.* from prompts p
LEFT JOIN LATERAL (
SELECT
count(*) AS "observationCount",
MIN(o.start_time) AS "firstUsed",
MAX(o.start_time) AS "lastUsed",
PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY o.completion_tokens) AS "medianOutputTokens",
PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY o.prompt_tokens) AS "medianInputTokens",
PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY o.calculated_total_cost) AS "medianTotalCost",
PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY o.latency) AS "medianLatency"
FROM
"observations_view" o
WHERE
o.prompt_id = p.id
AND "type" = 'GENERATION'
AND "project_id" = ${input.projectId}
) AS observation_metrics ON true
WHERE "project_id" = ${input.projectId}
AND p.id in (${Prisma.join(input.promptIds)})
ORDER BY version DESC
`,
),
// generationScores
ctx.prisma.$queryRaw<
Array<{
promptId: string;
scores: Array<ScoreSimplified>;
}>
>(Prisma.sql`
SELECT
p.id AS "promptId",
array_agg(s.score) AS "scores"
FROM
prompts p
LEFT JOIN LATERAL (
SELECT
jsonb_build_object ('name', s.name, 'stringValue', s.string_value, 'value', s.value, 'source', s."source", 'dataType', s.data_type, 'comment', s.comment) AS "score"
FROM
observations AS o
LEFT JOIN scores s ON o.trace_id = s.trace_id
AND s.observation_id = o.id
AND s.project_id = ${input.projectId}
WHERE
o.prompt_id IS NOT NULL
AND o.type = 'GENERATION'
AND o.prompt_id = p.id
AND o.project_id = ${input.projectId}
AND s.name IS NOT NULL
AND p.id IN (${Prisma.join(input.promptIds)})
) s ON TRUE
WHERE
p.project_id = ${input.projectId}
AND s.score IS NOT NULL
GROUP BY
p.id
`),
// traceScores
ctx.prisma.$queryRaw<
Array<{
promptId: string;
scores: Array<ScoreSimplified>;
}>
>(Prisma.sql`
SELECT
p.id AS "promptId",
array_agg(s.score) AS "scores"
FROM
prompts p
LEFT JOIN LATERAL (
SELECT
jsonb_build_object ('name', s.name, 'stringValue', s.string_value, 'value', s.value, 'source', s."source", 'dataType', s.data_type, 'comment', s.comment) AS "score"
FROM
scores s
WHERE
s.trace_id IN (
SELECT o.trace_id
FROM observations o
WHERE
o.prompt_id IS NOT NULL
AND o.prompt_id = p.id
AND o.type = 'GENERATION'
AND o.project_id = ${input.projectId}
AND o.prompt_id IN (${Prisma.join(input.promptIds)})
)
AND s.observation_id IS NULL
AND s.project_id = ${input.projectId}
) s ON TRUE
WHERE
p.project_id = ${input.projectId}
AND s.score IS NOT NULL
GROUP BY
p.id
`),
]);
const [observations, observationScores, traceScores] = await Promise.all([
getObservationMetricsForPrompts(input.projectId, input.promptIds),
getScoresForPromptIds(input.projectId, input.promptIds, "observation"),
getScoresForPromptIds(input.projectId, input.promptIds, "trace"),
]);
return metrics.map((metric) => ({
...metric,
observationScores: aggregateScores(
generationScores.find((score) => score.promptId === metric.id)
?.scores ?? [],
),
traceScores: aggregateScores(
traceScores.find((score) => score.promptId === metric.id)
?.scores ?? [],
),
}));
},
clickhouseExecution: async () => {
console.log("clickhouseExecution", input.promptIds);
const [observations, observationScores, traceScores] =
await Promise.all([
getObservationMetricsForPrompts(input.projectId, input.promptIds),
getScoresForPromptIds(
input.projectId,
input.promptIds,
"observation",
),
getScoresForPromptIds(input.projectId, input.promptIds, "trace"),
]);
return observations.map((r) => {
const promptObservationScores = observationScores.find(
(score) => score.promptId === r.promptId,
);
const promptTraceScores = traceScores.find(
(score) => score.promptId === r.promptId,
);
return observations.map((r) => {
const promptObservationScores = observationScores.find(
(score) => score.promptId === r.promptId,
);
const promptTraceScores = traceScores.find(
(score) => score.promptId === r.promptId,
);
return {
id: r.promptId,
observationCount: BigInt(r.count),
firstUsed: r.firstObservation,
lastUsed: r.lastObservation,
medianOutputTokens: r.medianOutputUsage,
medianInputTokens: r.medianInputUsage,
medianTotalCost: r.medianTotalCost,
medianLatency: r.medianLatencyMs,
observationScores: aggregateScores(
promptObservationScores?.scores ?? [],
),
traceScores: aggregateScores(promptTraceScores?.scores ?? []),
};
});
},
return {
id: r.promptId,
observationCount: BigInt(r.count),
firstUsed: r.firstObservation,
lastUsed: r.lastObservation,
medianOutputTokens: r.medianOutputUsage,
medianInputTokens: r.medianInputUsage,
medianTotalCost: r.medianTotalCost,
medianLatency: r.medianLatencyMs,
observationScores: aggregateScores(
promptObservationScores?.scores ?? [],
),
traceScores: aggregateScores(promptTraceScores?.scores ?? []),
};
});
}),
});
@@ -256,8 +256,22 @@ export const QuickstartExamples = ({
>
FAQ post
</a>{" "}
for common resolutions or{" "}
<Link className="underline" href="/support">
for common resolutions,{" "}
<Link
className="underline"
href="https://langfuse.com/docs/ask-ai"
target="_blank"
rel="noopener noreferrer"
>
Ask AI
</Link>{" "}
or{" "}
<Link
className="underline"
href="https://langfuse.com/support"
target="_blank"
rel="noopener noreferrer"
>
get support
</Link>
.
@@ -100,9 +100,9 @@ export class RateLimitService {
isFirstInDuration: err.isFirstInDuration,
};
} else {
// Some other error occurred, rethrow it
// Some other error occurred, return undefined to fail open
logger.error("Internal Rate limit error", err);
throw err;
return undefined;
}
}
@@ -66,7 +66,7 @@ export const generateObservationsForPublicApi = async (props: QueryType) => {
${props.limit !== undefined && props.page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
`;
const records = await queryClickhouse<ObservationRecordReadType>({
const result = await queryClickhouse<ObservationRecordReadType>({
query,
params: {
...appliedFilter.params,
@@ -77,7 +77,7 @@ export const generateObservationsForPublicApi = async (props: QueryType) => {
: {}),
},
});
return records.map(convertObservationToView);
return result.map(convertObservationToView);
};
export const getObservationsCountForPublicApi = async (props: QueryType) => {
@@ -481,6 +481,7 @@ export function AnnotateDrawerContent({
...score,
scoreId: createdScore.id,
value: createdScore.value,
stringValue: createdScore.stringValue ?? undefined,
});
}
}
+38 -12
View File
@@ -80,7 +80,7 @@ export function SetupPage() {
"Create a new organization. This will be used to manage your projects and teams.",
}}
/>
<div className="md:container md:mx-auto">
<div className="mt-3 md:container md:mx-auto">
<Breadcrumb className="mb-3">
<BreadcrumbList>
<BreadcrumbItem>
@@ -142,11 +142,17 @@ export function SetupPage() {
{
// 1. Create Org
stepInt === 1 && (
<NewOrganizationForm
onSuccess={(orgId) => {
router.push(inviteMembersRoute(orgId));
}}
/>
<div>
<Header title="New Organization" level="h3" />
<p className="mb-4 text-sm text-muted-foreground">
Organizations are used to manage your projects and teams.
</p>
<NewOrganizationForm
onSuccess={(orgId) => {
router.push(inviteMembersRoute(orgId));
}}
/>
</div>
)
}
{
@@ -155,6 +161,10 @@ export function SetupPage() {
<div className="flex flex-col gap-10">
<div>
<Header title="Organization Members" level="h3" />
<p className="mb-4 text-sm text-muted-foreground">
Invite members to your organization to collaborate on
projects. You can always add more members later.
</p>
<MembersTable orgId={organization.id} />
</div>
<div>
@@ -166,12 +176,20 @@ export function SetupPage() {
{
// 3. Create Project
stepInt === 3 && organization && (
<NewProjectForm
orgId={organization.id}
onSuccess={(projectId) =>
router.push(setupTracingRoute(projectId))
}
/>
<div>
<Header title="New Project" level="h3" />
<p className="mb-4 text-sm text-muted-foreground">
Projects are used to group traces, datasets, evals and
prompts. Multiple environments are best separated via tags
within a project.
</p>
<NewProjectForm
orgId={organization.id}
onSuccess={(projectId) =>
router.push(setupTracingRoute(projectId))
}
/>
</div>
)
}
{
@@ -180,6 +198,10 @@ export function SetupPage() {
<div className="space-y-8">
<div>
<Header title="API Keys" level="h3" />
<p className="mb-4 text-sm text-muted-foreground">
These keys are used to authenticate your API requests. You
can create more keys later in the project settings.
</p>
<TracingSetup
projectId={project.id}
hasAnyTrace={hasAnyTrace ?? false}
@@ -266,6 +288,10 @@ const TracingSetup = ({
level="h3"
status={hasAnyTrace ? "active" : "pending"}
/>
<p className="mb-4 text-sm text-muted-foreground">
Tracing is used to track and analyze your LLM calls. You can always
skip this step and setup tracing later.
</p>
<QuickstartExamples
secretKey={apiKeys.secretKey}
publicKey={apiKeys.publicKey}
+5
View File
@@ -115,4 +115,9 @@ export const showChat = () => {
}
};
export const chatIsVisible = () => {
if (!chatAvailable) return false;
return sessionStorage.getItem("supportChatVisible") === "true";
};
export const chatAvailable = !!process.env.NEXT_PUBLIC_CRISP_WEBSITE_ID;
+1 -1
View File
@@ -11,7 +11,7 @@ import {
import { env } from "@/src/env.mjs";
// Interval between jobs in minutes
const JOB_INTERVAL_MINUTES = Prisma.raw("24 * 60"); // 24 hours
const JOB_INTERVAL_MINUTES = Prisma.raw("720"); // 12 hours
// Timeout for job in minutes, if job is not finished in this time, it will be retried
const JOB_TIMEOUT_MINUTES = Prisma.raw("10"); // 10 minutes
+13
View File
@@ -109,6 +109,7 @@ const MyApp: AppType<{ session: Session | null }> = ({
<Component {...pageProps} />
<UserTracking />
</Layout>
<BetterStackUptimeStatusMessage />
</ThemeProvider>
</MarkdownContextProvider>
<CrispWidget />
@@ -248,3 +249,15 @@ if (
return await shutdown(signal);
});
}
function BetterStackUptimeStatusMessage() {
if (!env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) return null;
return (
<script
src="https://uptime.betterstack.com/widgets/announcement.js"
data-id="189328"
async={true}
type="text/javascript"
></script>
);
}
+5 -15
View File
@@ -9,7 +9,6 @@ import {
import { LangfuseNotFoundError, InvalidRequestError } from "@langfuse/shared";
import { addDatasetRunItemsToEvalQueue } from "@/src/ee/features/evals/server/addDatasetRunItemsToEvalQueue";
import { getObservationById } from "@langfuse/shared/src/server";
import { env } from "@/src/env.mjs";
export default withMiddlewares({
POST: createAuthedAPIRoute({
@@ -51,20 +50,11 @@ export default withMiddlewares({
// Backwards compatibility: historically, dataset run items were linked to observations, not traces
if (!traceId && observationId) {
const observation =
env.LANGFUSE_RETURN_FROM_CLICKHOUSE === "true"
? await getObservationById(
observationId,
auth.scope.projectId,
true,
)
: await prisma.observation.findUnique({
where: {
id: observationId,
projectId: auth.scope.projectId,
},
});
const observation = await getObservationById(
observationId,
auth.scope.projectId,
true,
);
if (observationId && !observation) {
throw new LangfuseNotFoundError("Observation not found");
}
+23 -157
View File
@@ -1,13 +1,9 @@
import { Prisma, prisma } from "@langfuse/shared/src/db";
import {
GetMetricsDailyV1Query,
GetMetricsDailyV1Response,
} from "@/src/features/public-api/types/metrics";
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
import { createAuthedAPIRoute } from "@/src/features/public-api/server/createAuthedAPIRoute";
import { type z } from "zod";
import { env } from "@/src/env.mjs";
import { measureAndReturnApi } from "@/src/server/utils/checkClickhouseAccess";
import {
generateDailyMetrics,
getDailyMetricsCount,
@@ -20,162 +16,32 @@ export default withMiddlewares({
responseSchema: GetMetricsDailyV1Response,
rateLimitResource: "public-api-metrics",
fn: async ({ query, auth }) => {
return measureAndReturnApi({
input: { projectId: auth.scope.projectId, queryClickhouse: false },
operation: "api/public/metrics/daily",
user: null,
pgExecution: async () => {
const traceNameCondition = query.traceName
? Prisma.sql`AND t.name = ${query.traceName}`
: Prisma.empty;
const userCondition = query.userId
? Prisma.sql`AND t."user_id" = ${query.userId}`
: Prisma.empty;
const tagsCondition = query.tags
? Prisma.sql`AND ARRAY[${Prisma.join(
(Array.isArray(query.tags)
? query.tags
: query.tags.split(",")
).map((v) => Prisma.sql`${v}`),
", ",
)}] <@ t."tags"`
: Prisma.empty;
const fromTimestampCondition = query.fromTimestamp
? Prisma.sql`AND t."timestamp" >= ${query.fromTimestamp}::timestamp with time zone at time zone 'UTC'`
: Prisma.empty;
const toTimestampCondition = query.toTimestamp
? Prisma.sql`AND t."timestamp" < ${query.toTimestamp}::timestamp with time zone at time zone 'UTC'`
: Prisma.empty;
const fromObservationStartTimeCondition = query.fromTimestamp
? Prisma.sql`AND o."start_time" >= ${query.fromTimestamp}::timestamp with time zone at time zone 'UTC' - INTERVAL '1 day'`
: Prisma.empty;
const toObservationStartTimeCondition = query.toTimestamp
? Prisma.sql`AND o."start_time" < ${query.toTimestamp}::timestamp with time zone at time zone 'UTC' + INTERVAL '1 day'`
: Prisma.empty;
const filterProps = {
projectId: auth.scope.projectId,
page: query.page ?? undefined,
limit: query.limit ?? undefined,
traceName: query.traceName ?? undefined,
userId: query.userId ?? undefined,
tags: query.tags ?? undefined,
fromTimestamp: query.fromTimestamp ?? undefined,
toTimestamp: query.toTimestamp ?? undefined,
};
// TODO: We can use observations as soon as we compute costs on write and backfill self-hosters
const observationtable =
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === undefined
? Prisma.sql`observations_view`
: Prisma.sql`observations`;
const [usage, count] = await Promise.all([
generateDailyMetrics(filterProps),
getDailyMetricsCount(filterProps),
]);
const [usage, totalItemsRes] = await Promise.all([
prisma.$queryRaw`
WITH model_usage AS (
SELECT DATE_TRUNC('DAY', o.start_time) "date",
o.model,
count(o.id)::integer as "countObservations",
count(distinct t.id)::integer as "countTraces",
SUM(o.prompt_tokens) "inputUsage",
SUM(o.completion_tokens) "outputUsage",
SUM(o.total_tokens) "totalUsage",
COALESCE(SUM(o.calculated_total_cost), 0)::DOUBLE PRECISION as "totalCost"
FROM traces t
LEFT JOIN ${observationtable} o
ON o.trace_id = t.id
AND o.project_id = t.project_id
WHERE o.start_time IS NOT NULL
AND t.project_id = ${auth.scope.projectId}
${traceNameCondition}
${userCondition}
${tagsCondition}
${fromTimestampCondition}
${toTimestampCondition}
${fromObservationStartTimeCondition}
${toObservationStartTimeCondition}
GROUP BY 1, 2),
daily_model_usage AS (
SELECT "date",
sum("countObservations")::integer "countObservations",
sum("totalCost")::DOUBLE PRECISION "totalCost",
json_agg(json_build_object(
'model', "model",
'inputUsage', "inputUsage",
'outputUsage', "outputUsage",
'totalUsage', "totalUsage",
'totalCost', "totalCost",
'countObservations', "countObservations",
'countTraces', "countTraces"
)) daily_usage_json
FROM model_usage
GROUP BY 1),
trace_usage AS (
SELECT DATE_TRUNC('DAY', t.timestamp) "date",
count(t.id)::integer as "countTraces"
FROM traces t
WHERE t.project_id = ${auth.scope.projectId}
${traceNameCondition}
${userCondition}
${tagsCondition}
${fromTimestampCondition}
${toTimestampCondition}
GROUP BY 1)
SELECT TO_CHAR(COALESCE(trace_usage.date, daily_model_usage.date), 'YYYY-MM-DD') AS "date",
COALESCE(trace_usage."countTraces", 0) "countTraces",
COALESCE("countObservations", 0) "countObservations",
COALESCE("totalCost", 0) "totalCost",
COALESCE(daily_usage_json, '[]'::JSON) usage
FROM daily_model_usage
FULL OUTER JOIN trace_usage
ON daily_model_usage."date" = trace_usage."date"
ORDER BY 1 DESC
LIMIT ${query.limit} OFFSET ${(query.page - 1) * query.limit}
`,
prisma.$queryRaw<{ count: number }[]>`
SELECT COUNT(DISTINCT DATE_TRUNC('DAY', t.timestamp))::integer
FROM traces t
WHERE t.project_id = ${auth.scope.projectId}
${traceNameCondition}
${userCondition}
${tagsCondition}
${fromTimestampCondition}
${toTimestampCondition}
`,
]);
const totalItems = totalItemsRes[0]?.count ?? 0;
return {
// cannot use type warnings due to query raw, covered by tests
data: usage as z.infer<typeof GetMetricsDailyV1Response>["data"],
meta: {
page: query.page,
limit: query.limit,
totalItems,
totalPages: Math.ceil(totalItems / query.limit),
},
};
const finalCount = count || 0;
return {
data: usage,
meta: {
page: query.page,
limit: query.limit,
totalItems: finalCount,
totalPages: Math.ceil(finalCount / query.limit),
},
clickhouseExecution: async () => {
const filterProps = {
projectId: auth.scope.projectId,
page: query.page ?? undefined,
limit: query.limit ?? undefined,
traceName: query.traceName ?? undefined,
userId: query.userId ?? undefined,
tags: query.tags ?? undefined,
fromTimestamp: query.fromTimestamp ?? undefined,
toTimestamp: query.toTimestamp ?? undefined,
};
const [usage, count] = await Promise.all([
generateDailyMetrics(filterProps),
getDailyMetricsCount(filterProps),
]);
const finalCount = count || 0;
return {
data: usage,
meta: {
page: query.page,
limit: query.limit,
totalItems: finalCount,
totalPages: Math.ceil(finalCount / query.limit),
},
};
},
});
};
},
}),
});
@@ -7,7 +7,6 @@ import {
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
import { createAuthedAPIRoute } from "@/src/features/public-api/server/createAuthedAPIRoute";
import { LangfuseNotFoundError } from "@langfuse/shared";
import { measureAndReturnApi } from "@/src/server/utils/checkClickhouseAccess";
import { getObservationViewById } from "@langfuse/shared/src/server";
export default withMiddlewares({
@@ -16,77 +15,58 @@ export default withMiddlewares({
querySchema: GetObservationV1Query,
responseSchema: GetObservationV1Response,
fn: async ({ query, auth }) => {
const { observationId } = query;
const observation = await measureAndReturnApi({
input: { projectId: auth.scope.projectId, queryClickhouse: false },
operation: "api/public/observations/[observationId]",
user: null,
pgExecution: async () => {
return await prisma.observationView.findFirst({
where: {
id: observationId,
projectId: auth.scope.projectId,
},
});
},
clickhouseExecution: async () => {
const observation = await getObservationViewById(
observationId,
auth.scope.projectId,
true,
);
if (!observation) {
throw new LangfuseNotFoundError(
"Observation not found within authorized project",
);
}
const clickhouseObservation = await getObservationViewById(
query.observationId,
auth.scope.projectId,
true,
);
if (!clickhouseObservation) {
throw new LangfuseNotFoundError(
"Observation not found within authorized project",
);
}
const model = observation.modelId
? await prisma.model.findFirst({
where: {
AND: [
const model = clickhouseObservation.modelId
? await prisma.model.findFirst({
where: {
AND: [
{
id: clickhouseObservation.modelId,
},
{
OR: [
{
id: observation.modelId,
projectId: auth.scope.projectId,
},
{
OR: [
{
projectId: auth.scope.projectId,
},
{
projectId: null,
},
],
projectId: null,
},
],
},
include: {
Price: true,
},
orderBy: {
projectId: {
sort: "desc",
nulls: "last",
},
},
})
: undefined;
],
},
include: {
Price: true,
},
orderBy: {
projectId: {
sort: "desc",
nulls: "last",
},
},
})
: undefined;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
return {
...observation,
modelId: model?.id ?? null,
inputPrice:
model?.Price?.find((m) => m.usageType === "input")?.price ?? null,
outputPrice:
model?.Price?.find((m) => m.usageType === "output")?.price ??
null,
totalPrice:
model?.Price?.find((m) => m.usageType === "total")?.price ?? null,
};
},
});
const observation = {
...clickhouseObservation,
modelId: model?.id ?? null,
inputPrice:
model?.Price?.find((m) => m.usageType === "input")?.price ?? null,
outputPrice:
model?.Price?.find((m) => m.usageType === "output")?.price ?? null,
totalPrice:
model?.Price?.find((m) => m.usageType === "total")?.price ?? null,
};
if (!observation) {
throw new LangfuseNotFoundError(
+62 -188
View File
@@ -2,15 +2,12 @@ import { prisma } from "@langfuse/shared/src/db";
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
import { createAuthedAPIRoute } from "@/src/features/public-api/server/createAuthedAPIRoute";
import { Prisma, type ObservationView } from "@langfuse/shared/src/db";
import { InternalServerError } from "@langfuse/shared";
import {
GetObservationsV1Query,
GetObservationsV1Response,
transformDbToApiObservation,
} from "@/src/features/public-api/types/observations";
import { measureAndReturnApi } from "@/src/server/utils/checkClickhouseAccess";
import {
generateObservationsForPublicApi,
getObservationsCountForPublicApi,
@@ -22,194 +19,71 @@ export default withMiddlewares({
querySchema: GetObservationsV1Query,
responseSchema: GetObservationsV1Response,
fn: async ({ query, auth }) => {
return await measureAndReturnApi({
input: { projectId: auth.scope.projectId, queryClickhouse: false },
operation: "api/public/observations",
user: null,
pgExecution: async () => {
const userIdCondition = query.userId
? Prisma.sql`AND traces."user_id" = ${query.userId}`
: Prisma.empty;
const filterProps = {
projectId: auth.scope.projectId,
page: query.page ?? undefined,
limit: query.limit ?? undefined,
traceId: query.traceId ?? undefined,
userId: query.userId ?? undefined,
name: query.name ?? undefined,
type: query.type ?? undefined,
parentObservationId: query.parentObservationId ?? undefined,
fromStartTime: query.fromStartTime ?? undefined,
toStartTime: query.toStartTime ?? undefined,
version: query.version ?? undefined,
};
const [items, count] = await Promise.all([
generateObservationsForPublicApi(filterProps),
getObservationsCountForPublicApi(filterProps),
]);
const uniqueModels: string[] = Array.from(
new Set(
items.map((r) => r.modelId).filter((r): r is string => Boolean(r)),
),
);
const nameCondition = query.name
? Prisma.sql`AND o."name" = ${query.name}`
: Prisma.empty;
const models =
uniqueModels.length > 0
? await prisma.model.findMany({
where: {
id: {
in: uniqueModels,
},
OR: [{ projectId: auth.scope.projectId }, { projectId: null }],
},
include: {
Price: true,
},
})
: [];
const finalCount = count ? count : 0;
const observationTypeCondition = query.type
? Prisma.sql`AND o."type" = ${query.type}::"ObservationType"`
: Prisma.empty;
const traceIdCondition = query.traceId
? Prisma.sql`AND o."trace_id" = ${query.traceId}`
: Prisma.empty;
const parentObservationIdCondition = query.parentObservationId
? Prisma.sql`AND o."parent_observation_id" = ${query.parentObservationId}`
: Prisma.empty;
const fromStartTimeCondition = query.fromStartTime
? Prisma.sql`AND o."start_time" >= ${query.fromStartTime}::timestamp with time zone at time zone 'UTC'`
: Prisma.empty;
const toStartTimeCondition = query.toStartTime
? Prisma.sql`AND o."start_time" < ${query.toStartTime}::timestamp with time zone at time zone 'UTC'`
: Prisma.empty;
const versionCondition = query.version
? Prisma.sql`AND o."version" = ${query.version}`
: Prisma.empty;
const observations = await prisma.$queryRaw<ObservationView[]>`
SELECT
o."id",
o."name",
o."start_time" AS "startTime",
o."end_time" AS "endTime",
o."parent_observation_id" AS "parentObservationId",
o."type",
o."metadata",
o."model",
o."input",
o."output",
o."level",
o."status_message" AS "statusMessage",
o."completion_start_time" AS "completionStartTime",
o."completion_tokens" AS "completionTokens",
o."prompt_tokens" AS "promptTokens",
o."total_tokens" AS "totalTokens",
o."unit" AS "unit",
o."version",
o."project_id" AS "projectId",
o."trace_id" AS "traceId",
o."modelParameters" AS "modelParameters",
o."model_id" as "modelId",
o."input_price" as "inputPrice",
o."output_price" as "outputPrice",
o."total_price" as "totalPrice",
o."calculated_input_cost" as "calculatedInputCost",
o."calculated_output_cost" as "calculatedOutputCost",
o."calculated_total_cost" as "calculatedTotalCost",
o."latency",
o."prompt_id" as "promptId",
o."prompt_name" as "promptName",
o."prompt_version" as "promptVersion",
o."created_at" as "createdAt",
o."updated_at" as "updatedAt",
o."time_to_first_token" as "timeToFirstToken"
FROM observations_view o LEFT JOIN traces ON o."trace_id" = traces."id" AND traces."project_id" = o."project_id"
WHERE o."project_id" = ${auth.scope.projectId}
${nameCondition}
${userIdCondition}
${observationTypeCondition}
${traceIdCondition}
${versionCondition}
${parentObservationIdCondition}
${fromStartTimeCondition}
${toStartTimeCondition}
ORDER by o."start_time" DESC
OFFSET ${(query.page - 1) * query.limit}
LIMIT ${query.limit}
`;
const countRes = await prisma.$queryRaw<{ count: bigint }[]>`
SELECT COUNT(*) FROM observations o LEFT JOIN traces ON o."trace_id" = traces."id"
WHERE o."project_id" = ${auth.scope.projectId}
${observationTypeCondition}
${nameCondition}
${userIdCondition}
${traceIdCondition}
${versionCondition}
${parentObservationIdCondition}
${fromStartTimeCondition}
${toStartTimeCondition}
`;
if (countRes.length !== 1) {
throw new InternalServerError("Unexpected totalItems result");
}
const totalItems = Number(countRes[0].count);
return {
data: observations.map(transformDbToApiObservation),
meta: {
page: query.page,
limit: query.limit,
totalItems,
totalPages: Math.ceil(totalItems / query.limit),
},
};
return {
data: items
.map((i) => {
const model = models.find((m) => m.id === i.modelId);
return {
...i,
modelId: model?.id ?? null,
inputPrice:
model?.Price?.find((m) => m.usageType === "input")?.price ??
null,
outputPrice:
model?.Price?.find((m) => m.usageType === "output")?.price ??
null,
totalPrice:
model?.Price?.find((m) => m.usageType === "total")?.price ??
null,
};
})
.map(transformDbToApiObservation),
meta: {
page: query.page,
limit: query.limit,
totalItems: finalCount,
totalPages: Math.ceil(finalCount / query.limit),
},
clickhouseExecution: async () => {
const filterProps = {
projectId: auth.scope.projectId,
page: query.page ?? undefined,
limit: query.limit ?? undefined,
traceId: query.traceId ?? undefined,
userId: query.userId ?? undefined,
name: query.name ?? undefined,
type: query.type ?? undefined,
parentObservationId: query.parentObservationId ?? undefined,
fromStartTime: query.fromStartTime ?? undefined,
toStartTime: query.toStartTime ?? undefined,
version: query.version ?? undefined,
};
const [items, count] = await Promise.all([
generateObservationsForPublicApi(filterProps),
getObservationsCountForPublicApi(filterProps),
]);
const uniqueModels: string[] = Array.from(
new Set(
items
.map((r) => r.modelId)
.filter((r): r is string => Boolean(r)),
),
);
const models =
uniqueModels.length > 0
? await prisma.model.findMany({
where: {
id: {
in: uniqueModels,
},
OR: [
{ projectId: auth.scope.projectId },
{ projectId: null },
],
},
include: {
Price: true,
},
})
: [];
const finalCount = count ? count : 0;
return {
data: items
.map((i) => {
const model = models.find((m) => m.id === i.modelId);
return {
...i,
modelId: model?.id ?? null,
inputPrice:
model?.Price?.find((m) => m.usageType === "input")?.price ??
null,
outputPrice:
model?.Price?.find((m) => m.usageType === "output")
?.price ?? null,
totalPrice:
model?.Price?.find((m) => m.usageType === "total")?.price ??
null,
};
})
.map(transformDbToApiObservation),
meta: {
page: query.page,
limit: query.limit,
totalItems: finalCount,
totalPages: Math.ceil(finalCount / query.limit),
},
};
},
});
};
},
}),
});
+2 -31
View File
@@ -1,6 +1,5 @@
import { createAuthedAPIRoute } from "@/src/features/public-api/server/createAuthedAPIRoute";
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
import { measureAndReturnApi } from "@/src/server/utils/checkClickhouseAccess";
import {
DeleteScoreQuery,
DeleteScoreResponse,
@@ -9,7 +8,6 @@ import {
InternalServerError,
LangfuseNotFoundError,
} from "@langfuse/shared";
import { prisma } from "@langfuse/shared/src/db";
import {
deleteScore,
getScoreById,
@@ -23,24 +21,7 @@ export default withMiddlewares({
querySchema: GetScoreQuery,
responseSchema: GetScoreResponse,
fn: async ({ query, auth }) => {
const { scoreId } = query;
const score = await measureAndReturnApi({
input: { projectId: auth.scope.projectId, queryClickhouse: false },
operation: "api/public/scores/[scoreId]",
user: null,
pgExecution: async () => {
return await prisma.score.findUnique({
where: {
id: scoreId,
projectId: auth.scope.projectId,
},
});
},
clickhouseExecution: async () => {
return await getScoreById(auth.scope.projectId, scoreId);
},
});
const score = await getScoreById(auth.scope.projectId, query.scoreId);
if (!score) {
throw new LangfuseNotFoundError("Score not found");
@@ -63,17 +44,7 @@ export default withMiddlewares({
responseSchema: DeleteScoreResponse,
fn: async ({ query, auth }) => {
const { scoreId } = query;
await Promise.all([
prisma.score.deleteMany({
where: {
id: scoreId,
projectId: auth.scope.projectId,
},
}),
deleteScore(auth.scope.projectId, scoreId),
]);
await deleteScore(auth.scope.projectId, scoreId);
return { message: "Score deleted successfully" };
},
}),
+45 -186
View File
@@ -9,14 +9,12 @@ import {
PostScoresBody,
PostScoresResponse,
} from "@langfuse/shared";
import { prisma, Prisma } from "@langfuse/shared/src/db";
import {
eventTypes,
logger,
processEventBatch,
} from "@langfuse/shared/src/server";
import { tokenCount } from "@/src/features/ingest/usage";
import { measureAndReturnApi } from "@/src/server/utils/checkClickhouseAccess";
import {
generateScoresForPublicApi,
getScoresCountForPublicApi,
@@ -57,193 +55,54 @@ export default withMiddlewares({
querySchema: GetScoresQuery,
responseSchema: GetScoresResponse,
fn: async ({ query, auth }) => {
const {
page,
limit,
configId,
queueId,
traceTags,
userId,
name,
fromTimestamp,
toTimestamp,
source,
operator,
value,
scoreIds,
dataType,
} = query;
const [items, count] = await Promise.all([
generateScoresForPublicApi({
projectId: auth.scope.projectId,
page: query.page ?? undefined,
limit: query.limit ?? undefined,
userId: query.userId ?? undefined,
name: query.name ?? undefined,
configId: query.configId ?? undefined,
queueId: query.queueId ?? undefined,
traceTags: query.traceTags ?? undefined,
dataType: query.dataType ?? undefined,
fromTimestamp: query.fromTimestamp ?? undefined,
toTimestamp: query.toTimestamp ?? undefined,
source: query.source ?? undefined,
value: query.value ?? undefined,
operator: query.operator ?? undefined,
scoreIds: query.scoreIds ?? undefined,
}),
getScoresCountForPublicApi({
projectId: auth.scope.projectId,
page: query.page ?? undefined,
limit: query.limit ?? undefined,
userId: query.userId ?? undefined,
name: query.name ?? undefined,
configId: query.configId ?? undefined,
queueId: query.queueId ?? undefined,
traceTags: query.traceTags ?? undefined,
dataType: query.dataType ?? undefined,
fromTimestamp: query.fromTimestamp ?? undefined,
toTimestamp: query.toTimestamp ?? undefined,
source: query.source ?? undefined,
value: query.value ?? undefined,
operator: query.operator ?? undefined,
scoreIds: query.scoreIds ?? undefined,
}),
]);
return await measureAndReturnApi({
input: { projectId: auth.scope.projectId, queryClickhouse: false },
operation: "api/public/scores",
user: null,
pgExecution: async () => {
const skipValue = (page - 1) * limit;
const configCondition = configId
? Prisma.sql`AND s."config_id" = ${configId}`
: Prisma.empty;
const queueCondition = queueId
? Prisma.sql`AND s."queue_id" = ${queueId}`
: Prisma.empty;
const traceTagsCondition = traceTags
? Prisma.sql`AND ARRAY[${Prisma.join(
(Array.isArray(traceTags)
? traceTags
: traceTags.split(",")
).map((v) => Prisma.sql`${v}`),
", ",
)}] <@ t."tags"`
: Prisma.empty;
const dataTypeCondition = dataType
? Prisma.sql`AND s."data_type" = ${dataType}::"ScoreDataType"`
: Prisma.empty;
const userCondition = userId
? Prisma.sql`AND t."user_id" = ${userId}`
: Prisma.empty;
const nameCondition = name
? Prisma.sql`AND s."name" = ${name}`
: Prisma.empty;
const fromTimestampCondition = fromTimestamp
? Prisma.sql`AND s."timestamp" >= ${fromTimestamp}::timestamp with time zone at time zone 'UTC'`
: Prisma.empty;
const toTimestampCondition = toTimestamp
? Prisma.sql`AND s."timestamp" < ${toTimestamp}::timestamp with time zone at time zone 'UTC'`
: Prisma.empty;
const sourceCondition = source
? Prisma.sql`AND s."source" = ${source}::"ScoreSource"`
: Prisma.empty;
const valueCondition =
operator && value !== null && value !== undefined
? Prisma.sql`AND s."value" ${Prisma.raw(`${operator}`)} ${value}`
: Prisma.empty;
const scoreIdCondition = scoreIds
? Prisma.sql`AND s."id" = ANY(${scoreIds})`
: Prisma.empty;
const finalCount = count ? count : 0;
const scores = await prisma.$queryRaw<Array<unknown>>(Prisma.sql`
SELECT
s.id,
s.timestamp,
s.name,
s.value,
s.string_value as "stringValue",
s.author_user_id as "authorUserId",
s.project_id as "projectId",
s.created_at as "createdAt",
s.updated_at as "updatedAt",
s.source,
s.comment,
s.data_type as "dataType",
s.config_id as "configId",
s.queue_id as "queueId",
s.trace_id as "traceId",
s.observation_id as "observationId",
json_build_object('userId', t.user_id, 'tags', t.tags) as "trace"
FROM "scores" AS s
LEFT JOIN "traces" AS t ON t.id = s.trace_id AND t.project_id = ${auth.scope.projectId}
WHERE s.project_id = ${auth.scope.projectId}
${configCondition}
${queueCondition}
${traceTagsCondition}
${dataTypeCondition}
${userCondition}
${nameCondition}
${sourceCondition}
${fromTimestampCondition}
${toTimestampCondition}
${valueCondition}
${scoreIdCondition}
ORDER BY s."timestamp" DESC
LIMIT ${limit} OFFSET ${skipValue}
`);
const totalItemsRes = await prisma.$queryRaw<{ count: bigint }[]>(
Prisma.sql`
SELECT COUNT(*) as count
FROM "scores" AS s
LEFT JOIN "traces" AS t ON t.id = s.trace_id AND t.project_id = ${auth.scope.projectId}
WHERE s.project_id = ${auth.scope.projectId}
${configCondition}
${queueCondition}
${traceTagsCondition}
${dataTypeCondition}
${userCondition}
${nameCondition}
${sourceCondition}
${fromTimestampCondition}
${toTimestampCondition}
${valueCondition}
${scoreIdCondition}
`,
);
const validatedScores = legacyFilterAndValidateV1GetScoreList(scores);
const totalItems =
totalItemsRes[0] !== undefined ? Number(totalItemsRes[0].count) : 0;
return {
data: validatedScores,
meta: {
page: page,
limit: limit,
totalItems,
totalPages: Math.ceil(totalItems / limit),
},
};
return {
data: legacyFilterAndValidateV1GetScoreList(items),
meta: {
page: query.page,
limit: query.limit,
totalItems: finalCount,
totalPages: Math.ceil(finalCount / query.limit),
},
clickhouseExecution: async () => {
const [items, count] = await Promise.all([
generateScoresForPublicApi({
projectId: auth.scope.projectId,
page: query.page ?? undefined,
limit: query.limit ?? undefined,
userId: query.userId ?? undefined,
name: query.name ?? undefined,
configId: query.configId ?? undefined,
queueId: query.queueId ?? undefined,
traceTags: query.traceTags ?? undefined,
dataType: query.dataType ?? undefined,
fromTimestamp: query.fromTimestamp ?? undefined,
toTimestamp: query.toTimestamp ?? undefined,
source: query.source ?? undefined,
value: query.value ?? undefined,
operator: query.operator ?? undefined,
scoreIds: query.scoreIds ?? undefined,
}),
getScoresCountForPublicApi({
projectId: auth.scope.projectId,
page: query.page ?? undefined,
limit: query.limit ?? undefined,
userId: query.userId ?? undefined,
name: query.name ?? undefined,
configId: query.configId ?? undefined,
queueId: query.queueId ?? undefined,
traceTags: query.traceTags ?? undefined,
dataType: query.dataType ?? undefined,
fromTimestamp: query.fromTimestamp ?? undefined,
toTimestamp: query.toTimestamp ?? undefined,
source: query.source ?? undefined,
value: query.value ?? undefined,
operator: query.operator ?? undefined,
scoreIds: query.scoreIds ?? undefined,
}),
]);
const finalCount = count ? count : 0;
return {
data: legacyFilterAndValidateV1GetScoreList(items),
meta: {
page: query.page,
limit: query.limit,
totalItems: finalCount,
totalPages: Math.ceil(finalCount / query.limit),
},
};
},
});
};
},
}),
});
@@ -6,7 +6,6 @@ import {
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
import { createAuthedAPIRoute } from "@/src/features/public-api/server/createAuthedAPIRoute";
import { LangfuseNotFoundError } from "@langfuse/shared";
import { measureAndReturnApi } from "@/src/server/utils/checkClickhouseAccess";
import { getTracesBySessionId } from "@langfuse/shared/src/server";
export default withMiddlewares({
@@ -16,47 +15,34 @@ export default withMiddlewares({
responseSchema: GetSessionV1Response,
fn: async ({ query, auth }) => {
const { sessionId } = query;
return await measureAndReturnApi({
input: { projectId: auth.scope.projectId, queryClickhouse: false },
operation: "scores.countAll",
user: null,
pgExecution: async () => {
throw new LangfuseNotFoundError(
"Session not found within authorized project",
);
const session = await prisma.traceSession.findUnique({
where: {
id_projectId: {
id: sessionId,
projectId: auth.scope.projectId,
},
},
clickhouseExecution: async () => {
const session = await prisma.traceSession.findUnique({
where: {
id_projectId: {
id: sessionId,
projectId: auth.scope.projectId,
},
},
select: {
id: true,
createdAt: true,
projectId: true,
},
});
if (!session) {
throw new LangfuseNotFoundError(
"Session not found within authorized project",
);
}
const traces = await getTracesBySessionId(auth.scope.projectId, [
sessionId,
]);
return {
...session,
traces,
};
select: {
id: true,
createdAt: true,
projectId: true,
},
});
if (!session) {
throw new LangfuseNotFoundError(
"Session not found within authorized project",
);
}
const traces = await getTracesBySessionId(auth.scope.projectId, [
sessionId,
]);
return {
...session,
traces,
};
},
}),
});

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