Compare commits

...
85 Commits
Author SHA1 Message Date
Marc Klingen ca9377ea4e chore: release v2.69.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (node20, pg12) (push) Waiting to run
CI/CD / tests-web (node20, pg15) (push) Waiting to run
CI/CD / tests-worker (node20, pg12) (push) Waiting to run
CI/CD / tests-worker (node20, pg15) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
Snyk Container / snyk (push) Waiting to run
2024-08-07 17:53:14 +02:00
Marc KlingenandGitHub 9f3ac3ab9f fix(ee): add ENCRYPTION_KEY to default environment variables + improve error messages (#2853) 2024-08-07 17:21:40 +02:00
Marc Klingen e38deed204 chore(ui): improve azure base url description 2024-08-07 17:15:05 +02:00
marliessophieandGitHub 19bf90dd71 perf: limit table scores columns by table timeframe (#2851) 2024-08-07 16:08:20 +02:00
marliessophieandGitHub 0523fbfc67 chore(ui): remove beta flag from trace timeline view (#2841) 2024-08-07 13:49:27 +02:00
Marc KlingenandGitHub 0fcdac0cf1 feat(models): add gpt-4o-2024-08-06 pricing, and make it available on playground and evals (#2845) 2024-08-07 13:48:00 +02:00
Hassieb PakzadandGitHub c2bc17d4d1 chore(flushQueue): add logs (#2843) 2024-08-07 12:16:59 +02:00
Hassieb PakzadandGitHub 7d11812fd1 fix(IngestionService): add fallback to event timestamp (#2842) 2024-08-07 09:46:20 +00:00
marliessophieandGitHub 877bad2234 feat(data-table): break out scores to individual columns in tables (#2743) 2024-08-07 10:47:00 +02:00
Max DeichmannandGitHub dd90a28d1c perf: instrument async clickhouse tasks (#2834) 2024-08-06 21:48:58 +00:00
Marc Klingen c8c9fc0eba chore: release v2.68.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (node20, pg12) (push) Waiting to run
CI/CD / tests-web (node20, pg15) (push) Waiting to run
CI/CD / tests-worker (node20, pg12) (push) Waiting to run
CI/CD / tests-worker (node20, pg15) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
Snyk Container / snyk (push) Waiting to run
2024-08-06 22:31:48 +02:00
Marc KlingenandGitHub aafd45f771 chore(ui): less padding on tag selector (#2839) 2024-08-06 20:09:16 +00:00
Max DeichmannandGitHub fc6f32ebe2 fix: fix prisma version in dockerfile (#2838) 2024-08-06 19:39:46 +00:00
Marc KlingenandGitHub 765406492a fix(ui): large tables in markdown i/o should be scrollable horizontally (#2835) 2024-08-06 18:36:28 +02:00
Marc KlingenandGitHub 3cd840766b refactor: use docker volumes for persistence of clickhouse data in dev setup (#2832) 2024-08-06 17:33:40 +02:00
Marc KlingenandGitHub ed4d7ceb40 feat(api): add lastConfig attribute and fromUpdatedAt and toUpdatedAt query params to GET /v2/prompts (#2824) 2024-08-06 11:02:56 +02:00
Marc KlingenandGitHub 3520b1d28d fix(ui): truncation of long email addresses in user menu (#2827) 2024-08-05 19:54:08 +02:00
Hassieb PakzadandGitHub 1f0e7b2c37 chore: fix flaky test (#2826) 2024-08-05 15:31:52 +00:00
Marc KlingenandGitHub 17c9fd0028 chore(ui): remove beta flag from playground (#2825) 2024-08-05 16:59:01 +02:00
Hassieb PakzadandGitHub ab997018ff fix: disable scores in CH PoC (#2822) 2024-08-05 15:18:49 +02:00
Marc KlingenandGitHub bc10176935 feat(api): add lastUpdatedAt to GET /v2/prompts (#2820) 2024-08-05 11:44:38 +00:00
Marc KlingenandGitHub 6058fc7388 feat(ui): extend max prompt label length to 36 characters (#2819)
chore(ui): extend max prompt label length to 36 characters
2024-08-05 09:41:59 +00:00
Max DeichmannandGitHub 923ea17fef feat: increase shutdown grace period web container (#2818)
add error handling
2024-08-05 08:37:04 +00:00
Marc KlingenandGitHub 6b3630f3c2 fix(auth): strict no-cache response headers (#2814) 2024-08-02 18:42:44 +02:00
Marc Klingen c4ad55b3ff fix(ui): date_trunc of dashboard ranges 2024-08-02 14:47:55 +02:00
Max DeichmannandGitHub 45c17ca286 chore: reduce logs in ingestion pipeline (#2805) 2024-08-01 11:33:34 +00:00
Marc KlingenandGitHub d1298e68a2 fix(ui): switch to trpc networkMode: "always" to resolve trpc connection issues (#2804) 2024-08-01 09:51:52 +00:00
Max DeichmannandGitHub ab85bad70b fix: remove newrelic config file (#2803)
inject logs
2024-08-01 09:33:21 +00:00
042901ddc8 fix(ui): prevents email address overflow in user menu box (#2800)
---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-08-01 01:16:08 +02:00
Hassieb PakzadandGitHub 111240c2d6 perf: writes to clickhouse (#2631) 2024-07-31 18:56:45 +02:00
Marc Klingen 6b2c14acbb chore: release v2.67.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (node20, pg12) (push) Waiting to run
CI/CD / tests-web (node20, pg15) (push) Waiting to run
CI/CD / tests-worker (node20, pg12) (push) Waiting to run
CI/CD / tests-worker (node20, pg15) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
Snyk Container / snyk (push) Waiting to run
2024-07-31 18:20:00 +02:00
37e72a4b7f perf(ui): add date range filter to prompt metrics (#2796)
---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-07-31 16:08:25 +00:00
ad93b6c3c5 perf(ui): add debounce to filter states (#2786)
---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-07-31 17:40:20 +02:00
Richard KrümmelandGitHub d0e33c1c34 fix(ui): refine date range dropdown options in dashboard (#2784) 2024-07-31 16:56:57 +02:00
Richard KrümmelandGitHub f02d4916a4 feat(dashboard): extend filter to include user, version and release (#2779) 2024-07-31 16:43:13 +02:00
Marc KlingenandGitHub c6151dd042 fix(ui): overflow of ids/links in tables (#2795) 2024-07-31 15:36:37 +02:00
Max DeichmannandGitHub dd34aad84d chore: improve logging for redis cache (#2794) 2024-07-31 12:04:38 +02:00
Marc Klingen 78ca9757ff fix(ui): show/hide columns broken after introducing resizable columns 2024-07-31 11:33:54 +02:00
Max DeichmannandGitHub 805a91c9f8 infra: reduce log amount for 401 (#2792)
reduce-api-key-logging
2024-07-31 09:26:58 +00:00
Hassieb PakzadandGitHub 24ec7884a1 perf: add prompt key index (#2793) 2024-07-31 09:12:44 +00:00
Marc KlingenandGitHub 18122fa334 feat(ui): resizable columns (#2789) 2024-07-31 10:56:52 +02:00
Max DeichmannandGitHub c564608ee7 feat: add sentry sampling via env to worker (#2788)
something
2024-07-30 21:38:58 +00:00
Marc Klingen a5413c101b chore: add comment to update docs when adding models to playground 2024-07-30 15:04:38 +02:00
Marc KlingenandGitHub 25f6c6966e feat(ui): show info message in column selector (#2783) 2024-07-30 14:11:05 +02:00
Marc Klingen 634057e32b chore: release v2.66.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (node20, pg12) (push) Waiting to run
CI/CD / tests-web (node20, pg15) (push) Waiting to run
CI/CD / tests-worker (node20, pg12) (push) Waiting to run
CI/CD / tests-worker (node20, pg15) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
Snyk Container / snyk (push) Waiting to run
2024-07-29 22:27:50 +02:00
Marc KlingenandGitHub ff8cae0c06 feat(security): customize JWT session duration via AUTH_SESSION_MAX_AGE, poll every 5min (#2782) 2024-07-29 22:26:25 +02:00
Max DeichmannandGitHub 8c1af8a046 feat: store non existence of api key in redis (#2763) 2024-07-29 21:55:09 +02:00
Max DeichmannandGitHub d03e035d5c fix: correctly await clickhouse health check response (#2778)
push
2024-07-29 16:45:32 +02:00
Marc KlingenandGitHub 0a83be775f docs(api): add additional descriptions to api reference (#2776) 2024-07-29 14:36:44 +00:00
f9a5c23e9b feat(ui): add separate date range filter to tables (#2398)
---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-07-29 14:08:07 +00:00
Max DeichmannandGitHub fc0fb4a559 feat: add connection check endpoint for clickhouse (#2775) 2024-07-29 13:44:38 +00:00
Hassieb PakzadandGitHub e0268f38bb chore: add logs to invalidating cached prompts (#2773) 2024-07-29 13:33:09 +00:00
Marc KlingenandGitHub a729306b5b fix(ui): hide "sign in" button if email/pw auth is disabled (#2774) 2024-07-29 10:27:46 +00:00
Marc KlingenandGitHub 85d92daafd chore(ui): add header tooltips to traces and generations (#2765)
* chore(ui): add header tooltips to traces and generations

* fix unused import
2024-07-29 09:41:23 +00:00
Max DeichmannandGitHub 939b233120 doc: Redis caching strategy (#2771) 2024-07-29 08:04:05 +00:00
Max DeichmannandGitHub 5612aa2d27 fix: fix porter deployment (#2764) 2024-07-26 14:12:53 +00:00
Max DeichmannandGitHub 2a72fcc421 fix: correctly coerce env variables for cache (#2762) 2024-07-26 15:47:36 +02:00
Marc Klingen 99a45ccd97 chore: fix name of migration 2024-07-26 13:23:31 +02:00
Marc KlingenandGitHub b123cc5a4f feat(api): add promptName and promptVersion to observations (#2750) 2024-07-26 13:21:20 +02:00
Marc KlingenandGitHub a3e5208a90 perf(ee): increase cache TTL of multi-tenant SSO config (#2759) 2024-07-26 11:02:31 +00:00
marliessophieandGitHub 484ef3fcac refactor: improve type safety in ingestion score types across typescript versions (#2757)
* refactor: improve type safety in ingestion score types
2024-07-26 12:10:54 +02:00
Hassieb PakzadandGitHub e080690408 perf: remove failed jobs from queues (#2758) 2024-07-26 11:47:52 +02:00
marliessophieandGitHub bd53c54435 chore(ui): populate annotation drawer default form state on render + misc markdown ui improvements (#2740)
* chore(ui): populate annotation drawer default form state on render

* style(ui): add width and quality properties to markdown image loader

* style(ui): markdown fix list marker alignment

* fix: more state management enhancement for annotation drawer empty keys
2024-07-26 09:37:14 +02:00
Max DeichmannandGitHub 804255b7ec perf: increase default api key ttl (#2752) 2024-07-25 19:19:38 +00:00
Max DeichmannandGitHub 0fa4b381fc fix: do not use cache for api keys if not enabled (#2748)
test fix
2024-07-25 18:54:11 +00:00
Hassieb PakzadandGitHub 01102b0128 fix: invalidate prompt cache on tags update (#2751) 2024-07-25 20:34:40 +02:00
Marc KlingenandGitHub ddd7671d5b feat(api): add version and release filter to GET traces and observations routes (#2749) 2024-07-25 18:09:18 +00:00
Hassieb PakzadandGitHub 1ff58377f1 feat: adds server-side caching to prompts (#2741) 2024-07-25 17:52:38 +00:00
Max DeichmannandGitHub 41ab5e0b7b feat: cache api keys in redis (#2739) 2024-07-25 17:35:10 +00:00
Max DeichmannandGitHub a78bf26bd4 refactor: revert sending trace events from web via redis (#2744) 2024-07-25 16:52:00 +00:00
Max DeichmannandGitHub a185863bc5 fix: improve import in ingestion pipeline (#2732) 2024-07-24 18:34:32 +00:00
Max DeichmannandGitHub 57b46b9f19 chore: improve redis logging (#2731) 2024-07-24 20:19:05 +02:00
Max DeichmannandGitHub 58b547e0f1 fix: do not shutdown but disconnect containers from redis (#2730) 2024-07-24 17:40:58 +00:00
Max DeichmannandGitHub 6554c5ee88 feat(web): send trace events via Redis instead of Rest (#2579) 2024-07-24 19:23:44 +02:00
Marc KlingenandGitHub 90fc9fb660 refactor(api): dataset-related ids unique on project level (#2665) 2024-07-24 11:22:13 +02:00
Marc KlingenandGitHub e2c5c9b5cc chore: add sentry error capturing to multi-tenant sso (#2723)
chore: add sentry to multi-tenant sso
2024-07-23 17:40:11 +00:00
Marc KlingenandGitHub c27c32f844 refactor(ee): move multi-tenant sso to /web/src/ee (#2722)
* refactor(ee): move multi-tenant sso to /web/src/ee

* fix lint
2024-07-23 17:22:09 +00:00
ClemoandGitHub d33066e012 docs: update readme, remove superagent 2024-07-23 16:27:02 +02:00
Mal CurtisandGitHub dff8ab4faa docs: standardize api route names (#2698) 2024-07-23 14:49:59 +02:00
Max DeichmannandGitHub 57a42c8c4b infra: log prisma gauges (#2719) 2024-07-23 14:44:37 +02:00
marliessophieandGitHub a8258382d5 style(ui): add icon representation for trace links in Dataset Run Items table (#2715)
* style(ui): add icon representation for trace links in Dataset Run Items Table
2024-07-23 14:08:52 +02:00
Marlies Mayerhofer ba9d5217a4 chore: release v2.65.1
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (node20, pg12) (push) Waiting to run
CI/CD / tests-web (node20, pg15) (push) Waiting to run
CI/CD / tests-worker (node20, pg12) (push) Waiting to run
CI/CD / tests-worker (node20, pg15) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
Snyk Container / snyk (push) Waiting to run
2024-07-23 13:38:15 +02:00
marliessophieandGitHub 69bf783b11 fix(ui): gracefully handle image paths in markdown (#2717) 2024-07-23 09:23:04 +00:00
marliessophieandGitHub 31fb4b4b20 fix: render image paths in markdown gracefully (#2711) 2024-07-23 07:41:07 +00:00
Max DeichmannandGitHub 2d2c3c5818 infra: remove outdated worker us deployment (#2708)
types
2024-07-22 17:51:37 +00:00
244 changed files with 13143 additions and 4062 deletions
+8 -1
View File
@@ -6,6 +6,12 @@
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"
# Next Auth
# You can generate a new secret on the command line with:
# openssl rand -base64 32
@@ -44,4 +50,5 @@ REDIS_AUTH="myredissecret"
LANGFUSE_WORKER_PASSWORD=mybasicauthsecret
# openssl rand -hex 32 used only here
ENCRYPTION_KEY=6c16874e5c0f0cc74ddec00425fa99fbe9ffbe412b7d5d906a4b00005df91403
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
+4 -1
View File
@@ -18,4 +18,7 @@ REDIS_HOST="127.0.0.1"
REDIS_PORT=6379
REDIS_AUTH="myredissecret"
LANGFUSE_WORKER_PASSWORD=myworkerpassword
LANGFUSE_WORKER_PASSWORD=myworkerpassword
# openssl rand -hex 32 used only here
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
+41 -11
View File
@@ -16,14 +16,17 @@ DATABASE_URL="postgresql://postgres:postgres@db:5432/postgres"
# NEXTAUTH_URL does not need to be set when deploying on Vercel
NEXTAUTH_URL="http://localhost:3000"
# You can generate a new secret on the command line with:
# For each of these, 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="secret"
SALT="salt"
NEXTAUTH_SECRET="secret" # https://next-auth.js.org/configuration/options#secret
SALT="salt" # salt used to hash api keys
# API level encryption for sensitive data
# Must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32
ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000"
# Use CSP headers to enforce HTTPS, optional
LANGFUSE_CSP_ENFORCE_HTTPS="true"
# LANGFUSE_CSP_ENFORCE_HTTPS="true"
# Docker only, optional
@@ -44,6 +47,7 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
# AUTH_DOMAINS_WITH_SSO_ENFORCEMENT=domain1.com,domain2.com
# AUTH_DISABLE_USERNAME_PASSWORD=true
# AUTH_DISABLE_SIGNUP=true
# AUTH_SESSION_MAX_AGE=43200 # 30 days in minutes (default)
# SSO, each group is optional
# AUTH_GOOGLE_CLIENT_ID=
@@ -104,9 +108,6 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
# Disable the expensive analytics queries and related features
# LANGFUSE_DISABLE_EXPENSIVE_POSTGRES_QUERIES="true"
# Customize the default datetime offset filter used in for the UI tables
# LANGFUSE_DEFAULT_TABLE_DATETIME_OFFSET="7" # int (days)
### END Envs to be deprecated in Langfuse v3.0
@@ -145,11 +146,40 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
# Crisp chat
# NEXT_PUBLIC_CRISP_WEBSITE_ID=
# Encryption
# ENCRYPTION_KEY=
# Admin API
# ADMIN_API_KEY=
# Redis
# REDIS_HOST=
# REDIS_PORT=
# REDIS_AUTH=
# REDIS_CONNECTION_STRING=
# Cache configuration
# LANGFUSE_CACHE_API_KEY_ENABLED=
# LANGFUSE_CACHE_API_KEY_TTL_SECONDS=
# LANGFUSE_CACHE_PROMPT_ENABLED=
# LANGFUSE_CACHE_PROMPT_TTL_SECONDS=
### END Langfuse Cloud Config
## START Langfuse V3 Ingestion
# Clickhouse
# CLICKHOUSE_MIGRATION_URL=
# CLICKHOUSE_URL=
# CLICKHOUSE_USER=
# CLICKHOUSE_PASSWORD=
# Ingestion
# LANGFUSE_INGESTION_BUFFER_TTL_SECONDS=
# LANGFUSE_INGESTION_FLUSH_DELAY_MS=
# LANGFUSE_INGESTION_FLUSH_ATTEMPTS=
# LANGFUSE_INGESTION_FLUSH_PROCESSING_CONCURRENCY=
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_BATCH_SIZE=
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=
# LANGFUSE_INGESTION_CLICKHOUSE_MAX_ATTEMPTS=
# LANGFUSE_LOG_LEVEL=
## END Langfuse V3 Ingestion
+11 -1
View File
@@ -63,6 +63,7 @@ jobs:
fi
tests-web:
timeout-minutes: 20
runs-on: ubuntu-latest
name: tests-web (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }})
strategy:
@@ -93,7 +94,7 @@ jobs:
- name: Load default env
run: |
cp .env.dev.example .env
cp .env.dev.example web/.env
grep -v '^REDIS_HOST=' .env.dev.example > web/.env
- name: Run + migrate
run: |
@@ -118,6 +119,7 @@ jobs:
run: pnpm --filter=web run test
tests-worker:
timeout-minutes: 20
runs-on: ubuntu-latest
name: tests-worker (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }})
strategy:
@@ -145,10 +147,17 @@ jobs:
run: |
pnpm install
- name: Install golang-migrate for Clickhouse migrations
run: |
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.16.2/migrate.linux-amd64.tar.gz | tar xvz
sudo mv migrate /usr/bin/migrate
which migrate
- name: Load default env
run: |
cp .env.dev.example .env
cp .env.dev.example web/.env
cp .env.dev.example worker/.env
- name: Run + migrate
run: |
@@ -158,6 +167,7 @@ jobs:
- name: Seed DB
run: |
pnpm run --filter=shared ch:up
pnpm run db:migrate
pnpm run db:seed
@@ -1,29 +0,0 @@
"on":
push:
branches:
- production
name: Deploy to worker (US)
jobs:
porter-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set Github tag
id: vars
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Setup porter
uses: porter-dev/setup-porter@v0.1.0
- name: Deploy stack
timeout-minutes: 30
run: exec porter apply
env:
PORTER_CLUSTER: "4038"
PORTER_DEPLOYMENT_TARGET_ID: 260cd04e-5dc8-4b22-a291-ad4453f981f7
PORTER_HOST: https://dashboard.getporter.dev
PORTER_PR_NUMBER: ${{ github.event.number }}
PORTER_PROJECT: "12565"
PORTER_REPO_NAME: ${{ github.event.repository.name }}
PORTER_STACK_NAME: worker
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4038 }}
-1
View File
@@ -127,7 +127,6 @@ Packages that integrate with Langfuse:
| [AI SDK by Vercel](/docs/sdk/typescript/example-vercel-ai) | Typescript SDK that makes streaming LLM outputs super easy. |
| [Flowise](/docs/integrations/flowise) | JS/TS no-code builder for customized LLM flows. |
| [Langflow](/docs/integrations/langflow) | Python-based UI for LangChain, designed with react-flow to provide an effortless way to experiment and prototype flows. |
| [Superagent](/docs/integrations/superagent) | Open Source AI Assistant Framework & API for prototyping and deployment of agents. |
## Questions and feedback
+1
View File
@@ -16,6 +16,7 @@ services:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
- NEXTAUTH_SECRET=mysecret
- SALT=mysalt
- ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 # generate via `openssl rand -hex 32`
- NEXTAUTH_URL=http://localhost:3000
- TELEMETRY_ENABLED=${TELEMETRY_ENABLED:-true}
- LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false}
+25 -3
View File
@@ -1,4 +1,22 @@
services:
clickhouse:
image: clickhouse/clickhouse-server
user: "101:101"
container_name: clickhouse
hostname: clickhouse
environment:
CLICKHOUSE_DB: default
CLICKHOUSE_USER: clickhouse
CLICKHOUSE_PASSWORD: clickhouse
volumes:
- langfuse_clickhouse_data:/var/lib/clickhouse
- langfuse_clickhouse_logs:/var/log/clickhouse-server
ports:
- "8123:8123"
- "9000:9000"
depends_on:
- postgres
redis:
image: redis:7.2.4
restart: always
@@ -7,7 +25,7 @@ services:
ports:
- 6379:6379
db:
postgres:
image: postgres:${POSTGRES_VERSION:-latest}
restart: always
command: ["postgres", "-c", "log_statement=all"]
@@ -18,8 +36,12 @@ services:
ports:
- 5432:5432
volumes:
- database_data:/var/lib/postgresql/data
- langfuse_postgres_data:/var/lib/postgresql/data
volumes:
database_data:
langfuse_postgres_data:
driver: local
langfuse_clickhouse_data:
driver: local
langfuse_clickhouse_logs:
driver: local
+1
View File
@@ -10,6 +10,7 @@ services:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
- NEXTAUTH_SECRET=mysecret
- SALT=mysalt
- ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 # generate via `openssl rand -hex 32`
- NEXTAUTH_URL=http://localhost:3000
- TELEMETRY_ENABLED=${TELEMETRY_ENABLED:-true}
- LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false}
-1
View File
@@ -3,7 +3,6 @@ import { env as sharedEnv } from "@langfuse/shared";
const EnvSchema = z.object({
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION: z.string().optional(),
ADMIN_API_KEY: z.string().optional(),
});
export const env = { ...sharedEnv, ...EnvSchema.parse(process.env) };
+141 -45
View File
@@ -5,16 +5,36 @@ types:
id:
type: string
docs: The unique identifier of a trace
timestamp: datetime
name: optional<string>
input: optional<unknown>
output: optional<unknown>
sessionId: optional<string>
release: optional<string>
version: optional<string>
userId: optional<string>
metadata: optional<unknown>
tags: optional<list<string>>
timestamp:
type: datetime
docs: The timestamp when the trace was created
name:
type: optional<string>
docs: The name of the trace
input:
type: optional<unknown>
docs: The input data of the trace. Can be any JSON.
output:
type: optional<unknown>
docs: The output data of the trace. Can be any JSON.
sessionId:
type: optional<string>
docs: The session identifier associated with the trace
release:
type: optional<string>
docs: The release version of the application when the trace was created
version:
type: optional<string>
docs: The version of the trace
userId:
type: optional<string>
docs: The user identifier associated with the trace
metadata:
type: optional<unknown>
docs: The metadata associated with the trace. Can be any JSON.
tags:
type: optional<list<string>>
docs: The tags associated with the trace. Can be an array of strings or null.
public:
type: optional<boolean>
docs: Public traces are accessible via url without login
@@ -65,37 +85,97 @@ types:
traces: list<Trace>
Observation:
properties:
id: string
traceId: optional<string>
type: string
name: optional<string>
startTime: datetime
endTime: optional<datetime>
completionStartTime: optional<datetime>
model: optional<string>
modelParameters: optional<map<string, MapValue>>
input: optional<unknown>
version: optional<string>
metadata: optional<unknown>
output: optional<unknown>
usage: optional<Usage>
level: ObservationLevel
statusMessage: optional<string>
parentObservationId: optional<string>
promptId: optional<string>
id:
type: string
docs: The unique identifier of the observation
traceId:
type: optional<string>
docs: The trace ID associated with the observation
type:
type: string
docs: The type of the observation
name:
type: optional<string>
docs: The name of the observation
startTime:
type: datetime
docs: The start time of the observation
endTime:
type: optional<datetime>
docs: The end time of the observation.
completionStartTime:
type: optional<datetime>
docs: The completion start time of the observation
model:
type: optional<string>
docs: The model used for the observation
modelParameters:
type: optional<map<string, MapValue>>
docs: The parameters of the model used for the observation
input:
type: optional<unknown>
docs: The input data of the observation
version:
type: optional<string>
docs: The version of the observation
metadata:
type: optional<unknown>
docs: Additional metadata of the observation
output:
type: optional<unknown>
docs: The output data of the observation
usage:
type: optional<Usage>
docs: The usage data of the observation
level:
type: ObservationLevel
docs: The level of the observation
statusMessage:
type: optional<string>
docs: The status message of the observation
parentObservationId:
type: optional<string>
docs: The parent observation ID
promptId:
type: optional<string>
docs: The prompt ID associated with the observation
ObservationsView:
extends: Observation
properties:
modelId: optional<string>
inputPrice: optional<double>
outputPrice: optional<double>
totalPrice: optional<double>
calculatedInputCost: optional<double>
calculatedOutputCost: optional<double>
calculatedTotalCost: optional<double>
latency: optional<double>
timeToFirstToken: optional<double>
promptName:
type: optional<string>
docs: The name of the prompt associated with the observation
promptVersion:
type: optional<integer>
docs: The version of the prompt associated with the observation
modelId:
type: optional<string>
docs: The unique identifier of the model
inputPrice:
type: optional<double>
docs: The price of the input in USD
outputPrice:
type: optional<double>
docs: The price of the output in USD.
totalPrice:
type: optional<double>
docs: The total price in USD.
calculatedInputCost:
type: optional<double>
docs: The calculated cost of the input in USD
calculatedOutputCost:
type: optional<double>
docs: The calculated cost of the output in USD
calculatedTotalCost:
type: optional<double>
docs: The calculated total cost in USD
latency:
type: optional<double>
docs: The latency in seconds.
timeToFirstToken:
type: optional<double>
docs: The time to the first token in seconds
Usage:
docs: Standard interface for usage and cost
@@ -237,14 +317,30 @@ types:
updatedAt: datetime
DatasetRun:
properties:
id: string
name: string
description: optional<string>
metadata: optional<unknown>
datasetId: string
datasetName: string
createdAt: datetime
updatedAt: datetime
id:
type: string
docs: Unique identifier of the dataset run
name:
type: string
docs: Name of the dataset run
description:
type: optional<string>
docs: Description of the run
metadata:
type: optional<unknown>
docs: Metadata of the dataset run
datasetId:
type: string
docs: Id of the associated dataset
datasetName:
type: string
docs: Name of the associated dataset
createdAt:
type: datetime
docs: The date and time when the dataset run was created
updatedAt:
type: datetime
docs: The date and time when the dataset run was last updated
DatasetRunWithItems:
extends: DatasetRun
properties:
@@ -49,7 +49,7 @@ types:
sourceObservationId: optional<string>
id:
type: optional<string>
docs: Dataset items are upserted on their id. Id needs to be globally unique and cannot be reused across datasets.
docs: Dataset items are upserted on their id. Id needs to be unique (project-level) and cannot be reused across datasets.
status:
type: optional<commons.DatasetStatus>
docs: Defaults to ACTIVE for newly created items
@@ -39,6 +39,9 @@ service:
toStartTime:
type: optional<datetime>
docs: Retrieve only observations with a start_time before this datetime (ISO 8601).
version:
type: optional<string>
docs: Optional filter to only include observations with a certain version.
response: ObservationsViews
types:
+10
View File
@@ -41,6 +41,12 @@ service:
limit:
type: optional<integer>
docs: limit of items per page
fromUpdatedAt:
type: optional<datetime>
docs: Optional filter to only include prompt versions created/updated on or after a certain datetime (ISO 8601)
toUpdatedAt:
type: optional<datetime>
docs: Optional filter to only include prompt versions created/updated before a certain datetime (ISO 8601)
response: PromptMetaListResponse
@@ -63,6 +69,10 @@ types:
versions: list<integer>
labels: list<string>
tags: list<string>
lastUpdatedAt: datetime
lastConfig:
type: unknown
docs: Config object of the most recent prompt version that matches the filters (if any are provided)
CreatePromptRequest:
union:
+1 -1
View File
@@ -7,7 +7,7 @@ service:
base-path: /api/public
endpoints:
list:
docs: Get sessions.
docs: Get sessions
method: GET
path: /sessions
request:
+7 -1
View File
@@ -16,7 +16,7 @@ service:
docs: The unique langfuse identifier of a trace
response: commons.TraceWithFullDetails
list:
docs: Get list of traces.
docs: Get list of traces
method: GET
path: /traces
request:
@@ -44,6 +44,12 @@ service:
type: optional<string>
allow-multiple: true
docs: Only traces that include all of these tags will be returned.
version:
type: optional<string>
docs: Optional filter to only include traces with a certain version.
release:
type: optional<string>
docs: Optional filter to only include traces with a certain release.
response: Traces
types:
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "2.65.0",
"version": "2.69.0",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
@@ -16,8 +16,8 @@
"db:seed": "turbo run db:seed",
"db:seed:examples": "turbo run db:seed:examples",
"nuke": "bash ./scripts/nuke.sh",
"dx": "pnpm i && pnpm run infra:dev:up && pnpm --filter=shared run db:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
"dx-f": "pnpm i && pnpm run infra:dev:up && pnpm --filter=shared run db:reset -f && pnpm --filter=shared run db:seed:examples && pnpm run dev",
"dx": "pnpm i && pnpm run infra:dev:up && pnpm --filter=shared run db:reset && pnpm --filter=shared run ch:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
"dx-f": "pnpm i && pnpm run infra:dev:up && pnpm --filter=shared run db:reset -f && pnpm --filter=shared run ch:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
"build": "turbo run build",
"start": "turbo run start",
"dev": "turbo run dev",
@@ -0,0 +1 @@
DROP TABLE traces;
@@ -0,0 +1,26 @@
CREATE TABLE traces (
`id` String,
`timestamp` DateTime64(3),
`name` String,
`user_id` Nullable(String),
`metadata` Map(String, String) CODEC(ZSTD(1)),
`release` Nullable(String),
`version` Nullable(String),
`project_id` String,
`public` Bool,
`bookmarked` Bool,
`tags` Array(String),
`input` Nullable(String) CODEC(ZSTD(1)),
`output` Nullable(String) CODEC(ZSTD(1)),
`session_id` Nullable(String),
`created_at` DateTime64(3) DEFAULT now(),
`updated_at` DateTime64(3) DEFAULT now(),
INDEX idx_id id TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_res_metadata_key mapKeys(metadata) TYPE bloom_filter(0.01) GRANULARITY 1,
INDEX idx_res_metadata_value mapValues(metadata) TYPE bloom_filter(0.01) GRANULARITY 1
) ENGINE = ReplacingMergeTree Partition by toYYYYMM(timestamp)
ORDER BY (
project_id,
toUnixTimestamp(timestamp),
id
);
@@ -0,0 +1 @@
DROP TABLE observations;
@@ -0,0 +1,50 @@
CREATE TABLE observations (
`id` String,
`trace_id` String,
`project_id` String,
`type` LowCardinality(String),
`parent_observation_id` Nullable(String),
`start_time` DateTime64(3),
`end_time` Nullable(DateTime64(3)),
`name` String,
`metadata` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
`level` LowCardinality(String),
`status_message` Nullable(String),
`version` Nullable(String),
`input` Nullable(String) CODEC(ZSTD(3)),
`output` Nullable(String) CODEC(ZSTD(3)),
`provided_model_name` Nullable(String),
`internal_model_id` Nullable(String),
`model_parameters` Nullable(String),
`provided_input_usage_units` Nullable(Decimal64(12)),
`provided_output_usage_units` Nullable(Decimal64(12)),
`provided_total_usage_units` Nullable(Decimal64(12)),
`input_usage_units` Nullable(Decimal64(12)),
`output_usage_units` Nullable(Decimal64(12)),
`total_usage_units` Nullable(Decimal64(12)),
`unit` Nullable(String),
`provided_input_cost` Nullable(Decimal64(12)),
`provided_output_cost` Nullable(Decimal64(12)),
`provided_total_cost` Nullable(Decimal64(12)),
`input_cost` Nullable(Decimal64(12)),
`output_cost` Nullable(Decimal64(12)),
`total_cost` Nullable(Decimal64(12)),
`completion_start_time` Nullable(DateTime64(3)),
`prompt_id` Nullable(String),
`prompt_name` Nullable(String),
`prompt_version` Nullable(UInt16),
`created_at` DateTime64(3) DEFAULT now(),
`updated_at` DateTime64(3) DEFAULT now(),
INDEX idx_id id TYPE bloom_filter() GRANULARITY 1,
INDEX idx_trace_id trace_id TYPE bloom_filter() GRANULARITY 1,
INDEX idx_project_id project_id TYPE bloom_filter() GRANULARITY 1,
INDEX idx_res_metadata_key mapKeys(metadata) TYPE bloom_filter() GRANULARITY 1,
INDEX idx_res_metadata_value mapValues(metadata) TYPE bloom_filter() GRANULARITY 1
) ENGINE = ReplacingMergeTree Partition by toYYYYMM(start_time)
ORDER BY (
project_id,
`type`,
trace_id,
toUnixTimestamp(start_time),
id
);
@@ -0,0 +1 @@
DROP TABLE scores;
@@ -0,0 +1,25 @@
CREATE TABLE scores (
`id` String,
`timestamp` DateTime64(3),
`project_id` String,
`trace_id` String,
`observation_id` Nullable(String),
`name` String,
`value` Float64,
`source` String,
`comment` Nullable(String) CODEC(ZSTD(1)),
`author_user_id` Nullable(String),
`config_id` Nullable(String),
`data_type` String,
`string_value` Nullable(String),
`created_at` DateTime64(3) DEFAULT now(),
`updated_at` DateTime64(3) DEFAULT now(),
INDEX idx_id id TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_project_id trace_id TYPE bloom_filter(0.001) GRANULARITY 1
) ENGINE = ReplacingMergeTree Partition by toYYYYMM(timestamp)
ORDER BY (
project_id,
trace_id,
toUnixTimestamp(timestamp),
id
);
@@ -0,0 +1,22 @@
#!/bin/bash
# Load environment variables
source ../../.env
# Check if golang-migrate is installed
if ! command -v migrate &> /dev/null
then
echo "Error: golang-migrate is not installed or not in PATH."
echo "Please install golang-migrate via 'brew install golang-migrate' to run this script."
echo "Visit https://github.com/golang-migrate/migrate for more installation instructions."
exit 1
fi
# Construct the database URL
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-migrations-table-engine=MergeTree"
else
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-migrations-table-engine=MergeTree"
fi
# Execute the down command
migrate -source file://clickhouse/migrations -database "$DATABASE_URL" down
@@ -0,0 +1,22 @@
#!/bin/bash
# Load environment variables
source ../../.env
# Check if golang-migrate is installed
if ! command -v migrate &> /dev/null
then
echo "Error: golang-migrate is not installed or not in PATH."
echo "Please install golang-migrate via 'brew install golang-migrate' to run this script."
echo "Visit https://github.com/golang-migrate/migrate for more installation instructions."
exit 1
fi
# Construct the database URL
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-migrations-table-engine=MergeTree"
else
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-migrations-table-engine=MergeTree"
fi
# Execute the drop command
migrate -source file://clickhouse/migrations -database "$DATABASE_URL" drop
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
# Load environment variables
source ../../.env
# Check if golang-migrate is installed
if ! command -v migrate &> /dev/null
then
echo "Error: golang-migrate is not installed or not in PATH."
echo "Please install golang-migrate via 'brew install golang-migrate' to run this script."
echo "Visit https://github.com/golang-migrate/migrate for more installation instructions."
exit 1
fi
# Construct the database URL
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-migrations-table-engine=MergeTree"
else
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-migrations-table-engine=MergeTree"
fi
# Execute the up command
migrate -source file://clickhouse/migrations -database "$DATABASE_URL" up
+12 -1
View File
@@ -38,15 +38,22 @@
"db:seed": "dotenv -e ../../.env -- npx prisma db seed",
"db:generate": "dotenv -e ../../.env -- npx prisma generate",
"db:seed:examples": "dotenv -e ../../.env -- npx prisma db seed -- --environment examples",
"db:seed:load": "dotenv -e ../../.env -- npx prisma db seed -- --environment load"
"db:seed:load": "dotenv -e ../../.env -- npx prisma db seed -- --environment load",
"ch:status": "dotenv -e ../../.env -- goose -dir './clickhouse/migrations/' status",
"ch:up": "bash clickhouse/scripts/up.sh",
"ch:down": "bash clickhouse/scripts/down.sh",
"ch:drop": "bash clickhouse/scripts/drop.sh",
"ch:reset": "pnpm run ch:down && pnpm run ch:up"
},
"prisma": {
"seed": "ts-node -r tsconfig-paths/register -r dotenv/config --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
},
"dependencies": {
"@anthropic-ai/tokenizer": "^0.0.4",
"@aws-sdk/client-s3": "^3.550.0",
"@aws-sdk/lib-storage": "^3.568.0",
"@aws-sdk/s3-request-presigner": "^3.554.0",
"@clickhouse/client": "^1.4.0",
"@langchain/anthropic": "^0.1.21",
"@langchain/core": "^0.1.61",
"@langchain/openai": "^0.0.33",
@@ -55,12 +62,16 @@
"@react-email/render": "^0.0.15",
"@types/bcryptjs": "^2.4.6",
"bcryptjs": "^2.4.3",
"bullmq": "^5.4.2",
"ioredis": "^5.4.1",
"js-tiktoken": "^1.0.12",
"kysely": "^0.27.3",
"langchain": "^0.2.6",
"lodash": "^4.17.21",
"next-auth": "^4.24.7",
"nodemailer": "^6.9.13",
"prisma-extension-kysely": "^2.1.0",
"uuid": "^9.0.1",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.22.5"
},
+6 -1
View File
@@ -121,15 +121,16 @@ export type CronJobs = {
};
export type Dataset = {
id: string;
project_id: string;
name: string;
description: string | null;
metadata: unknown | null;
project_id: string;
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
};
export type DatasetItem = {
id: string;
project_id: string;
status: Generated<DatasetStatus>;
input: unknown | null;
expected_output: unknown | null;
@@ -142,6 +143,7 @@ export type DatasetItem = {
};
export type DatasetRunItems = {
id: string;
project_id: string;
dataset_run_id: string;
dataset_item_id: string;
trace_id: string;
@@ -151,6 +153,7 @@ export type DatasetRunItems = {
};
export type DatasetRuns = {
id: string;
project_id: string;
name: string;
description: string | null;
metadata: unknown | null;
@@ -306,6 +309,8 @@ export type ObservationView = {
unit: string | null;
completion_start_time: Timestamp | null;
prompt_id: string | null;
prompt_name: string | null;
prompt_version: number | null;
model_id: string | null;
input_price: string | null;
output_price: string | null;
@@ -0,0 +1,68 @@
-- Add project_id to datasets, dataset_items, dataset_runs, dataset_run_items
ALTER TABLE "dataset_items"
ADD COLUMN "project_id" TEXT;
ALTER TABLE "dataset_run_items"
ADD COLUMN "project_id" TEXT;
ALTER TABLE "dataset_runs"
ADD COLUMN "project_id" TEXT;
-- Backfill project_id for dataset_items
UPDATE dataset_items
SET project_id = datasets.project_id
FROM datasets
WHERE dataset_items.dataset_id = datasets.id;
-- Backfill project_id for dataset_runs
UPDATE dataset_runs
SET project_id = datasets.project_id
FROM datasets
WHERE dataset_runs.dataset_id = datasets.id;
-- Backfill project_id for dataset_run_items
UPDATE dataset_run_items
SET project_id = dataset_runs.project_id
FROM dataset_runs
WHERE dataset_run_items.dataset_run_id = dataset_runs.id;
-- Drop the old foreign keys
ALTER TABLE "dataset_run_items"
DROP CONSTRAINT "dataset_run_items_dataset_item_id_fkey",
DROP CONSTRAINT "dataset_run_items_dataset_run_id_fkey";
ALTER TABLE "dataset_items"
DROP CONSTRAINT "dataset_items_dataset_id_fkey";
ALTER TABLE "dataset_runs"
DROP CONSTRAINT "dataset_runs_dataset_id_fkey";
-- Now alter the columns to NOT NULL and update primary keys
ALTER TABLE "datasets"
DROP CONSTRAINT "datasets_pkey",
ADD CONSTRAINT "datasets_pkey" PRIMARY KEY ("id", "project_id");
ALTER TABLE "dataset_items"
ALTER COLUMN "project_id" SET NOT NULL,
DROP CONSTRAINT "dataset_items_pkey",
ADD CONSTRAINT "dataset_items_pkey" PRIMARY KEY ("id", "project_id");
ALTER TABLE "dataset_runs"
ALTER COLUMN "project_id" SET NOT NULL,
DROP CONSTRAINT "dataset_runs_pkey",
ADD CONSTRAINT "dataset_runs_pkey" PRIMARY KEY ("id", "project_id");
ALTER TABLE "dataset_run_items"
ALTER COLUMN "project_id" SET NOT NULL,
DROP CONSTRAINT "dataset_run_items_pkey",
ADD CONSTRAINT "dataset_run_items_pkey" PRIMARY KEY ("id", "project_id");
-- Add new foreign keys
ALTER TABLE "dataset_items" ADD CONSTRAINT "dataset_items_dataset_id_project_id_fkey" FOREIGN KEY ("dataset_id", "project_id") REFERENCES "datasets"("id", "project_id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "dataset_runs" ADD CONSTRAINT "dataset_runs_dataset_id_project_id_fkey" FOREIGN KEY ("dataset_id", "project_id") REFERENCES "datasets"("id", "project_id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "dataset_run_items" ADD CONSTRAINT "dataset_run_items_dataset_run_id_project_id_fkey" FOREIGN KEY ("dataset_run_id", "project_id") REFERENCES "dataset_runs"("id", "project_id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "dataset_run_items" ADD CONSTRAINT "dataset_run_items_dataset_item_id_project_id_fkey" FOREIGN KEY ("dataset_item_id", "project_id") REFERENCES "dataset_items"("id", "project_id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE UNIQUE INDEX CONCURRENTLY "dataset_runs_dataset_id_project_id_name_key" ON "dataset_runs"("dataset_id", "project_id", "name");
@@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX CONCURRENTLY "dataset_runs_dataset_id_name_key";
@@ -0,0 +1,100 @@
DROP VIEW IF EXISTS "observations_view"; -- Drop view as column was added in 20240705154048_observation_view_add_created_at_updated_at and update view must have same columns
CREATE VIEW "observations_view" AS -- Specify the columns that should be returned in the view, as calculated columns are added but exist in the observations table already
SELECT
o.id,
o.name,
o.start_time,
o.end_time,
o.parent_observation_id,
o.type,
o.trace_id,
o.metadata,
o.model,
o."modelParameters",
o.input,
o.output,
o.level,
o.status_message,
o.completion_start_time,
o.completion_tokens,
o.prompt_tokens,
o.total_tokens,
o.version,
o.project_id,
o.created_at,
o.updated_at,
o.unit,
o.prompt_id,
p.name as prompt_name, -- added in this change
p.version as prompt_version, -- added in this change
o.input_cost,
o.output_cost,
o.total_cost,
o.internal_model,
m.id AS "model_id",
m.start_date AS "model_start_date",
m.input_price,
m.output_price,
m.total_price,
m.tokenizer_config AS "tokenizer_config",
CASE
WHEN o.calculated_input_cost IS NULL AND o.input_cost IS NULL AND o.output_cost IS NULL AND o.total_cost IS NULL THEN
o.prompt_tokens::decimal * m.input_price
ELSE
COALESCE(o.calculated_input_cost, o.input_cost)
END AS "calculated_input_cost",
CASE
WHEN o.calculated_output_cost IS NULL AND o.input_cost IS NULL AND o.output_cost IS NULL AND o.total_cost IS NULL THEN
o.completion_tokens::decimal * m.output_price
ELSE
COALESCE(o.calculated_output_cost, o.output_cost)
END AS "calculated_output_cost",
CASE
WHEN o.calculated_total_cost IS NULL AND o.input_cost IS NULL AND o.output_cost IS NULL AND o.total_cost IS NULL THEN
CASE
WHEN m.total_price IS NOT NULL AND o.total_tokens IS NOT NULL THEN
m.total_price * o.total_tokens
ELSE
o.prompt_tokens::decimal * m.input_price +
o.completion_tokens::decimal * m.output_price
END
ELSE
COALESCE(o.calculated_total_cost, o.total_cost)
END AS "calculated_total_cost",
CASE WHEN o.end_time IS NULL THEN NULL ELSE (EXTRACT(EPOCH FROM o."end_time") - EXTRACT(EPOCH FROM o."start_time"))::double precision END AS "latency",
CASE WHEN o.completion_start_time IS NOT NULL AND o.start_time IS NOT NULL THEN EXTRACT(EPOCH FROM (completion_start_time - start_time))::double precision ELSE NULL END as "time_to_first_token"
FROM
observations o
LEFT JOIN LATERAL (
SELECT
models.*
FROM
models
WHERE (models.project_id = o.project_id OR models.project_id IS NULL)
AND models.model_name = o.internal_model
AND (models.start_date < o.start_time OR models.start_date IS NULL)
AND o.unit::TEXT = models.unit
ORDER BY
models.project_id ASC, -- in postgres, NULLs are sorted last when ordering ASC
models.start_date DESC NULLS LAST -- now, NULLs are sorted last when ordering DESC as well
LIMIT 1
) m ON TRUE
LEFT JOIN LATERAL (
SELECT
prompts.*
FROM
prompts
WHERE prompts.id = o.prompt_id
AND prompts.project_id = o.project_id
LIMIT 1
) p ON TRUE
-- requirements:
-- 1. The view should return all columns from the observations table
-- 2. The view should match with only one model for each observation if:
-- a. The model has the same project_id as the observation, otherwise the model without project_id.
-- b. The model has the same model_name as the observation
-- c. The model has a start_date that is less than the observation start_time, otherwise the model without start_date
-- d. The model has the same unit as the observation
@@ -0,0 +1,18 @@
INSERT INTO models (
id,
project_id,
model_name,
match_pattern,
start_date,
input_price,
output_price,
total_price,
unit,
tokenizer_id,
tokenizer_config
)
VALUES
-- gpt-4o-2024-08-06
('clzjr85f70000ymmzg7hqffra', NULL, 'gpt-4o-2024-08-06', '(?i)^(gpt-4o-2024-08-06)$', NULL, 0.0000025, 0.000010, NULL, 'TOKENS', 'openai', '{ "tokensPerMessage": 3, "tokensPerName": 1, "tokenizerModel": "gpt-4o" }')
+21 -11
View File
@@ -376,7 +376,10 @@ view ObservationView {
unit String?
completionStartTime DateTime? @map("completion_start_time")
promptId String? @map("prompt_id")
// prompts
promptId String? @map("prompt_id")
promptName String? @map("prompt_name")
promptVersion Int? @map("prompt_version")
// model fields
modelId String? @map("model_id")
@@ -488,17 +491,18 @@ model CronJobs {
}
model Dataset {
id String @id @default(cuid())
id String @default(cuid())
projectId String @map("project_id")
name String
description String?
metadata Json?
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
datasetItems DatasetItem[]
datasetRuns DatasetRuns[]
@@id([id, projectId])
@@unique([projectId, name])
@@index([projectId], type: Hash)
@@index([createdAt])
@@ -507,7 +511,8 @@ model Dataset {
}
model DatasetItem {
id String @id @default(cuid())
id String @default(cuid())
projectId String @map("project_id")
status DatasetStatus @default(ACTIVE)
input Json?
expectedOutput Json? @map("expected_output")
@@ -517,11 +522,12 @@ model DatasetItem {
sourceObservationId String? @map("source_observation_id")
sourceObservation Observation? @relation(fields: [sourceObservationId], references: [id], onDelete: SetNull)
datasetId String @map("dataset_id")
dataset Dataset @relation(fields: [datasetId], references: [id], onDelete: Cascade)
dataset Dataset @relation(fields: [datasetId, projectId], references: [id, projectId], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
datasetRunItems DatasetRunItems[]
@@id([id, projectId])
@@index([sourceTraceId], type: Hash)
@@index([sourceObservationId], type: Hash)
@@index([datasetId], type: Hash)
@@ -536,17 +542,19 @@ enum DatasetStatus {
}
model DatasetRuns {
id String @id @default(cuid())
id String @default(cuid())
projectId String @map("project_id")
name String
description String?
metadata Json?
datasetId String @map("dataset_id")
dataset Dataset @relation(fields: [datasetId], references: [id], onDelete: Cascade)
dataset Dataset @relation(fields: [datasetId, projectId], references: [id, projectId], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
datasetRunItems DatasetRunItems[]
@@unique([datasetId, name])
@@id([id, projectId])
@@unique([datasetId, projectId, name])
@@index([datasetId], type: Hash)
@@index([createdAt])
@@index([updatedAt])
@@ -554,16 +562,18 @@ model DatasetRuns {
}
model DatasetRunItems {
id String @id @default(cuid())
id String @default(cuid())
projectId String @map("project_id")
datasetRunId String @map("dataset_run_id")
datasetRun DatasetRuns @relation(fields: [datasetRunId], references: [id], onDelete: Cascade)
datasetRun DatasetRuns @relation(fields: [datasetRunId, projectId], references: [id, projectId], onDelete: Cascade)
datasetItemId String @map("dataset_item_id")
datasetItem DatasetItem @relation(fields: [datasetItemId], references: [id], onDelete: Cascade)
datasetItem DatasetItem @relation(fields: [datasetItemId, projectId], references: [id, projectId], onDelete: Cascade)
traceId String @map("trace_id")
observationId String? @map("observation_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
@@id([id, projectId])
@@index([datasetRunId], type: Hash)
@@index([datasetItemId], type: Hash)
@@index([observationId], type: Hash)
+8
View File
@@ -14,6 +14,7 @@ import { v4 } from "uuid";
import { ModelUsageUnit } from "../src";
import { getDisplaySecretKey, hashSecretKey } from "../src/server";
import { encrypt } from "../src/encryption";
import { redis } from "../src/server/redis/redis";
const LOAD_TRACE_VOLUME = 10_000;
@@ -286,6 +287,7 @@ async function main() {
: undefined;
const datasetItem = await prisma.datasetItem.create({
data: {
projectId: project2.id,
datasetId: dataset.id,
sourceTraceId: sourceObservation?.traceId,
sourceObservationId:
@@ -312,6 +314,7 @@ async function main() {
for (let datasetRunNumber = 0; datasetRunNumber < 5; datasetRunNumber++) {
const datasetRun = await prisma.datasetRuns.create({
data: {
projectId: project2.id,
name: `demo-dataset-run-${datasetRunNumber}`,
description: Math.random() > 0.5 ? "Dataset run description" : "",
datasetId: dataset.id,
@@ -336,6 +339,7 @@ async function main() {
await prisma.datasetRunItems.create({
data: {
projectId: project2.id,
datasetItemId,
traceId: observation.traceId as string,
observationId: Math.random() > 0.5 ? observation.id : undefined,
@@ -351,10 +355,14 @@ async function main() {
main()
.then(async () => {
await prisma.$disconnect();
redis?.disconnect();
console.log("Disconnected from postgres and redis");
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
redis?.disconnect();
console.log("Disconnected from postgres and redis");
process.exit(1);
});
+11 -9
View File
@@ -1,7 +1,7 @@
import crypto from "crypto";
import { env } from "../env";
const ENCRYPTION_KEY: string = env.ENCRYPTION_KEY || ""; // Must be 256 bits (32 bytes, 64 hex characters)
const ENCRYPTION_KEY: string | undefined = env.ENCRYPTION_KEY; // Must be 256 bits (32 bytes, 64 hex characters)
const IV_LENGTH: number = 16; // For AES, this is always 16
// Alternatively: openssl rand -hex 32
@@ -13,17 +13,16 @@ export function keyGen() {
* Encrypts the given plain text using AES-256-GCM algorithm.
*
* @param {string} plainText - The text to encrypt.
* @param {string} [keyHex=ENCRYPTION_KEY] - The encryption key in hexadecimal format.
* @returns {string} The encrypted data in hex format, including IV and authentication tag.
*/
export function encrypt(
plainText: string,
keyHex: string = ENCRYPTION_KEY
): string {
export function encrypt(plainText: string): string {
if (!ENCRYPTION_KEY) {
throw new Error("Missing environment variable: `ENCRYPTION_KEY`");
}
const iv = crypto.randomBytes(IV_LENGTH); // Directly use Buffer returned by randomBytes
const cipher = crypto.createCipheriv(
"aes-256-gcm",
Buffer.from(keyHex, "hex"),
Buffer.from(ENCRYPTION_KEY, "hex"),
iv
);
let encrypted = cipher.update(plainText, "utf8", "hex");
@@ -34,7 +33,10 @@ export function encrypt(
return iv.toString("hex") + ":" + encrypted + ":" + authTag.toString("hex");
}
export function decrypt(text: string, keyHex: string = ENCRYPTION_KEY): string {
export function decrypt(text: string): string {
if (!ENCRYPTION_KEY) {
throw new Error("Missing environment variable: `ENCRYPTION_KEY`");
}
const [ivHex, encryptedHex, authTagHex] = text.split(":");
if (!ivHex || !encryptedHex || !authTagHex) {
throw new Error("Invalid or corrupted cipher format");
@@ -46,7 +48,7 @@ export function decrypt(text: string, keyHex: string = ENCRYPTION_KEY): string {
const decipher = crypto.createDecipheriv(
"aes-256-gcm",
Buffer.from(keyHex, "hex"),
Buffer.from(ENCRYPTION_KEY, "hex"),
iv
);
decipher.setAuthTag(authTag);
+20
View File
@@ -1,6 +1,21 @@
import { z } from "zod";
const EnvSchema = z.object({
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
REDIS_HOST: z.string().nullish(),
REDIS_PORT: z.coerce
.number({
description:
".env files convert numbers to strings, therefoore we have to enforce them to be numbers",
})
.positive()
.max(65536, `options.port should be >= 0 and < 65536`)
.default(6379)
.nullable(),
REDIS_AUTH: z.string().nullish(),
REDIS_CONNECTION_STRING: z.string().nullish(),
ENCRYPTION_KEY: z
.string()
.length(
@@ -8,6 +23,11 @@ const EnvSchema = z.object({
"ENCRYPTION_KEY must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32"
)
.optional(),
LANGFUSE_CACHE_PROMPT_ENABLED: z.enum(["true", "false"]).default("false"),
LANGFUSE_CACHE_PROMPT_TTL_SECONDS: z.coerce.number().default(60 * 60),
CLICKHOUSE_URL: z.string().url().optional(),
CLICKHOUSE_USER: z.string().optional(),
CLICKHOUSE_PASSWORD: z.string().optional(),
});
export const env = EnvSchema.parse(process.env);
@@ -14,7 +14,7 @@ const NumericData = z.object({
});
const CategoricalData = z.object({
value: z.number().optional().nullish(),
value: z.number().nullish(),
stringValue: z.string(),
dataType: z.literal("CATEGORICAL"),
});
@@ -31,7 +31,7 @@ const CreateAnnotationScoreBase = z.object({
traceId: z.string(),
configId: z.string().optional(),
observationId: z.string().optional(),
comment: z.string().optional().nullish(),
comment: z.string().nullish(),
});
const UpdateAnnotationScoreBase = CreateAnnotationScoreBase.extend({
@@ -81,6 +81,7 @@ export const evalModelsByAdapter: Record<LLMAdapter, string[]> = {
[LLMAdapter.OpenAI]: [
"gpt-4o",
"gpt-4o-mini",
"gpt-4o-2024-08-06", // cheaper than gpt-4o
"gpt-4-turbo-preview",
"gpt-3.5-turbo",
] as const,
+1 -1
View File
@@ -10,9 +10,9 @@ export * from "./orderByToPrisma";
export * from "./tracesTable";
export * from "./server/auth";
export * from "./observationsTable";
export * from "./features/ingestion/types";
export * from "./utils/zod";
export * from "./utils/json";
export * from "./utils/objects";
export { env } from "./env";
// llm api
+2
View File
@@ -44,6 +44,7 @@ export enum QueueName {
EvaluationExecution = "evaluation-execution-queue", // Worker executes Evals
BatchExport = "batch-export-queue",
RepeatQueue = "repeat-queue",
IngestionFlushQueue = "ingestion-flush-queue",
}
export enum QueueJobs {
@@ -51,6 +52,7 @@ export enum QueueJobs {
EvaluationExecution = "evaluation-execution-job",
BatchExportJob = "batch-export-job",
EnqueueBatchExportJobs = "enqueue-batch-export-jobs",
FlushIngestionEntity = "flush-ingestion-entity",
}
export type TQueueJobTypes = {
+16
View File
@@ -0,0 +1,16 @@
import { createClient } from "@clickhouse/client";
import { env } from "../env";
export type ClickhouseClientType = ReturnType<typeof createClient>;
export const clickhouseClient = createClient({
url: env.CLICKHOUSE_URL,
username: env.CLICKHOUSE_USER,
password: env.CLICKHOUSE_PASSWORD,
database: "default",
clickhouse_settings: {
async_insert: 1,
wait_for_async_insert: 1, // if disabled, we won't get errors from clickhouse
},
});
+167
View File
@@ -0,0 +1,167 @@
import z from "zod";
export const clickhouseStringDateSchema = z
.string()
// clickhouse stores UTC like '2024-05-23 18:33:41.602000'
// we need to convert it to '2024-05-23T18:33:41.602000Z'
.transform((str) => str.replace(" ", "T") + "Z")
.pipe(z.string().datetime());
export const observationRecordBaseSchema = z.object({
id: z.string(),
trace_id: z.string().nullish(),
project_id: z.string(),
type: z.string(),
parent_observation_id: z.string().nullish(),
name: z.string().nullish(),
metadata: z.record(z.string()),
level: z.string().nullish(),
status_message: z.string().nullish(),
version: z.string().nullish(),
input: z.string().nullish(),
output: z.string().nullish(),
provided_model_name: z.string().nullish(),
internal_model_id: z.string().nullish(),
model_parameters: z.string().nullish(),
unit: z.string().nullish(),
input_usage_units: z.number().nullish(),
output_usage_units: z.number().nullish(),
total_usage_units: z.number().nullish(),
input_cost: z.number().nullish(),
output_cost: z.number().nullish(),
total_cost: z.number().nullish(),
provided_input_usage_units: z.number().nullish(),
provided_output_usage_units: z.number().nullish(),
provided_total_usage_units: z.number().nullish(),
provided_input_cost: z.number().nullish(),
provided_output_cost: z.number().nullish(),
provided_total_cost: z.number().nullish(),
prompt_id: z.string().nullish(),
prompt_name: z.string().nullish(),
prompt_version: z.number().nullish(),
});
export type ObservationRecordBaseType = z.infer<
typeof observationRecordBaseSchema
>;
export const observationRecordReadSchema = observationRecordBaseSchema.extend({
created_at: clickhouseStringDateSchema,
updated_at: clickhouseStringDateSchema,
start_time: clickhouseStringDateSchema,
end_time: clickhouseStringDateSchema.nullish(),
completion_start_time: clickhouseStringDateSchema.nullish(),
});
export type ObservationRecordReadType = z.infer<
typeof observationRecordReadSchema
>;
export const observationRecordInsertSchema = observationRecordBaseSchema.extend(
{
created_at: z.number(),
updated_at: z.number(),
start_time: z.number(),
end_time: z.number().nullish(),
completion_start_time: z.number().nullish(),
}
);
export type ObservationRecordInsertType = z.infer<
typeof observationRecordInsertSchema
>;
export const traceRecordBaseSchema = z.object({
id: z.string(),
name: z.string().nullish(),
user_id: z.string().nullish(),
metadata: z.record(z.string()),
release: z.string().nullish(),
version: z.string().nullish(),
project_id: z.string(),
public: z.boolean(),
bookmarked: z.boolean(),
tags: z.array(z.string()),
input: z.string().nullish(),
output: z.string().nullish(),
session_id: z.string().nullish(),
});
export type TraceRecordBaseType = z.infer<typeof traceRecordBaseSchema>;
export const traceRecordReadSchema = traceRecordBaseSchema.extend({
timestamp: clickhouseStringDateSchema,
created_at: clickhouseStringDateSchema,
updated_at: clickhouseStringDateSchema,
});
export type TraceRecordReadType = z.infer<typeof traceRecordReadSchema>;
export const traceRecordInsertSchema = traceRecordBaseSchema.extend({
timestamp: z.number(),
created_at: z.number(),
updated_at: z.number(),
});
export type TraceRecordInsertType = z.infer<typeof traceRecordInsertSchema>;
export const scoreRecordBaseSchema = z.object({
id: z.string(),
project_id: z.string(),
trace_id: z.string(),
observation_id: z.string().nullish(),
name: z.string().nullish(),
value: z.union([z.number(), z.string()]).nullish(),
source: z.string(),
comment: z.string().nullish(),
author_user_id: z.string().nullish(),
config_id: z.string().nullish(),
data_type: z.enum(["NUMERIC", "CATEGORICAL", "BOOLEAN"]).nullish(),
string_value: z.string().nullish(),
});
export type ScoreRecordBaseType = z.infer<typeof scoreRecordBaseSchema>;
export const scoreRecordReadSchema = scoreRecordBaseSchema.extend({
created_at: clickhouseStringDateSchema,
updated_at: clickhouseStringDateSchema,
timestamp: clickhouseStringDateSchema,
});
export type ScoreRecordReadType = z.infer<typeof scoreRecordReadSchema>;
export const scoreRecordInsertSchema = scoreRecordBaseSchema.extend({
created_at: z.number(),
updated_at: z.number(),
timestamp: z.number(),
});
export type ScoreRecordInsertType = z.infer<typeof scoreRecordInsertSchema>;
export const convertTraceReadToInsert = (
record: TraceRecordReadType
): TraceRecordInsertType => {
return {
...record,
created_at: new Date(record.created_at).getTime(),
updated_at: new Date(record.created_at).getTime(),
timestamp: new Date(record.timestamp).getTime(),
};
};
export const convertObservationReadToInsert = (
record: ObservationRecordReadType
): ObservationRecordInsertType => {
return {
...record,
created_at: new Date(record.created_at).getTime(),
updated_at: new Date(record.created_at).getTime(),
start_time: new Date(record.start_time).getTime(),
end_time: record.end_time ? new Date(record.end_time).getTime() : undefined,
completion_start_time: record.completion_start_time
? new Date(record.completion_start_time).getTime()
: undefined,
};
};
export const convertScoreReadToInsert = (
record: ScoreRecordReadType
): ScoreRecordInsertType => {
return {
...record,
created_at: new Date(record.created_at).getTime(),
updated_at: new Date(record.updated_at).getTime(),
timestamp: new Date(record.timestamp).getTime(),
};
};
+9
View File
@@ -2,8 +2,17 @@ export * from "./services/S3StorageService";
export * from "./services/email/projectInvitation/sendProjectInvitationEmail";
export * from "./services/email/batchExportSuccess/sendBatchExportSuccessEmail";
export * from "./services/email/passwordReset/sendResetPasswordVerificationRequest";
export * from "./services/PromptService";
export * from "./auth";
export * from "./llm/fetchLLMCompletion";
export * from "./llm/types";
export * from "./utils/DatabaseReadStream";
export * from "./utils/transforms";
export * from "./clickhouse";
export * from "../server/definitions";
export * from "../server/ingestion/types";
export * from "../server/ingestion/model-match";
export * from "../server/ingestion/types";
export * from "./redis/redis";
export * from "./redis/trace-upsert";
export * from "./clickhouse";
@@ -0,0 +1,63 @@
import { Model, Observation, Prisma } from "@prisma/client";
import { prisma } from "../../db";
export async function findModel(p: {
event: {
projectId: string;
model?: string;
unit?: string;
startTime?: Date;
};
existingDbObservation?: Observation;
}): Promise<Model | null> {
const { event, existingDbObservation } = p;
// either get the model from the existing observation
// or match pattern on the user provided model name
const modelCondition = event.model
? Prisma.sql`AND ${event.model} ~ match_pattern`
: existingDbObservation?.internalModel
? Prisma.sql`AND model_name = ${existingDbObservation.internalModel}`
: undefined;
if (!modelCondition) return null;
// unit based on the current event or the existing observation, both can be undefined
const mergedUnit = event.unit ?? existingDbObservation?.unit;
const unitCondition = mergedUnit
? Prisma.sql`AND unit = ${mergedUnit}`
: Prisma.empty;
const sql = Prisma.sql`
SELECT
id,
created_at AS "createdAt",
updated_at AS "updatedAt",
project_id AS "projectId",
model_name AS "modelName",
match_pattern AS "matchPattern",
start_date AS "startDate",
input_price AS "inputPrice",
output_price AS "outputPrice",
total_price AS "totalPrice",
unit,
tokenizer_id AS "tokenizerId",
tokenizer_config AS "tokenizerConfig"
FROM
models
WHERE (project_id = ${event.projectId}
OR project_id IS NULL)
${modelCondition}
${unitCondition}
AND (start_date IS NULL OR start_date <= ${
event.startTime ? new Date(event.startTime) : new Date()
}::timestamp with time zone at time zone 'UTC')
ORDER BY
project_id ASC,
start_date DESC NULLS LAST
LIMIT 1
`;
const foundModels = await prisma.$queryRaw<Array<Model>>(sql);
return foundModels[0] ?? null;
}
@@ -168,63 +168,39 @@ const BaseScoreBody = z.object({
/**
* ScoreBody exactly mirrors `PostScoresBody` in the public API. Please refer there for source of truth.
*/
export const ScoreBody = z
.discriminatedUnion("dataType", [
BaseScoreBody.merge(
z.object({
value: z.number(),
dataType: z.literal("NUMERIC"),
configId: z.string().nullish(),
})
),
BaseScoreBody.merge(
z.object({
value: z.string(),
dataType: z.literal("CATEGORICAL"),
configId: z.string().nullish(),
})
),
BaseScoreBody.merge(
z.object({
value: z.number().refine((val) => val === 0 || val === 1, {
message: "Value must be either 0 or 1",
}),
dataType: z.literal("BOOLEAN"),
configId: z.string().nullish(),
})
),
BaseScoreBody.merge(
z.object({
value: z.union([z.string(), z.number()]),
dataType: z.undefined(),
configId: z.string().nullish(),
})
),
])
.superRefine((data, ctx) => {
if (data.dataType) {
if (typeof data.value === "number") {
if (data.dataType === "CATEGORICAL") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Value must be a string for data type ${data.dataType}`,
});
}
} else if (typeof data.value === "string") {
if (data.dataType === "NUMERIC") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Value must be a number for data type ${data.dataType}`,
});
} else if (data.dataType === "BOOLEAN") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Value must number equal to either 0 or 1 for data type ${data.dataType}`,
});
}
}
}
});
export const ScoreBody = z.discriminatedUnion("dataType", [
BaseScoreBody.merge(
z.object({
value: z.number(),
dataType: z.literal("NUMERIC"),
configId: z.string().nullish(),
})
),
BaseScoreBody.merge(
z.object({
value: z.string(),
dataType: z.literal("CATEGORICAL"),
configId: z.string().nullish(),
})
),
BaseScoreBody.merge(
z.object({
value: z.number().refine((value) => value === 0 || value === 1, {
message:
"Value must be a number equal to either 0 or 1 for data type BOOLEAN",
}),
dataType: z.literal("BOOLEAN"),
configId: z.string().nullish(),
})
),
BaseScoreBody.merge(
z.object({
value: z.union([z.string(), z.number()]),
dataType: z.undefined(),
configId: z.string().nullish(),
})
),
]);
// LEGACY, only required for backwards compatibility
export const LegacySpanPostSchema = z.object({
@@ -334,6 +310,16 @@ export const SdkLogEvent = z.object({
// definitions for the ingestion API
export const observationTypes = [
"observation-create",
"observation-update",
"generation-create",
"generation-update",
"span-create",
"span-update",
"event-create",
];
export const eventTypes = {
TRACE_CREATE: "trace-create",
SCORE_CREATE: "score-create",
@@ -343,7 +329,6 @@ export const eventTypes = {
GENERATION_CREATE: "generation-create",
GENERATION_UPDATE: "generation-update",
SDK_LOG: "sdk-log",
// LEGACY, only required for backwards compatibility
OBSERVATION_CREATE: "observation-create",
OBSERVATION_UPDATE: "observation-update",
@@ -358,6 +343,7 @@ export const traceEvent = base.extend({
type: z.literal(eventTypes.TRACE_CREATE),
body: TraceBody,
});
export type TraceEventType = z.infer<typeof traceEvent>;
export const eventCreateEvent = base.extend({
type: z.literal(eventTypes.EVENT_CREATE),
@@ -383,6 +369,7 @@ export const scoreEvent = base.extend({
type: z.literal(eventTypes.SCORE_CREATE),
body: ScoreBody,
});
export type ScoreEventType = z.infer<typeof scoreEvent>;
export const sdkLogEvent = base.extend({
type: z.literal(eventTypes.SDK_LOG),
body: SdkLogEvent,
@@ -409,10 +396,35 @@ export const ingestionEvent = z.discriminatedUnion("type", [
legacyObservationCreateEvent,
legacyObservationUpdateEvent,
]);
export type IngestionEventType = z.infer<typeof ingestionEvent>;
export const ingestionBatchEvent = z.array(ingestionEvent);
export type IngestionBatchEventType = z.infer<typeof ingestionBatchEvent>;
export const ingestionEventWithProjectId = ingestionEvent.and(
z.object({ projectId: z.string() })
);
export type IngestionEventWithProjectIdType = z.infer<
typeof ingestionEventWithProjectId
>;
export const ingestionApiSchema = z.object({
batch: ingestionBatchEvent,
metadata: jsonSchema.nullish(),
});
export const ingestionApiSchemaWithProjectId = ingestionApiSchema.extend({
projectId: z.string(),
});
export type IngestionApiSchemaWithProjectId = z.infer<
typeof ingestionApiSchemaWithProjectId
>;
export type ObservationEvent =
| z.infer<typeof legacyObservationCreateEvent>
| z.infer<typeof legacyObservationUpdateEvent>
| z.infer<typeof eventCreateEvent>
| z.infer<typeof spanCreateEvent>
| z.infer<typeof spanUpdateEvent>
| z.infer<typeof generationCreateEvent>
| z.infer<typeof generationUpdateEvent>;
+3
View File
@@ -45,8 +45,10 @@ export const ZodModelConfig = z.object({
top_p: z.coerce.number().optional(),
});
// NOTE: Update docs page when changing this!
export const openAIModels = [
"gpt-4o",
"gpt-4o-2024-08-06",
"gpt-4o-2024-05-13",
"gpt-4o-mini",
"gpt-4o-mini-2024-07-18",
@@ -66,6 +68,7 @@ export const openAIModels = [
export type OpenAIModel = (typeof openAIModels)[number];
// NOTE: Update docs page when changing this!
export const anthropicModels = [
"claude-3-5-sonnet-20240620",
"claude-3-opus-20240229",
@@ -1,23 +1,23 @@
import Redis from "ioredis";
import { env } from "./env";
import logger from "./logger";
import { env } from "../../env";
const createRedisClient = () => {
try {
return env.REDIS_CONNECTION_STRING
? new Redis(env.REDIS_CONNECTION_STRING, { maxRetriesPerRequest: null })
: new Redis({
host: String(env.REDIS_HOST),
port: Number(env.REDIS_PORT),
password: String(env.REDIS_AUTH),
maxRetriesPerRequest: null, // Set to `null` to disable retrying
});
: env.REDIS_HOST
? new Redis({
host: String(env.REDIS_HOST),
port: Number(env.REDIS_PORT),
password: String(env.REDIS_AUTH),
maxRetriesPerRequest: null, // Set to `null` to disable retrying
})
: null;
} catch (e) {
logger.error(e, "Failed to connect to redis");
console.error(e, "Failed to connect to redis");
return null;
}
};
declare global {
// eslint-disable-next-line no-var
var redis: undefined | ReturnType<typeof createRedisClient>;
@@ -0,0 +1,65 @@
import { randomUUID } from "crypto";
import {
QueueJobs,
QueueName,
TQueueJobTypes,
TraceUpsertEventType,
} from "../../queues";
import { Queue } from "bullmq";
import { redis } from "./redis";
let traceUpsertQueue: Queue<TQueueJobTypes[QueueName.TraceUpsert]> | null =
null;
export const getTraceUpsertQueue = () => {
if (traceUpsertQueue) return traceUpsertQueue;
traceUpsertQueue = redis
? new Queue<TQueueJobTypes[QueueName.TraceUpsert]>(QueueName.TraceUpsert, {
connection: redis,
})
: null;
return traceUpsertQueue;
};
export function convertTraceUpsertEventsToRedisEvents(
events: TraceUpsertEventType[]
) {
const uniqueTracesPerProject = events.reduce((acc, event) => {
if (!acc.get(event.projectId)) {
acc.set(event.projectId, new Set());
}
acc.get(event.projectId)?.add(event.traceId);
return acc;
}, new Map<string, Set<string>>());
const jobs = [...uniqueTracesPerProject.entries()]
.map((tracesPerProject) => {
const [projectId, traceIds] = tracesPerProject;
return [...traceIds].map((traceId) => ({
name: QueueJobs.TraceUpsert,
data: {
payload: {
projectId,
traceId,
},
id: randomUUID(),
timestamp: new Date(),
name: QueueJobs.TraceUpsert as const,
},
opts: {
removeOnFail: 10000,
removeOnComplete: true,
attempts: 5,
backoff: {
type: "exponential",
delay: 1000,
},
},
}));
})
.flat();
return jobs;
}
@@ -0,0 +1,251 @@
import { Prompt, PrismaClient } from "@prisma/client";
import { Redis } from "ioredis";
import { env } from "../../env";
export class PromptService {
private cacheEnabled: boolean;
private ttlSeconds: number;
constructor(
private prisma: PrismaClient,
private redis: Redis | null,
private metricIncrementer?: // used for Sentry metrics
(name: string, value?: number) => void,
cacheEnabled?: boolean // used for testing
) {
this.cacheEnabled =
Boolean(redis) &&
(cacheEnabled || env.LANGFUSE_CACHE_PROMPT_ENABLED === "true");
this.ttlSeconds = env.LANGFUSE_CACHE_PROMPT_TTL_SECONDS;
if (this.cacheEnabled) {
this.logInfo("Prompt cache enabled with TTL seconds", this.ttlSeconds);
} else {
this.logInfo("Prompt cache disabled");
}
}
public async getPrompt(params: PromptParams): Promise<Prompt | null> {
if (await this.shouldUseCache(params)) {
const cachedPrompt = await this.getCachedPrompt(params);
this.incrementMetric(
cachedPrompt ? Metrics.PromptCacheHit : Metrics.PromptCacheMiss
);
if (cachedPrompt) {
this.logInfo("Returning cached prompt for params", params);
return cachedPrompt;
}
}
const dbPrompt = await this.getDbPrompt(params);
if ((await this.shouldUseCache(params)) && dbPrompt) {
await this.cachePrompt({ ...params, prompt: dbPrompt });
this.logInfo("Successfully cached prompt for params", params);
}
this.logInfo("Returning DB prompt for params", params);
return dbPrompt;
}
private async getDbPrompt(params: PromptParams): Promise<Prompt | null> {
const { projectId, promptName, version, label } = params;
if (version) {
return await this.prisma.prompt.findFirst({
where: {
projectId,
name: promptName,
version,
},
});
}
if (label) {
return await this.prisma.prompt.findFirst({
where: {
projectId,
name: promptName,
labels: {
has: label,
},
},
});
}
this.logError("Invalid prompt params", params);
return null;
}
private async shouldUseCache(params: PromptParams): Promise<boolean> {
if (!this.cacheEnabled) return false;
const isLocked = await this.isCacheLocked(params);
if (isLocked) {
this.logInfo("Cache is locked for params", params);
}
return !isLocked;
}
private async getCachedPrompt(params: PromptParams): Promise<Prompt | null> {
try {
const key = this.getCacheKey(params);
const value = await this.redis?.getex(key, "EX", this.ttlSeconds);
if (value) return JSON.parse(value) as Prompt;
} catch (e) {
this.logError("Error getting cached prompt", e);
}
return null;
}
private async cachePrompt(params: PromptParams & { prompt: Prompt }) {
try {
const keyIndexKey = this.getKeyIndexKey(params);
const key = this.getCacheKey(params);
const value = JSON.stringify(params.prompt);
await this.redis?.sadd(keyIndexKey, key);
await this.redis?.set(key, value, "EX", this.ttlSeconds);
} catch (e) {
this.logError("Error caching prompt", e);
}
}
public async lockCache(
params: Pick<PromptParams, "projectId" | "promptName">
): Promise<void> {
if (!this.cacheEnabled) return;
const lockKey = this.getLockKey(params);
try {
await this.redis?.setex(lockKey, 30, "locked");
} catch (e) {
this.logError("Error locking cache key prefix", lockKey, e);
throw e;
}
}
public async unlockCache(
params: Pick<PromptParams, "projectId" | "promptName">
): Promise<void> {
if (!this.cacheEnabled) return;
const lockKey = this.getLockKey(params);
try {
await this.redis?.del(lockKey);
} catch (e) {
this.logError("Error unlocking cache key prefix", lockKey, e);
// Don't re-throw error as lock TTL is short and it's not critical
}
}
private async isCacheLocked(
params: Pick<PromptParams, "projectId" | "promptName">
): Promise<boolean> {
const lockKey = this.getLockKey(params);
try {
return Boolean(await this.redis?.exists(lockKey));
} catch (e) {
this.logError("Error checking if cache is locked", lockKey, e);
return false;
}
}
private getLockKey(
params: Pick<PromptParams, "projectId" | "promptName">
): string {
// Important to *pre*fix LOCK as otherwise it would be deleted by deleteKeysByPrefix
return `LOCK:${this.getCacheKeyPrefix(params)}`;
}
public async invalidateCache(
params: Pick<PromptParams, "projectId" | "promptName">
): Promise<void> {
if (!this.cacheEnabled) return;
const cacheKeyPrefix = this.getCacheKeyPrefix(params);
try {
const startTime = Date.now();
this.logInfo("Invalidating cache for prefix", cacheKeyPrefix);
const keyIndexKey = this.getKeyIndexKey(params);
const keys = await this.redis?.smembers(keyIndexKey);
// Delete all keys for the prefix and the key index
await this.redis?.del([...(keys ?? []), keyIndexKey]);
this.logInfo(
`Cache invalidated for prefix ${cacheKeyPrefix} in ${Date.now() - startTime}ms`
);
} catch (e) {
this.logError("Error deleting keys for prefix", cacheKeyPrefix, e);
throw e;
}
}
private getCacheKey(params: PromptParams): string {
const prefix = this.getCacheKeyPrefix(params);
return `${prefix}:${params.version ?? params.label}`;
}
private getCacheKeyPrefix(
params: Pick<PromptParams, "projectId" | "promptName">
): string {
return `prompt:${params.projectId}:${params.promptName}`;
}
private getKeyIndexKey(
params: Pick<PromptParams, "projectId" | "promptName">
): string {
return `prompt_key_index:${params.projectId}:${params.promptName}`;
}
private logError(message: string, ...args: any[]) {
console.error(`[PromptService] ${message}`, ...args);
}
private logInfo(message: string, ...args: any[]) {
console.log(`[PromptService] ${message}`, ...args);
}
private incrementMetric(name: Metrics, value: number = 1) {
try {
this.metricIncrementer?.(name, value);
} catch (e) {
this.logError("Error incrementing metric", name, e);
}
}
}
type PromptParams = {
projectId: string;
promptName: string;
} & (
| { version: number; label: undefined }
| { version: null | undefined; label: string }
);
enum Metrics {
PromptCacheHit = "prompt_cache_hit",
PromptCacheMiss = "prompt_cache_miss",
}
+45 -2
View File
@@ -1,6 +1,6 @@
import { type z } from "zod";
import { z } from "zod";
import lodash from "lodash";
import { jsonSchema } from "./zod";
import { JsonNested, jsonSchema, jsonSchemaNullable } from "./zod";
export const parseJson = (input: string) => {
try {
@@ -57,3 +57,46 @@ export const mergeJson = (
}
return lodash.merge(json1, json2);
};
export const parseJsonPrioritised = (
json: string
): z.infer<typeof jsonSchema> | string | undefined => {
try {
const parsedJson = JSON.parse(json);
if (Object.keys(parsedJson).length === 0) {
return undefined;
}
const arr = z.array(jsonSchemaNullable).safeParse(parsedJson);
if (arr.success) {
return arr.data;
}
const obj = z.record(jsonSchemaNullable).safeParse(parsedJson);
if (obj.success) {
return obj.data;
}
return jsonSchema.parse(parsedJson);
} catch (error) {
return jsonSchema.parse(json);
}
};
export const convertRecordToJsonSchema = (
record: Record<string, string>
): JsonNested | undefined => {
const jsonSchema: JsonNested = {};
// if record is empty, return undefined
if (Object.keys(record).length === 0) {
return undefined;
}
for (const key in record) {
try {
jsonSchema[key] = JSON.parse(record[key]);
} catch (e) {
jsonSchema[key] = record[key];
}
}
return jsonSchema;
};
+16
View File
@@ -0,0 +1,16 @@
type OmitKeys<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
/**
* Removes specified keys from an object and returns a new object without those keys.
*/
export function removeObjectKeys<T, K extends keyof T>(
obj: T,
keys: K[]
): OmitKeys<T, K> {
const result = { ...obj };
for (const key of keys) {
delete result[key];
}
return result;
}
+2 -2
View File
@@ -17,11 +17,11 @@ const nestedLiteralSchema = z.union([
type Root = z.infer<typeof rootLiteralSchema>;
type Literal = z.infer<typeof nestedLiteralSchema>;
type JsonNested = Literal | { [key: string]: JsonNested } | JsonNested[];
export type JsonNested = Literal | { [key: string]: JsonNested } | JsonNested[];
type Json = Root | { [key: string]: JsonNested } | JsonNested[];
// Here, you define the schema recursively
const jsonSchemaNullable: z.ZodType<JsonNested> = z.lazy(() =>
export const jsonSchemaNullable: z.ZodType<JsonNested> = z.lazy(() =>
z.union([
nestedLiteralSchema,
z.array(jsonSchemaNullable),
+1529 -1562
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -74,7 +74,7 @@ RUN apk add --no-cache dumb-init
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN npm install -g --no-package-lock --no-save prisma
RUN npm install -g --no-package-lock --no-save prisma@5.13.0
COPY --from=builder --chown=nextjs:nodejs /app/web/next.config.mjs .
COPY --from=builder --chown=nextjs:nodejs /app/web/package.json .
+6
View File
@@ -16,6 +16,8 @@ const serverTestConfig = {
displayName: "server",
testMatch: ["/**/*.servertest.[jt]s?(x)"],
testEnvironment: "jest-environment-node",
setupFilesAfterEnv: ["<rootDir>/src/__tests__/after-teardown.ts"],
globalTeardown: "<rootDir>/src/__tests__/teardown.ts",
};
// To avoid the "Cannot use import statement outside a module" errors while transforming ESM.
@@ -37,4 +39,8 @@ const config = {
],
};
process.env = Object.assign(process.env, {
LANGFUSE_CACHE_API_KEY_ENABLED: "true",
});
export default config;
+7 -6
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "2.65.0",
"version": "2.69.0",
"private": true,
"license": "MIT",
"engines": {
@@ -14,7 +14,7 @@
"prettier": "prettier --write ./src *.{ts,js}",
"clean": "rm -rf node_modules",
"start": "dotenv -e ../.env -- sh -c 'NEXT_MANUAL_SIG_HANDLE=true next start'",
"test": "dotenv -e ../.env -- jest --runInBand",
"test": "dotenv -e ../.env -- jest --runInBand --detectOpenHandles --verbose",
"test:watch": "dotenv -e ../.env -- jest --watch --runInBand",
"test:e2e": "dotenv -e ../.env -- playwright test",
"models:migrate": "dotenv -e ../.env -- tsx scripts/model-match.ts",
@@ -60,10 +60,9 @@
"@remixicon/react": "^4.2.0",
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@sentry/nextjs": "^8.13.0",
"@sentry/node": "^8.13.0",
"@sentry/profiling-node": "^8.13.0",
"@sentry/types": "^8.13.0",
"@sentry/nextjs": "8.13.0",
"@sentry/node": "8.13.0",
"@sentry/profiling-node": "8.13.0",
"@t3-oss/env-nextjs": "^0.10.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/react-query": "^4.36.1",
@@ -78,6 +77,7 @@
"@uiw/react-codemirror": "^4.21.25",
"ai": "^3.0.23",
"bcryptjs": "^2.4.3",
"bullmq": "^5.4.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
@@ -88,6 +88,7 @@
"dompurify": "^3.1.5",
"exponential-backoff": "^3.1.1",
"graphql": "^16.9.0",
"ioredis": "^5.4.1",
"ip-address": "^9.0.5",
"js-tiktoken": "^1.0.12",
"kysely": "^0.27.3",
+109 -4
View File
@@ -1007,6 +1007,13 @@ paths:
type: string
format: date-time
nullable: true
- name: version
in: query
description: Optional filter to only include observations with a certain version.
required: false
schema:
type: string
nullable: true
responses:
'200':
description: ''
@@ -1181,6 +1188,26 @@ paths:
schema:
type: integer
nullable: true
- name: fromUpdatedAt
in: query
description: >-
Optional filter to only include prompt versions created/updated on
or after a certain datetime (ISO 8601)
required: false
schema:
type: string
format: date-time
nullable: true
- name: toUpdatedAt
in: query
description: >-
Optional filter to only include prompt versions created/updated
before a certain datetime (ISO 8601)
required: false
schema:
type: string
format: date-time
nullable: true
responses:
'200':
description: ''
@@ -1675,7 +1702,7 @@ paths:
security: *ref_0
/api/public/sessions:
get:
description: Get sessions.
description: Get sessions
operationId: sessions_list
tags:
- Sessions
@@ -1846,7 +1873,7 @@ paths:
security: *ref_0
/api/public/traces:
get:
description: Get list of traces.
description: Get list of traces
operationId: trace_list
tags:
- Trace
@@ -1924,6 +1951,20 @@ paths:
items:
type: string
nullable: true
- name: version
in: query
description: Optional filter to only include traces with a certain version.
required: false
schema:
type: string
nullable: true
- name: release
in: query
description: Optional filter to only include traces with a certain release.
required: false
schema:
type: string
nullable: true
responses:
'200':
description: ''
@@ -1969,32 +2010,44 @@ components:
timestamp:
type: string
format: date-time
description: The timestamp when the trace was created
name:
type: string
nullable: true
description: The name of the trace
input:
nullable: true
description: The input data of the trace. Can be any JSON.
output:
nullable: true
description: The output data of the trace. Can be any JSON.
sessionId:
type: string
nullable: true
description: The session identifier associated with the trace
release:
type: string
nullable: true
description: The release version of the application when the trace was created
version:
type: string
nullable: true
description: The version of the trace
userId:
type: string
nullable: true
description: The user identifier associated with the trace
metadata:
nullable: true
description: The metadata associated with the trace. Can be any JSON.
tags:
type: array
items:
type: string
nullable: true
description: >-
The tags associated with the trace. Can be an array of strings or
null.
public:
type: boolean
nullable: true
@@ -2101,56 +2154,74 @@ components:
properties:
id:
type: string
description: The unique identifier of the observation
traceId:
type: string
nullable: true
description: The trace ID associated with the observation
type:
type: string
description: The type of the observation
name:
type: string
nullable: true
description: The name of the observation
startTime:
type: string
format: date-time
description: The start time of the observation
endTime:
type: string
format: date-time
nullable: true
description: The end time of the observation.
completionStartTime:
type: string
format: date-time
nullable: true
description: The completion start time of the observation
model:
type: string
nullable: true
description: The model used for the observation
modelParameters:
type: object
additionalProperties:
$ref: '#/components/schemas/MapValue'
nullable: true
description: The parameters of the model used for the observation
input:
nullable: true
description: The input data of the observation
version:
type: string
nullable: true
description: The version of the observation
metadata:
nullable: true
description: Additional metadata of the observation
output:
nullable: true
description: The output data of the observation
usage:
$ref: '#/components/schemas/Usage'
nullable: true
description: The usage data of the observation
level:
$ref: '#/components/schemas/ObservationLevel'
description: The level of the observation
statusMessage:
type: string
nullable: true
description: The status message of the observation
parentObservationId:
type: string
nullable: true
description: The parent observation ID
promptId:
type: string
nullable: true
description: The prompt ID associated with the observation
required:
- id
- type
@@ -2160,41 +2231,58 @@ components:
title: ObservationsView
type: object
properties:
promptName:
type: string
nullable: true
description: The name of the prompt associated with the observation
promptVersion:
type: integer
nullable: true
description: The version of the prompt associated with the observation
modelId:
type: string
nullable: true
description: The unique identifier of the model
inputPrice:
type: number
format: double
nullable: true
description: The price of the input in USD
outputPrice:
type: number
format: double
nullable: true
description: The price of the output in USD.
totalPrice:
type: number
format: double
nullable: true
description: The total price in USD.
calculatedInputCost:
type: number
format: double
nullable: true
description: The calculated cost of the input in USD
calculatedOutputCost:
type: number
format: double
nullable: true
description: The calculated cost of the output in USD
calculatedTotalCost:
type: number
format: double
nullable: true
description: The calculated total cost in USD
latency:
type: number
format: double
nullable: true
description: The latency in seconds.
timeToFirstToken:
type: number
format: double
nullable: true
description: The time to the first token in seconds
allOf:
- $ref: '#/components/schemas/Observation'
Usage:
@@ -2540,23 +2628,31 @@ components:
properties:
id:
type: string
description: Unique identifier of the dataset run
name:
type: string
description: Name of the dataset run
description:
type: string
nullable: true
description: Description of the run
metadata:
nullable: true
description: Metadata of the dataset run
datasetId:
type: string
description: Id of the associated dataset
datasetName:
type: string
description: Name of the associated dataset
createdAt:
type: string
format: date-time
description: The date and time when the dataset run was created
updatedAt:
type: string
format: date-time
description: The date and time when the dataset run was last updated
required:
- id
- name
@@ -2715,8 +2811,8 @@ components:
type: string
nullable: true
description: >-
Dataset items are upserted on their id. Id needs to be globally
unique and cannot be reused across datasets.
Dataset items are upserted on their id. Id needs to be unique
(project-level) and cannot be reused across datasets.
status:
$ref: '#/components/schemas/DatasetStatus'
nullable: true
@@ -3611,11 +3707,20 @@ components:
type: array
items:
type: string
lastUpdatedAt:
type: string
format: date-time
lastConfig:
description: >-
Config object of the most recent prompt version that matches the
filters (if any are provided)
required:
- name
- versions
- labels
- tags
- lastUpdatedAt
- lastConfig
CreatePromptRequest:
title: CreatePromptRequest
oneOf:
+30 -5
View File
@@ -718,7 +718,7 @@
"request": {
"description": "Get a list of observations",
"url": {
"raw": "{{baseUrl}}/api/public/observations?page=&limit=&name=&userId=&type=&traceId=&parentObservationId=&fromStartTime=&toStartTime=",
"raw": "{{baseUrl}}/api/public/observations?page=&limit=&name=&userId=&type=&traceId=&parentObservationId=&fromStartTime=&toStartTime=&version=",
"host": [
"{{baseUrl}}"
],
@@ -772,6 +772,11 @@
"key": "toStartTime",
"value": "",
"description": "Retrieve only observations with a start_time before this datetime (ISO 8601)."
},
{
"key": "version",
"value": "",
"description": "Optional filter to only include observations with a certain version."
}
],
"variable": []
@@ -872,7 +877,7 @@
"request": {
"description": "Get a list of prompt names with versions and labels",
"url": {
"raw": "{{baseUrl}}/api/public/v2/prompts?name=&label=&tag=&page=&limit=",
"raw": "{{baseUrl}}/api/public/v2/prompts?name=&label=&tag=&page=&limit=&fromUpdatedAt=&toUpdatedAt=",
"host": [
"{{baseUrl}}"
],
@@ -907,6 +912,16 @@
"key": "limit",
"value": "",
"description": "limit of items per page"
},
{
"key": "fromUpdatedAt",
"value": "",
"description": "Optional filter to only include prompt versions created/updated on or after a certain datetime (ISO 8601)"
},
{
"key": "toUpdatedAt",
"value": "",
"description": "Optional filter to only include prompt versions created/updated before a certain datetime (ISO 8601)"
}
],
"variable": []
@@ -1261,7 +1276,7 @@
"_type": "endpoint",
"name": "List",
"request": {
"description": "Get sessions.",
"description": "Get sessions",
"url": {
"raw": "{{baseUrl}}/api/public/sessions?page=&limit=&fromTimestamp=&toTimestamp=",
"host": [
@@ -1378,9 +1393,9 @@
"_type": "endpoint",
"name": "List",
"request": {
"description": "Get list of traces.",
"description": "Get list of traces",
"url": {
"raw": "{{baseUrl}}/api/public/traces?page=&limit=&userId=&name=&sessionId=&fromTimestamp=&toTimestamp=&orderBy=&tags=",
"raw": "{{baseUrl}}/api/public/traces?page=&limit=&userId=&name=&sessionId=&fromTimestamp=&toTimestamp=&orderBy=&tags=&version=&release=",
"host": [
"{{baseUrl}}"
],
@@ -1434,6 +1449,16 @@
"key": "tags",
"value": "",
"description": "Only traces that include all of these tags will be returned."
},
{
"key": "version",
"value": "",
"description": "Optional filter to only include traces with a certain version."
},
{
"key": "release",
"value": "",
"description": "Optional filter to only include traces with a certain release."
}
],
"variable": []
+2 -2
View File
@@ -5,10 +5,10 @@
import "dotenv/config";
import { findModel } from "@/src/server/api/services/EventProcessor";
import { findModel } from "@langfuse/shared/src/server";
import { prisma } from "@langfuse/shared/src/db";
import lodash from "lodash";
import { tokenCount } from "@/src/features/ingest/lib/usage";
import { tokenCount } from "@/src/features/ingest/usage";
import { type Prisma } from "@langfuse/shared/src/db";
async function main() {
+5
View File
@@ -0,0 +1,5 @@
import teardown from "@/src/__tests__/teardown";
afterAll(async () => {
await teardown();
});
@@ -0,0 +1,189 @@
import { aggregateScores } from "@/src/features/scores/lib/aggregateScores";
import { type APIScore } from "@/src/features/public-api/types/scores";
describe("aggregateScores", () => {
it("should return an empty object for an empty array", () => {
const scores: APIScore[] = [];
expect(aggregateScores(scores)).toEqual({});
});
it("should correctly aggregate a single numeric score", () => {
const scores = [
{
name: "test",
source: "API",
dataType: "NUMERIC",
value: 5,
comment: "test comment",
},
] as APIScore[];
expect(aggregateScores(scores)).toEqual({
"test-API-NUMERIC": {
type: "NUMERIC",
values: [5],
average: 5,
comment: "test comment",
},
});
});
it("should correctly aggregate multiple numeric scores with the same key", () => {
const scores = [
{
name: "test",
source: "API",
dataType: "NUMERIC",
value: 5,
comment: "test comment",
},
{
name: "test",
source: "API",
dataType: "NUMERIC",
value: 7,
comment: "another comment",
},
] as APIScore[];
expect(aggregateScores(scores)).toEqual({
"test-API-NUMERIC": {
type: "NUMERIC",
values: [5, 7],
average: 6,
comment: undefined,
},
});
});
it("should correctly aggregate scores with different keys", () => {
const scores: APIScore[] = [
{
name: "test1",
source: "API",
dataType: "NUMERIC",
value: 5,
comment: "test comment",
},
{
name: "test1",
source: "ANNOTATION",
dataType: "NUMERIC",
value: 7,
comment: "another comment",
},
] as APIScore[];
expect(aggregateScores(scores)).toEqual({
"test1-API-NUMERIC": {
type: "NUMERIC",
values: [5],
average: 5,
comment: "test comment",
},
"test1-ANNOTATION-NUMERIC": {
type: "NUMERIC",
values: [7],
average: 7,
comment: "another comment",
},
});
});
it("should correctly aggregate a single Categorical score", () => {
const scores = [
{
name: "test",
source: "ANNOTATION",
dataType: "CATEGORICAL",
stringValue: "good",
comment: "test comment",
},
] as APIScore[];
expect(aggregateScores(scores)).toEqual({
"test-ANNOTATION-CATEGORICAL": {
type: "CATEGORICAL",
values: ["good"],
valueCounts: [{ value: "good", count: 1 }],
comment: "test comment",
},
});
});
it("should correctly aggregate multiple Categorical scores with the same key", () => {
const scores = [
{
name: "test",
source: "API",
dataType: "BOOLEAN",
stringValue: "True",
comment: "test comment",
},
{
name: "test",
source: "API",
dataType: "BOOLEAN",
stringValue: "False",
comment: "another comment",
},
] as APIScore[];
expect(aggregateScores(scores)).toEqual({
"test-API-BOOLEAN": {
type: "CATEGORICAL",
values: ["True", "False"],
valueCounts: [
{ value: "True", count: 1 },
{ value: "False", count: 1 },
],
comment: undefined,
},
});
});
it("should correctly aggregate scores with mixed types and the same name", () => {
const scores = [
{
name: "test",
source: "API",
dataType: "NUMERIC",
value: 5,
comment: "test comment",
},
{
name: "test",
source: "ANNOTATION",
dataType: "CATEGORICAL",
stringValue: "good",
comment: "another comment",
},
{
name: "test",
source: "ANNOTATION",
dataType: "CATEGORICAL",
stringValue: "bad",
comment: "last comment",
},
{
name: "test",
source: "ANNOTATION",
dataType: "CATEGORICAL",
stringValue: "good",
comment: "last comment",
},
] as APIScore[];
expect(aggregateScores(scores)).toEqual({
"test-API-NUMERIC": {
type: "NUMERIC",
values: [5],
average: 5,
comment: "test comment",
},
"test-ANNOTATION-CATEGORICAL": {
type: "CATEGORICAL",
values: ["good", "bad", "good"],
valueCounts: [
{ value: "good", count: 2 },
{ value: "bad", count: 1 },
],
comment: undefined,
},
});
});
});
+371 -60
View File
@@ -2,87 +2,398 @@ import {
getDisplaySecretKey,
hashSecretKey,
} from "@langfuse/shared/src/server";
import { verifyAuthHeaderAndReturnScope } from "@/src/features/public-api/server/apiAuth";
import { prisma } from "@langfuse/shared/src/db";
import {
ApiAuthService,
ApiKeyZod,
} from "@/src/features/public-api/server/apiAuth";
import { type PrismaClient, prisma } from "@langfuse/shared/src/db";
import { Redis } from "ioredis";
import { env } from "@/src/env.mjs";
describe("Validate api calls", () => {
describe("Authenticate API calls", () => {
beforeEach(async () => {
await prisma.score.deleteMany();
await prisma.observation.deleteMany();
await prisma.trace.deleteMany();
await prisma.apiKey.deleteMany();
});
it("should create new api key", async () => {
await createAPIKey();
const auth = await verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(auth.validKey).toBe(true);
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
});
expect(apiKey).not.toBeNull();
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
afterEach(async () => {
await prisma.score.deleteMany();
await prisma.observation.deleteMany();
await prisma.trace.deleteMany();
await prisma.apiKey.deleteMany();
});
it("should create new api key and succeed with new key", async () => {
await createAPIKey();
const auth = await verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(auth.validKey).toBe(true);
describe("validates without redis", () => {
it("should create new api key", async () => {
await createAPIKey();
const auth = await new ApiAuthService(
prisma,
null,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
expect(auth.validKey).toBe(true);
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
});
expect(apiKey).not.toBeNull();
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
});
expect(apiKey).not.toBeNull();
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
const auth2 = await verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(auth2.validKey).toBe(true);
it("should create new api key and succeed with new key", async () => {
await createAPIKey();
const auth = await new ApiAuthService(
prisma,
null,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(auth.validKey).toBe(true);
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
});
expect(apiKey).not.toBeNull();
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
const auth2 = await new ApiAuthService(
prisma,
null,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(auth2.validKey).toBe(true);
});
it("should fail on wrong api key with new key", async () => {
await createAPIKey();
const auth = await new ApiAuthService(
prisma,
null,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(auth.validKey).toBe(true);
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
});
expect(apiKey).not.toBeNull();
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
const wrongAuth = await new ApiAuthService(
prisma,
null,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkx",
);
expect(wrongAuth.validKey).toBe(false);
});
it("should fail on wrong api key without new key", async () => {
await createAPIKey();
const initialApiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
});
expect(initialApiKey).not.toBeNull();
expect(initialApiKey?.fastHashedSecretKey).toBeNull();
const auth = await new ApiAuthService(
prisma,
null,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkx",
);
expect(auth.validKey).toBe(false);
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
});
expect(apiKey).not.toBeNull();
expect(apiKey?.fastHashedSecretKey).toBeNull();
});
});
it("should fail on wrong api key with new key", async () => {
await createAPIKey();
const auth = await verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(auth.validKey).toBe(true);
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
describe("validates with redis", () => {
const redis = new Redis("redis://:myredissecret@127.0.0.1:6379", {
maxRetriesPerRequest: null,
});
expect(apiKey).not.toBeNull();
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
const wrongAuth = await verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkx",
);
expect(wrongAuth.validKey).toBe(false);
});
beforeEach(async () => {
// if we do not remove the key, it will remain in the cache and
// calling the test twice will not add the key to the cache
it("should fail on wrong api key without new key", async () => {
await createAPIKey();
const initialApiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
const keys = await redis.keys("api-key*");
if (keys.length > 0) {
await redis.del(keys);
}
});
expect(initialApiKey).not.toBeNull();
expect(initialApiKey?.fastHashedSecretKey).toBeNull();
const auth = await verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkx",
);
expect(auth.validKey).toBe(false);
afterEach(async () => {
// if we do not remove the key, it will remain in the cache and
// calling the test twice will not add the key to the cache
const keys = await redis.keys("api-key*");
if (keys.length > 0) {
await redis.del(keys);
}
});
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
afterAll(async () => {
redis.disconnect();
});
it("should create new api key and read from cache", async () => {
await createAPIKey();
// first auth will generate the fast hashed api key
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
});
expect(apiKey).not.toBeNull();
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
const cachedKey = await redis.get(
`api-key:${apiKey?.fastHashedSecretKey}`,
);
expect(cachedKey).toBeNull();
// second will add the key to redis
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
const cachedKey2 = await redis.get(
`api-key:${apiKey?.fastHashedSecretKey}`,
);
expect(cachedKey2).not.toBeNull();
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey2!));
expect(parsed).toEqual({
...apiKey,
createdAt: apiKey?.createdAt.toISOString(),
});
});
it("searching for non-existing key stores flag in redis and fails auth", async () => {
// key does not exist in database
const verification = await new ApiAuthService(
prisma,
redis,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(verification.validKey).toBe(false);
const redisKeys = await redis.keys(`api-key:*`);
expect(redisKeys.length).toBe(1);
const redisValue = await redis.get(redisKeys[0]);
expect(redisValue).toBe('"api-key-non-existent"');
});
it("searching for non-existing key again fails auth", async () => {
// key does not exist in database
const verification = await new ApiAuthService(
prisma,
redis,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(verification.validKey).toBe(false);
const redisKeys = await redis.keys(`api-key:*`);
expect(redisKeys.length).toBe(1);
const redisValue = await redis.get(redisKeys[0]);
expect(redisValue).toBe('"api-key-non-existent"');
const verification2 = await new ApiAuthService(
prisma,
redis,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(verification2.validKey).toBe(false);
const redisKeys2 = await redis.keys(`api-key:*`);
expect(redisKeys2.length).toBe(1);
const redisValue2 = await redis.get(redisKeys[0]);
expect(redisValue2).toBe('"api-key-non-existent"');
});
it("prisma should not be used when reading cached keys", async () => {
await createAPIKey();
// Mock prisma
const mockPrisma = {
apiKey: {
findUnique: jest.fn(),
},
};
// first auth will generate the fast hashed api key
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
// second will add the key to redis
const auth2 = await new ApiAuthService(
prisma,
redis,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
// third will read from redis only
await new ApiAuthService(
mockPrisma as unknown as PrismaClient,
redis,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(auth2.validKey).toBe(true);
// Ensure prisma was not called
expect(mockPrisma.apiKey.findUnique).not.toHaveBeenCalled();
const cachedKey = await redis.get(
"api-key:ed6818ada09bdad405a74ac72773dde1708dd3fc6fe8bb81b59927400419d227",
);
expect(cachedKey).not.toBeNull();
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey!));
expect(parsed).toEqual({
id: expect.any(String),
note: "seeded key",
publicKey: "pk-lf-1234567890",
hashedSecretKey: expect.any(String),
fastHashedSecretKey: expect.any(String),
displaySecretKey: expect.any(String),
createdAt: expect.any(String),
lastUsedAt: null,
expiresAt: null,
projectId: expect.any(String),
});
});
it("ttl should be increased when reading from redis", async () => {
await createAPIKey();
// Mock prisma
const mockPrisma = {
apiKey: {
findUnique: jest.fn(),
},
};
// first auth will generate the fast hashed api key
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
// second will add the key to redis
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
// third will read from redis only
await new ApiAuthService(
mockPrisma as unknown as PrismaClient,
redis,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
const ttl = await redis.ttl(
"api-key:ed6818ada09bdad405a74ac72773dde1708dd3fc6fe8bb81b59927400419d227",
);
expect(ttl).toBeGreaterThan(env.LANGFUSE_CACHE_API_KEY_TTL_SECONDS - 2);
// wait for 5 seconds
await new Promise((resolve) => setTimeout(resolve, 5000));
await new ApiAuthService(
mockPrisma as unknown as PrismaClient,
redis,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
const ttl2 = await redis.ttl(
"api-key:ed6818ada09bdad405a74ac72773dde1708dd3fc6fe8bb81b59927400419d227",
);
expect(ttl2).toBeGreaterThan(env.LANGFUSE_CACHE_API_KEY_TTL_SECONDS - 2);
}, 10000);
it("should delete API keys from cache and db", async () => {
await createAPIKey();
// first auth will generate the fast hashed api key
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
// second will add the key to redis
const auth2 = await new ApiAuthService(
prisma,
redis,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(auth2.validKey).toBe(true);
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
});
expect(apiKey).not.toBeNull();
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
const cachedKey = await redis.get(
`api-key:${apiKey?.fastHashedSecretKey}`,
);
expect(cachedKey).not.toBeNull();
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey!));
expect(parsed).toEqual({
...apiKey,
createdAt: apiKey?.createdAt.toISOString(),
});
await new ApiAuthService(prisma, redis).deleteApiKey(
apiKey?.id!,
apiKey?.projectId!,
);
const deletedApiKey = await prisma.apiKey.findUnique({
where: { id: apiKey?.id! },
});
expect(deletedApiKey).toBeNull();
const deletedCachedKey = await redis.get(
`api-key:${apiKey?.fastHashedSecretKey}`,
);
expect(deletedCachedKey).toBeNull();
});
expect(apiKey).not.toBeNull();
expect(apiKey?.fastHashedSecretKey).toBeNull();
});
const createAPIKey = async () => {
+106 -13
View File
@@ -21,6 +21,7 @@ import {
PostDatasetsV1Response,
PostDatasetsV2Response,
} from "@/src/features/public-api/types/datasets";
import { v4 as uuidv4 } from "uuid";
describe("/api/public/datasets and /api/public/dataset-items API Endpoints", () => {
const traceId = v4();
@@ -295,12 +296,15 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
},
});
expect(dbDatasetItems.length).toBe(5);
const dbDatasetItemsApiResponseFormat = dbDatasetItems.map((item) => ({
...item,
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
datasetName: "dataset-name",
}));
const dbDatasetItemsApiResponseFormat = dbDatasetItems.map(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
({ projectId, ...item }) => ({
...item,
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
datasetName: "dataset-name",
}),
);
// add another dataset to test the list endpoint
await makeZodVerifiedAPICall(
@@ -333,7 +337,8 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
});
expect(dbDatasetItemsOther.length).toBe(1);
const dbDatasetItemsOtherApiResponseFormat = dbDatasetItemsOther.map(
(item) => ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
({ projectId, ...item }) => ({
...item,
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
@@ -784,12 +789,15 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
},
});
expect(dbRuns.length).toBe(3);
const dbRunsApiResponseFormat = dbRuns.map((run) => ({
...run,
createdAt: run.createdAt.toISOString(),
updatedAt: run.updatedAt.toISOString(),
datasetName: "dataset-name",
}));
const dbRunsApiResponseFormat = dbRuns.map(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
({ projectId, ...run }) => ({
...run,
createdAt: run.createdAt.toISOString(),
updatedAt: run.updatedAt.toISOString(),
datasetName: "dataset-name",
}),
);
// test get runs
const getRuns = await makeZodVerifiedAPICall(
@@ -835,4 +843,89 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
);
expect(response.status).toBe(400);
});
it("dataset item ids should be reusable across projects", async () => {
const otherProject = await prisma.project.create({
data: {
name: "other-project",
},
});
// dataset ids are always generated
const datasetBody = {
name: "dataset-name",
};
// dataset, id is generated
const apiDataset = await makeZodVerifiedAPICall(
PostDatasetsV1Response,
"POST",
"/api/public/datasets",
{ ...datasetBody, metadata: "api-dataset" },
);
const otherProjDbDataset = await prisma.dataset.create({
data: {
...datasetBody,
projectId: otherProject.id,
id: apiDataset.body.id, // use the same id, not possible via api, done to check security of this
},
});
const getApiDataset = await makeZodVerifiedAPICall(
GetDatasetV1Response,
"GET",
`/api/public/datasets/${encodeURIComponent(datasetBody.name)}`,
);
expect(getApiDataset.body.metadata).toBe("api-dataset");
// item ids can be set by the user
const datasetItemBody = {
input: "item-input",
id: uuidv4(),
};
await prisma.datasetItem.create({
data: {
...datasetItemBody,
expectedOutput: "other-proj",
projectId: otherProject.id,
datasetId: otherProjDbDataset.id,
},
});
// dataset item, id is set
await makeZodVerifiedAPICall(
PostDatasetItemsV1Response,
"POST",
"/api/public/dataset-items",
{
...datasetItemBody,
expectedOutput: "api-item",
datasetName: datasetBody.name,
metadata: "api-item",
},
);
const getApiDatasetItem = await makeZodVerifiedAPICall(
GetDatasetItemV1Response,
"GET",
`/api/public/dataset-items/${datasetItemBody.id}`,
);
expect(getApiDatasetItem.body.metadata).toBe("api-item");
const dbItems = await prisma.datasetItem.findMany({
where: { id: datasetItemBody.id },
});
expect(dbItems.length).toBe(2);
expect(dbItems).toHaveLength(2);
expect(dbItems).toEqual(
expect.arrayContaining([
expect.objectContaining({
metadata: "api-item",
projectId: apiDataset.body.projectId,
id: datasetItemBody.id,
}),
expect.objectContaining({
metadata: null,
projectId: otherProject.id,
id: datasetItemBody.id,
}),
]),
);
});
});
@@ -110,6 +110,10 @@ describe("/api/public/observations API Endpoint", () => {
fetchedObservations.body.data[0]?.calculatedTotalCost,
).toBeGreaterThan(0);
expect(fetchedObservations.body.data[0]?.promptId).toBe(prompt.id);
expect(fetchedObservations.body.data[0]?.promptName).toBe(prompt.name);
expect(fetchedObservations.body.data[0]?.promptVersion).toBe(
prompt.version,
);
});
it("should fetch all observations, filtered by generations", async () => {
await pruneDatabase();
+311
View File
@@ -0,0 +1,311 @@
import { type PrismaClient, type Prompt } from "@prisma/client";
import { PromptService, type redis } from "@langfuse/shared/src/server"; // Adjust the import path as needed
type Redis = NonNullable<typeof redis>;
describe("PromptService", () => {
let promptService: PromptService;
let mockPrisma: jest.Mocked<PrismaClient>;
let mockRedis: jest.Mocked<Redis>;
let mockMetricIncrementer: jest.Mock;
const mockPrompt: Omit<Prompt, "updatedAt" | "createdAt"> = {
id: "1",
projectId: "project1",
name: "testPrompt",
version: 1,
prompt: "Test prompt content",
labels: ["test"],
createdBy: "API",
type: "text",
isActive: null,
config: {},
tags: [],
};
beforeEach(() => {
mockPrisma = {
prompt: {
findFirst: jest.fn(),
},
} as unknown as jest.Mocked<PrismaClient>;
mockRedis = {
getex: jest.fn(),
set: jest.fn(),
setex: jest.fn(),
del: jest.fn(),
exists: jest.fn(),
eval: jest.fn(),
sadd: jest.fn(),
smembers: jest.fn(),
} as unknown as jest.Mocked<Redis>;
mockMetricIncrementer = jest.fn();
promptService = new PromptService(
mockPrisma,
mockRedis,
mockMetricIncrementer,
true,
);
});
describe("getPrompt", () => {
it("should return cached prompt if available", async () => {
mockRedis.exists.mockResolvedValue(0);
mockRedis.getex.mockResolvedValue(JSON.stringify(mockPrompt));
const result = await promptService.getPrompt({
projectId: "project1",
promptName: "testPrompt",
version: 1,
label: undefined,
});
expect(result).toEqual(mockPrompt);
expect(mockMetricIncrementer).toHaveBeenCalledWith("prompt_cache_hit", 1);
});
it("should fetch from database if not in cache", async () => {
mockRedis.exists.mockResolvedValue(0);
mockRedis.getex.mockResolvedValue(null);
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
const result = await promptService.getPrompt({
projectId: "project1",
promptName: "testPrompt",
version: 1,
label: undefined,
});
expect(result).toEqual(mockPrompt);
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
expect(mockMetricIncrementer).toHaveBeenCalledWith(
"prompt_cache_miss",
1,
);
expect(mockRedis.set).toHaveBeenCalledWith(
"prompt:project1:testPrompt:1",
JSON.stringify(mockPrompt),
"EX",
3600,
);
expect(mockRedis.sadd).toHaveBeenCalledWith(
"prompt_key_index:project1:testPrompt",
"prompt:project1:testPrompt:1",
);
});
it("should not use cache if locked", async () => {
mockRedis.exists.mockResolvedValue(1);
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
const result = await promptService.getPrompt({
projectId: "project1",
promptName: "testPrompt",
version: 1,
label: undefined,
});
expect(result).toEqual(mockPrompt);
expect(mockRedis.getex).not.toHaveBeenCalled();
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
});
});
describe("lockCache", () => {
it("should set a lock in Redis", async () => {
await promptService.lockCache({
projectId: "project1",
promptName: "testPrompt",
});
expect(mockRedis.setex).toHaveBeenCalledWith(
"LOCK:prompt:project1:testPrompt",
30,
"locked",
);
});
});
describe("unlockCache", () => {
it("should remove the lock from Redis", async () => {
await promptService.unlockCache({
projectId: "project1",
promptName: "testPrompt",
});
expect(mockRedis.del).toHaveBeenCalledWith(
"LOCK:prompt:project1:testPrompt",
);
});
});
describe("invalidateCache", () => {
it("should call deleteKeysByPrefix with correct prefix", async () => {
await promptService.invalidateCache({
projectId: "project1",
promptName: "testPrompt",
});
expect(mockRedis.smembers).toHaveBeenCalledWith(
"prompt_key_index:project1:testPrompt",
);
});
});
describe("caching disabled", () => {
beforeEach(() => {
promptService = new PromptService(
mockPrisma,
mockRedis,
mockMetricIncrementer,
false,
);
});
it("should not use cache when disabled", async () => {
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
const result = await promptService.getPrompt({
projectId: "project1",
promptName: "testPrompt",
version: 1,
label: undefined,
});
expect(result).toEqual(mockPrompt);
expect(mockRedis.getex).not.toHaveBeenCalled();
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
expect(mockMetricIncrementer).not.toHaveBeenCalled();
});
});
describe("null Redis instance", () => {
beforeEach(() => {
promptService = new PromptService(
mockPrisma,
null,
mockMetricIncrementer,
true,
);
});
it("should not use cache with null Redis instance", async () => {
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
const result = await promptService.getPrompt({
projectId: "project1",
promptName: "testPrompt",
version: 1,
label: undefined,
});
expect(result).toEqual(mockPrompt);
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
expect(mockMetricIncrementer).not.toHaveBeenCalled();
});
});
describe("getPrompt with Redis errors", () => {
it("should fallback to database if Redis.exists throws an error", async () => {
mockRedis.exists.mockRejectedValue(new Error("Redis error"));
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
const result = await promptService.getPrompt({
projectId: "project1",
promptName: "testPrompt",
version: 1,
label: undefined,
});
expect(result).toEqual(mockPrompt);
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
expect(mockMetricIncrementer).toHaveBeenCalledWith(
"prompt_cache_miss",
1,
);
});
it("should fallback to database if Redis.getex throws an error", async () => {
mockRedis.exists.mockResolvedValue(0);
mockRedis.getex.mockRejectedValue(new Error("Redis error"));
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
const result = await promptService.getPrompt({
projectId: "project1",
promptName: "testPrompt",
version: 1,
label: undefined,
});
expect(result).toEqual(mockPrompt);
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
expect(mockMetricIncrementer).toHaveBeenCalledWith(
"prompt_cache_miss",
1,
);
});
it("should not cache if Redis.set throws an error after database fetch", async () => {
mockRedis.exists.mockResolvedValue(0);
mockRedis.getex.mockResolvedValue(null);
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
mockRedis.set.mockRejectedValue(new Error("Redis error"));
const result = await promptService.getPrompt({
projectId: "project1",
promptName: "testPrompt",
version: 1,
label: undefined,
});
expect(result).toEqual(mockPrompt);
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
expect(mockMetricIncrementer).toHaveBeenCalledWith(
"prompt_cache_miss",
1,
);
});
});
describe("lockCache with Redis errors", () => {
it("should throw an error if Redis.setex fails", async () => {
mockRedis.setex.mockRejectedValue(new Error("Redis error"));
await expect(
promptService.lockCache({
projectId: "project1",
promptName: "testPrompt",
}),
).rejects.toThrow("Redis error");
});
});
describe("unlockCache with Redis errors", () => {
it("should log error but not throw if Redis.del fails", async () => {
mockRedis.del.mockRejectedValue(new Error("Redis error"));
await promptService.unlockCache({
projectId: "project1",
promptName: "testPrompt",
});
});
});
describe("invalidateCache with Redis errors", () => {
it("should throw an error if Redis.eval fails", async () => {
mockRedis.smembers.mockRejectedValue(new Error("Redis error"));
await expect(
promptService.invalidateCache({
projectId: "project1",
promptName: "testPrompt",
}),
).rejects.toThrow("Redis error");
});
});
});
+129 -8
View File
@@ -23,11 +23,13 @@ type CreatePromptInDBParams = {
prompt: string;
labels: string[];
version: number;
config: Record<string, object | number | string>;
config: any;
projectId: string;
createdBy: string;
type?: PromptType;
tags?: string[];
createdAt?: Date;
updatedAt?: Date;
};
const createPromptInDB = async (params: CreatePromptInDBParams) => {
return await prisma.prompt.create({
@@ -44,6 +46,8 @@ const createPromptInDB = async (params: CreatePromptInDBParams) => {
createdBy: params.createdBy,
type: params.type,
tags: params.tags,
createdAt: params.createdAt,
updatedAt: params.updatedAt,
},
});
};
@@ -75,7 +79,7 @@ describe("/api/public/v2/prompts API Endpoint", () => {
const projectId = uuidv4();
const response = await makeAPICall(
"GET",
`/api/public/v2/prompts`,
baseURI,
undefined,
`Bearer ${projectId}`,
);
@@ -833,20 +837,23 @@ describe("/api/public/v2/prompts API Endpoint", () => {
// Validate prompt-1 meta
expect(promptMeta1.name).toBe("prompt-1");
expect(promptMeta1.versions).toEqual([1, 2, 4]);
expect(promptMeta1.labels).toEqual(["production"]);
expect(promptMeta1.labels).toEqual(["production", "version2"]);
expect(promptMeta1.tags).toEqual([]);
expect(promptMeta1.lastUpdatedAt).toBeDefined();
// Validate prompt-2 meta
expect(promptMeta2.name).toBe("prompt-2");
expect(promptMeta2.versions).toEqual([1, 2, 3]);
expect(promptMeta2.labels).toEqual(["dev", "production", "staging"]);
expect(promptMeta2.tags).toEqual([]);
expect(promptMeta2.lastUpdatedAt).toBeDefined();
// Validate prompt-3 meta
expect(promptMeta3.name).toBe("prompt-3");
expect(promptMeta3.versions).toEqual([1]);
expect(promptMeta3.labels).toEqual(["production"]);
expect(promptMeta3.tags).toEqual(["tag-1"]);
expect(promptMeta3.lastUpdatedAt).toBeDefined();
// Validate pagination
expect(body.meta.page).toBe(1);
@@ -870,7 +877,7 @@ describe("/api/public/v2/prompts API Endpoint", () => {
expect(body.data).toHaveLength(1);
expect(body.data[0].name).toBe("prompt-1");
expect(body.data[0].versions).toEqual([1, 2, 4]);
expect(body.data[0].labels).toEqual(["production"]);
expect(body.data[0].labels).toEqual(["production", "version2"]);
expect(body.data[0].tags).toEqual([]);
// Validate pagination
@@ -991,6 +998,112 @@ describe("/api/public/v2/prompts API Endpoint", () => {
expect(body.meta.totalPages).toBe(3);
expect(body.meta.totalItems).toBe(3);
});
it("should fetch lastConfig correctly for a prompt with multiple versions", async () => {
// no filters
const response = await makeAPICall("GET", `${baseURI}`);
expect(response.status).toBe(200);
const body = response.body as unknown as PromptsMetaResponse;
expect(body.data).toHaveLength(3);
expect(body.data.some((promptMeta) => promptMeta.name === "prompt-1")).toBe(
true,
);
expect(body.data.some((promptMeta) => promptMeta.name === "prompt-2")).toBe(
true,
);
expect(body.data.some((promptMeta) => promptMeta.name === "prompt-3")).toBe(
true,
);
const prompt1 = body.data.find(
(promptMeta) => promptMeta.name === "prompt-1",
);
expect(prompt1).toBeDefined();
expect(prompt1?.lastConfig).toEqual({ version: 4 });
const prompt2 = body.data.find(
(promptMeta) => promptMeta.name === "prompt-2",
);
expect(prompt2).toBeDefined();
expect(prompt2?.lastConfig).toEqual({});
// validate with label filter
const response2 = await makeAPICall("GET", `${baseURI}?label=version2`);
expect(response2.status).toBe(200);
const body2 = response2.body as unknown as PromptsMetaResponse;
expect(body2.data).toHaveLength(1);
expect(body2.data[0].name).toBe("prompt-1");
expect(body2.data[0].lastConfig).toEqual({ version: 2 });
// validate with version filter
const response3 = await makeAPICall("GET", `${baseURI}?version=1`);
expect(response3.status).toBe(200);
const body3 = response3.body as unknown as PromptsMetaResponse;
expect(body3.data).toHaveLength(3);
const prompt1v1 = body3.data.find(
(promptMeta) => promptMeta.name === "prompt-1",
);
expect(prompt1v1?.lastConfig).toEqual({ version: 1 });
});
it("should respect the fromUpdatedAt and toUpdatedAt filters on GET /prompts", async () => {
// to and from
const from = new Date("2024-01-02T00:00:00.000Z");
const to = new Date("2024-01-04T00:00:00.000Z");
const response = await makeAPICall(
"GET",
`${baseURI}?fromUpdatedAt=${from.toISOString()}&toUpdatedAt=${to.toISOString()}`,
);
expect(response.status).toBe(200);
const body = response.body as unknown as PromptsMetaResponse;
expect(body.data).toHaveLength(1);
expect(body.data[0].name).toBe("prompt-1");
expect(body.data[0].lastUpdatedAt).toBe("2024-01-02T00:00:00.000Z");
expect(body.data[0].versions.length).toBe(1);
expect(body.meta.totalItems).toBe(1);
// only from
const response2 = await makeAPICall(
"GET",
`${baseURI}?fromUpdatedAt=${from.toISOString()}`,
);
expect(response2.status).toBe(200);
const body2 = response2.body as unknown as PromptsMetaResponse;
expect(body2.data).toHaveLength(1);
expect(body2.data[0].name).toBe("prompt-1");
expect(body2.data[0].lastUpdatedAt).toBe("2024-01-04T00:00:00.000Z");
expect(body2.data[0].versions.length).toBe(2);
expect(body2.meta.totalItems).toBe(1);
// only to
const response3 = await makeAPICall(
"GET",
`${baseURI}?toUpdatedAt=${to.toISOString()}`,
);
expect(response3.status).toBe(200);
const body3 = response3.body as unknown as PromptsMetaResponse;
expect(body3.data).toHaveLength(3);
expect(body3.data[0].name).toBe("prompt-1");
expect(body3.data[0].lastUpdatedAt).toBe("2024-01-02T00:00:00.000Z");
expect(body3.data[0].versions.length).toBe(2);
expect(body3.data[1].name).toBe("prompt-2");
expect(body3.data[1].lastUpdatedAt).toBe("2000-03-01T00:00:00.000Z");
expect(body3.data[1].versions.length).toBe(3);
expect(body3.data[2].name).toBe("prompt-3");
expect(body3.data[2].lastUpdatedAt).toBe("2000-01-01T00:00:00.000Z");
expect(body3.data[2].versions.length).toBe(1);
expect(body3.meta.totalItems).toBe(3);
});
});
const isPrompt = (x: unknown): x is Prompt => {
@@ -1029,17 +1142,19 @@ const mockPrompts = [
prompt: "prompt-1",
createdBy: "user-test",
projectId,
config: {},
config: { version: 1 },
version: 1,
updatedAt: new Date("2024-01-01T00:00:00.000Z"),
},
{
name: "prompt-1",
labels: ["production"],
labels: ["production", "version2"],
prompt: "prompt-1",
createdBy: "user-test",
projectId,
config: {},
config: { version: 2 },
version: 2,
updatedAt: new Date("2024-01-02T00:00:00.000Z"),
},
{
name: "prompt-1",
@@ -1047,8 +1162,9 @@ const mockPrompts = [
prompt: "prompt-1",
createdBy: "user-test",
projectId,
config: {},
config: { version: 4 },
version: 4,
updatedAt: new Date("2024-01-04T00:00:00.000Z"),
},
// Prompt with different labels
@@ -1060,6 +1176,7 @@ const mockPrompts = [
projectId,
config: {},
version: 1,
updatedAt: new Date("2000-01-01T00:00:00.000Z"),
},
{
name: "prompt-2",
@@ -1069,6 +1186,7 @@ const mockPrompts = [
projectId,
config: {},
version: 2,
updatedAt: new Date("2000-03-01T00:00:00.000Z"),
},
{
name: "prompt-2",
@@ -1078,6 +1196,7 @@ const mockPrompts = [
projectId,
config: {},
version: 3,
updatedAt: new Date("2000-02-01T00:00:00.000Z"),
},
// Prompt with different labels
@@ -1090,6 +1209,7 @@ const mockPrompts = [
config: {},
tags: ["tag-1"],
version: 1,
updatedAt: new Date("2000-01-01T00:00:00.000Z"),
},
// Prompt in different project
@@ -1101,5 +1221,6 @@ const mockPrompts = [
projectId: "239ad00f-562f-411d-af14-831c75ddd875",
config: {},
version: 1,
updatedAt: new Date("2000-01-01T00:00:00.000Z"),
},
];
+2 -1
View File
@@ -597,7 +597,8 @@ describe("/api/public/scores API Endpoint", () => {
error: [
{
code: "custom",
message: "Value must be either 0 or 1",
message:
"Value must be a number equal to either 0 or 1 for data type BOOLEAN",
path: ["value"],
},
],
+13
View File
@@ -0,0 +1,13 @@
export default async function teardown() {
const { redis } = await import("@langfuse/shared/src/server");
console.log(`Redis status ${redis?.status}`);
if (!redis) {
return;
}
if (redis.status === "end" || redis.status === "close") {
console.log("Redis connection already closed");
return;
}
redis?.disconnect();
console.log("Teardown complete");
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { ModelUsageUnit } from "@langfuse/shared";
import { tokenCount } from "@/src/features/ingest/lib/usage";
import { tokenCount } from "@/src/features/ingest/usage";
describe("Token Count Functions", () => {
const generateModel = (model: string, tokenizer: string) => {
+106 -59
View File
@@ -11,30 +11,18 @@ import {
} from "@/src/components/ui/popover";
import { cn } from "@/src/utils/tailwind";
import { type DateRange } from "react-day-picker";
import { addMinutes, format } from "date-fns";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/src/components/ui/select";
import { format } from "date-fns";
import { useEffect, useState } from "react";
import {
type DateTimeAggregationOption,
dateTimeAggregationSettings,
dateTimeAggregationOptions,
} from "@/src/features/dashboard/lib/timeseries-aggregation";
import { useMediaQuery } from "react-responsive";
import { type DashboardDateRange } from "@/src/pages/project/[projectId]";
import { isValidOption } from "@/src/utils/types";
import { setBeginningOfDay, setEndOfDay } from "@/src/utils/dates";
import { TimePicker } from "@/src/components/ui/time-picker";
export const DEFAULT_DATE_RANGE_SELECTION = "Date range" as const;
export type AvailableDateRangeSelections =
| typeof DEFAULT_DATE_RANGE_SELECTION
| DateTimeAggregationOption;
import { DashboardDateRangeDropdown } from "@/src/components/date-range-dropdowns";
import {
DASHBOARD_AGGREGATION_PLACEHOLDER,
type DashboardDateRangeOptions,
type DashboardDateRange,
} from "@/src/utils/date-range-utils";
import { combineDateAndTime } from "@/src/components/ui/time-picker-utils";
export function DatePicker({
date,
@@ -101,9 +89,9 @@ export function DatePicker({
export type DatePickerWithRangeProps = {
dateRange?: DashboardDateRange;
className?: string;
selectedOption: AvailableDateRangeSelections;
selectedOption: DashboardDateRangeOptions;
setDateRangeAndOption: (
option: AvailableDateRangeSelections,
option: DashboardDateRangeOptions,
date?: DashboardDateRange,
) => void;
};
@@ -122,18 +110,35 @@ export function DatePickerWithRange({
setInternalDateRange(dateRange);
}, [dateRange]);
const onDropDownSelection = (value: string) => {
if (isValidOption(value)) {
const setting = dateTimeAggregationSettings[value];
const fromDate = addMinutes(new Date(), -1 * setting.minutes);
const setNewDateRange = (
internalDateRange: DateRange | undefined,
newFromDate: Date | undefined,
newToDate: Date | undefined,
): DateRange | undefined => {
return internalDateRange
? {
from: newFromDate ?? internalDateRange.from,
to: newToDate ?? internalDateRange.to,
}
: undefined;
};
setDateRangeAndOption(value, {
from: fromDate,
to: new Date(),
});
setInternalDateRange({ from: fromDate, to: new Date() });
} else {
setDateRangeAndOption(DEFAULT_DATE_RANGE_SELECTION, undefined);
const updateDashboardDateRange = (
newRange: DateRange | undefined,
setDateRangeAndOption: (
option: DashboardDateRangeOptions,
date?: DashboardDateRange,
) => void,
) => {
if (newRange && newRange.from && newRange.to) {
const dashboardDateRange: DashboardDateRange = {
from: newRange.from,
to: newRange.to,
};
setDateRangeAndOption(
DASHBOARD_AGGREGATION_PLACEHOLDER,
dashboardDateRange,
);
}
};
@@ -146,13 +151,29 @@ export function DatePickerWithRange({
: undefined;
setInternalDateRange(newRange);
if (newRange && newRange.from && newRange.to) {
const dashboardDateRange: DashboardDateRange = {
from: newRange.from,
to: newRange.to,
};
setDateRangeAndOption(DEFAULT_DATE_RANGE_SELECTION, dashboardDateRange);
}
updateDashboardDateRange(newRange, setDateRangeAndOption);
};
const onStartTimeSelection = (date: Date | undefined) => {
const newDateTime = combineDateAndTime(internalDateRange?.from, date);
const newRange = setNewDateRange(
internalDateRange,
newDateTime,
internalDateRange?.to,
);
setInternalDateRange(newRange);
updateDashboardDateRange(newRange, setDateRangeAndOption);
};
const onEndTimeSelection = (date: Date | undefined) => {
const newDateTime = combineDateAndTime(internalDateRange?.to, date);
const newRange = setNewDateRange(
internalDateRange,
internalDateRange?.from,
newDateTime,
);
setInternalDateRange(newRange);
updateDashboardDateRange(newRange, setDateRangeAndOption);
};
const isSmallScreen = useMediaQuery({ query: "(max-width: 640px)" });
@@ -193,28 +214,54 @@ export function DatePickerWithRange({
defaultMonth={internalDateRange?.from}
selected={internalDateRange}
onSelect={onCalendarSelection}
numberOfMonths={isSmallScreen ? 1 : 2} // TODO: make this configurable to screen size
numberOfMonths={isSmallScreen ? 1 : 2}
/>
{!isSmallScreen && (
<div className="flex flex-row border-t-2 py-1.5">
<div className="px-3">
<p className="px-1 text-sm font-medium">Start time</p>
<TimePicker
date={internalDateRange?.from}
setDate={onStartTimeSelection}
className="border-0 px-0 pt-1"
/>
</div>
<div className="px-3">
<p className="px-1 text-sm font-medium">End time</p>
<TimePicker
date={internalDateRange?.to}
setDate={onEndTimeSelection}
className="border-0 px-0 pt-1"
/>
</div>
</div>
)}
{isSmallScreen && (
<div className="flex flex-col gap-2 border-t-2 py-1.5">
<div className="px-3">
<p className="px-1 text-sm font-medium">Start</p>
<TimePicker
date={internalDateRange?.from}
setDate={onStartTimeSelection}
className="border-0 px-0 pt-1"
/>
</div>
<div className="px-3">
<p className="px-1 text-sm font-medium">End</p>
<TimePicker
date={internalDateRange?.to}
setDate={onEndTimeSelection}
className="border-0 px-0 pt-1"
/>
</div>
</div>
)}
</PopoverContent>
</Popover>
<Select value={selectedOption} onValueChange={onDropDownSelection}>
<SelectTrigger className="w-[120px] hover:bg-accent hover:text-accent-foreground focus:ring-0 focus:ring-offset-0">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="popper" defaultValue={60}>
<SelectItem
key={DEFAULT_DATE_RANGE_SELECTION}
value={DEFAULT_DATE_RANGE_SELECTION}
>
{DEFAULT_DATE_RANGE_SELECTION}
</SelectItem>
{dateTimeAggregationOptions.toReversed().map((item) => (
<SelectItem key={item} value={item}>
{item}
</SelectItem>
))}
</SelectContent>
</Select>
<DashboardDateRangeDropdown
selectedOption={selectedOption}
setDateRangeAndOption={setDateRangeAndOption}
/>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
import * as React from "react";
import { addMinutes } from "date-fns";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/src/components/ui/select";
import {
DEFAULT_AGGREGATION_SELECTION,
tableDateRangeAggregationSettings,
dashboardDateRangeAggregationSettings,
DASHBOARD_AGGREGATION_PLACEHOLDER,
type DashboardDateRangeOptions,
type TableDateRangeOptions,
DASHBOARD_AGGREGATION_OPTIONS,
TABLE_AGGREGATION_OPTIONS,
type DashboardDateRange,
} from "@/src/utils/date-range-utils";
import { Clock } from "lucide-react";
type BaseDateRangeDropdownProps<T> = {
selectedOption: T;
options: T[];
onSelectionChange: (value: T) => void;
};
const BaseDateRangeDropdown = <T extends string>({
selectedOption,
options,
onSelectionChange,
}: BaseDateRangeDropdownProps<T>) => {
return (
<Select value={selectedOption} onValueChange={onSelectionChange}>
<SelectTrigger className="w-[130px] hover:bg-accent hover:text-accent-foreground focus:ring-0 focus:ring-offset-0">
<Clock className="h-4 w-4" />
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="popper" defaultValue={60}>
{options.map((item) => (
<SelectItem key={item} value={item}>
{item}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
type DashboardDateRangeDropdownProps = {
selectedOption: DashboardDateRangeOptions;
setDateRangeAndOption: (
option: DashboardDateRangeOptions,
date?: DashboardDateRange,
) => void;
};
export const DashboardDateRangeDropdown: React.FC<
DashboardDateRangeDropdownProps
> = ({ selectedOption, setDateRangeAndOption }) => {
const onDropDownSelection = (value: DashboardDateRangeOptions) => {
if (value === DASHBOARD_AGGREGATION_PLACEHOLDER) {
setDateRangeAndOption(DASHBOARD_AGGREGATION_PLACEHOLDER, undefined);
return;
}
const setting =
dashboardDateRangeAggregationSettings[
value as keyof typeof dashboardDateRangeAggregationSettings
];
setDateRangeAndOption(value, {
from: addMinutes(new Date(), -setting.minutes),
to: new Date(),
});
};
const options =
selectedOption === DASHBOARD_AGGREGATION_PLACEHOLDER
? [...DASHBOARD_AGGREGATION_OPTIONS, DASHBOARD_AGGREGATION_PLACEHOLDER]
: [...DASHBOARD_AGGREGATION_OPTIONS];
return (
<BaseDateRangeDropdown
selectedOption={selectedOption}
options={options}
onSelectionChange={onDropDownSelection}
/>
);
};
type TableDateRangeDropdownProps = {
selectedOption: TableDateRangeOptions;
setDateRangeAndOption: (
option: TableDateRangeOptions,
date?: DashboardDateRange,
) => void;
};
export const TableDateRangeDropdown: React.FC<TableDateRangeDropdownProps> = ({
selectedOption,
setDateRangeAndOption,
}) => {
const onDropDownSelection = (value: TableDateRangeOptions) => {
if (value === DEFAULT_AGGREGATION_SELECTION) {
setDateRangeAndOption(DEFAULT_AGGREGATION_SELECTION, undefined);
return;
}
const setting =
tableDateRangeAggregationSettings[
value as keyof typeof tableDateRangeAggregationSettings
];
setDateRangeAndOption(value, {
from: addMinutes(new Date(), -setting),
to: new Date(),
});
};
return (
<BaseDateRangeDropdown
selectedOption={selectedOption}
options={[...TABLE_AGGREGATION_OPTIONS, DEFAULT_AGGREGATION_SELECTION]}
onSelectionChange={onDropDownSelection}
/>
);
};
+50 -78
View File
@@ -4,89 +4,61 @@ import {
HoverCardContent,
HoverCardTrigger,
} from "@/src/components/ui/hover-card";
import {
isBooleanDataType,
isCategoricalDataType,
} from "@/src/features/manual-scoring/lib/helpers";
import { type ScoreSimplified } from "@/src/server/api/routers/generations/getAllQuery";
import { cn } from "@/src/utils/tailwind";
import { MessageCircleMore } from "lucide-react";
export const GroupedScoreBadges = ({
import { type APIScore } from "@/src/features/public-api/types/scores";
import { type LastUserScore } from "@/src/features/scores/lib/types";
export const GroupedScoreBadges = <T extends APIScore | LastUserScore>({
scores,
variant = "badge",
}: {
scores: ScoreSimplified[];
variant?: "badge" | "headings";
scores: T[];
}) => {
const groupedScores = scores.reduce<Record<string, ScoreSimplified[]>>(
(acc, score) => {
if (!acc[score.name] || !Array.isArray(acc[score.name])) {
acc[score.name] = [score];
} else {
(acc[score.name] as ScoreSimplified[]).push(score);
}
return acc;
},
{},
);
const groupedScores = scores.reduce<Record<string, T[]>>((acc, score) => {
if (!acc[score.name] || !Array.isArray(acc[score.name])) {
acc[score.name] = [score];
} else {
acc[score.name].push(score);
}
return acc;
}, {});
const ScoresOfGroup = (props: {
scores: ScoreSimplified[];
className?: string;
}) => (
<div className={cn("text-xs", props.className)}>
{props.scores.map((s, i) => (
<span key={i} className="group/score ml-1 first:ml-0">
{isCategoricalDataType(s.dataType) || isBooleanDataType(s.dataType)
? s.stringValue
: s.value?.toFixed(2)}
{s.comment && (
<HoverCard>
<HoverCardTrigger className="ml-1 inline-block cursor-pointer">
<MessageCircleMore size={12} />
</HoverCardTrigger>
<HoverCardContent className="overflow-hidden whitespace-normal break-normal">
<p>{s.comment}</p>
</HoverCardContent>
</HoverCard>
)}
<span className="group-last/score:hidden">,</span>
</span>
))}
</div>
);
if (variant === "headings")
return (
<div className="flex items-center gap-3">
{Object.entries(groupedScores)
.sort(([a], [b]) => (a < b ? -1 : 1))
.map(([name, scores]) => (
<div key={name}>
<div className="text-xs text-muted-foreground">{name}</div>
<ScoresOfGroup scores={scores} />
return (
<>
{Object.entries(groupedScores)
.sort(([a], [b]) => (a < b ? -1 : 1))
.map(([name, scores]) => (
<Badge
variant="outline"
key={name}
className="grid grid-cols-[1fr,auto] gap-1 font-normal"
>
<p className="truncate" title={name}>
{name}:
</p>
<div className="flex items-center gap-3 text-nowrap">
{scores.map((s, i) => (
<span
key={i}
className="group/score ml-1 rounded-sm first:ml-0"
>
{s.stringValue ?? s.value?.toFixed(2) ?? ""}
{s.comment && (
<HoverCard>
<HoverCardTrigger className="ml-1 inline-block cursor-pointer">
<MessageCircleMore size={12} />
</HoverCardTrigger>
<HoverCardContent className="overflow-hidden whitespace-normal break-normal">
<p>{s.comment}</p>
</HoverCardContent>
</HoverCard>
)}
<span className="group-last/score:hidden">,</span>
</span>
))}
</div>
))}
</div>
);
else
return (
<>
{Object.entries(groupedScores)
.sort(([a], [b]) => (a < b ? -1 : 1))
.map(([name, scores]) => (
<Badge
variant="outline"
key={name}
className="grid grid-cols-[1fr,auto] gap-1 font-normal"
>
<p className="truncate" title={name}>
{name}:
</p>
<ScoresOfGroup scores={scores} />
</Badge>
))}
</>
);
</Badge>
))}
</>
);
};
+17 -46
View File
@@ -5,28 +5,14 @@ import {
} from "@/src/components/ui/hover-card";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { cn } from "@/src/utils/tailwind";
import { HelpCircle, Info } from "lucide-react";
import Link from "next/link";
import { Info } from "lucide-react";
export type DocPopupProps = {
description: React.ReactNode;
href?: string;
style?: "question" | "info";
size?: "xs" | "sm" | "md" | "lg";
};
export default function DocPopup({
description,
href,
style = "info",
size = "sm",
}: DocPopupProps) {
const sizes = {
xs: "w-3 h-3",
sm: "w-4 h-4",
md: "w-6 h-6",
lg: "w-8 h-8",
};
export default function DocPopup({ description, href }: DocPopupProps) {
const capture = usePostHogClientCapture();
return (
@@ -45,36 +31,21 @@ export default function DocPopup({
className={cn("mx-1", href ? "cursor-pointer" : "cursor-default")}
asChild
>
{href ? (
<Link
href={href}
rel="noopener"
target="_blank"
className="inline-block whitespace-nowrap text-muted-foreground sm:pl-0"
onClick={() => {
capture("help_popup:href_clicked", {
href: href,
description: description,
});
}}
>
{
{
question: <HelpCircle className={sizes[size]} />,
info: <Info className={sizes[size]} />,
}[style]
}
</Link>
) : (
<div className="inline-block whitespace-nowrap text-muted-foreground sm:pl-0">
{
{
question: <HelpCircle className={sizes[size]} />,
info: <Info className={sizes[size]} />,
}[style]
}
</div>
)}
<div
className="inline-block whitespace-nowrap text-muted-foreground sm:pl-0"
onClick={(e) => {
if (!href) return;
e.preventDefault();
e.stopPropagation();
window.open(href, "_blank");
capture("help_popup:href_clicked", {
href: href,
description: description,
});
}}
>
<Info className={"h-3 w-3"} />
</div>
</HoverCardTrigger>
<HoverCardContent>
{typeof description === "string" ? (
-1
View File
@@ -106,7 +106,6 @@ export default function Header({
<DocPopup
description={props.help.description}
href={props.help.href}
size="sm"
/>
) : null}
{props.featureBetaURL ? (
+8 -2
View File
@@ -389,7 +389,10 @@ export default function Layout(props: PropsWithChildren) {
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute -top-full bottom-1 right-0 z-10 overflow-hidden rounded-md bg-background py-2 shadow-lg ring-1 ring-border focus:outline-none">
<span className="block border-b px-3 pb-2 text-sm leading-6 text-muted-foreground">
<span
className="block max-w-56 overflow-hidden truncate border-b px-3 pb-2 text-sm leading-6 text-muted-foreground"
title={session.data?.user?.email ?? undefined}
>
{session.data?.user?.email}
</span>
{userNavigation.map((item) => (
@@ -454,7 +457,10 @@ export default function Layout(props: PropsWithChildren) {
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 z-10 mt-2.5 rounded-md bg-background py-2 pb-1 shadow-lg ring-1 ring-border focus:outline-none">
<span className="mb-1 block border-b px-3 pb-2 text-sm leading-6 text-muted-foreground">
<span
className="mb-1 block max-w-56 overflow-hidden truncate border-b px-3 pb-2 text-sm leading-6 text-muted-foreground"
title={session.data?.user?.email ?? undefined}
>
{session.data?.user?.email}
</span>
{userNavigation.map((item) => (
-1
View File
@@ -101,7 +101,6 @@ export const ROUTES: Route[] = [
pathname: "/project/[projectId]/playground",
icon: TerminalIcon,
requires: "cloud-or-ee",
label: "Beta",
},
{
name: "Datasets",
+97
View File
@@ -0,0 +1,97 @@
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/src/components/ui/hover-card";
import {
type CategoricalAggregate,
type NumericAggregate,
} from "@/src/features/scores/lib/types";
import { numberFormatter } from "@/src/utils/numbers";
import { cn } from "@/src/utils/tailwind";
import { MessageCircleMore } from "lucide-react";
const COLOR_MAP = new Map([
["True", "bg-light-green p-0.5 text-dark-green"],
["False", "bg-light-red p-0.5 text-dark-red"],
]);
const COLLAPSE_CATEGORICAL_SCORES_AFTER = 2;
const ScoreValueCounts = ({
valueCounts,
}: {
valueCounts: CategoricalAggregate["valueCounts"];
}) => {
return valueCounts.map(({ value, count }) => (
<div key={value} className="flex flex-row">
<span className="truncate">{value}</span>
<span>{`: ${numberFormatter(count, 0)}`}</span>
</div>
));
};
export const ScoresTableCell = ({
aggregate,
showSingleValue = false,
}: {
aggregate: CategoricalAggregate | NumericAggregate;
showSingleValue?: boolean;
}) => {
if (showSingleValue && aggregate.values.length === 1) {
const value =
aggregate.type === "NUMERIC"
? aggregate.average.toFixed(4)
: aggregate.values[0];
return (
<span
className={cn("flex flex-row gap-0.5 rounded-sm", COLOR_MAP.get(value))}
>
{value}
{aggregate.comment && (
<HoverCard>
<HoverCardTrigger className="inline-block cursor-pointer">
<MessageCircleMore size={12} />
</HoverCardTrigger>
<HoverCardContent className="overflow-hidden whitespace-normal break-normal">
<p>{aggregate.comment}</p>
</HoverCardContent>
</HoverCard>
)}
</span>
);
}
if (aggregate.type === "NUMERIC") {
return (
<span className="rounded-sm">{`Ø ${aggregate.average.toFixed(4)}`}</span>
);
}
return (
<div className="group">
{aggregate.valueCounts.length > COLLAPSE_CATEGORICAL_SCORES_AFTER ? (
<HoverCard>
<HoverCardTrigger>
<div className="flex cursor-pointer flex-col group-hover:text-accent-dark-blue/55">
<ScoreValueCounts
valueCounts={aggregate.valueCounts.slice(
0,
COLLAPSE_CATEGORICAL_SCORES_AFTER,
)}
/>
</div>
</HoverCardTrigger>
<HoverCardContent className="z-20 flex max-h-[40vh] max-w-64 flex-col overflow-y-auto whitespace-normal break-normal">
<ScoreValueCounts valueCounts={aggregate.valueCounts} />
</HoverCardContent>
</HoverCard>
) : (
<div className="flex flex-col">
<ScoreValueCounts valueCounts={aggregate.valueCounts} />
</div>
)}
</div>
);
};
+8 -1
View File
@@ -13,8 +13,9 @@ import { api } from "@/src/utils/api";
import { usdFormatter } from "@/src/utils/numbers";
import Link from "next/link";
import { useEffect, useState } from "react";
import { AnnotateDrawer } from "@/src/features/manual-scoring/components/AnnotateDrawer";
import { AnnotateDrawer } from "@/src/features/scores/components/AnnotateDrawer";
import { Button } from "@/src/components/ui/button";
import useLocalStorage from "@/src/components/useLocalStorage";
// some projects have thousands of traces in a sessions, paginate to avoid rendering all at once
const PAGE_SIZE = 50;
@@ -47,6 +48,10 @@ export const SessionPage: React.FC<{
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session.isSuccess, session.data]);
const [emptySelectedConfigIds, setEmptySelectedConfigIds] = useLocalStorage<
string[]
>("emptySelectedConfigIds", []);
if (session.error?.data?.code === "UNAUTHORIZED")
return <ErrorPage message="You do not have access to this session." />;
@@ -129,6 +134,8 @@ export const SessionPage: React.FC<{
projectId={projectId}
traceId={trace.id}
scores={trace.scores}
emptySelectedConfigIds={emptySelectedConfigIds}
setEmptySelectedConfigIds={setEmptySelectedConfigIds}
variant="badge"
type="session"
source="SessionDetail"
@@ -10,11 +10,14 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuCheckboxItem,
DropdownMenuSeparator,
DropdownMenuLabel,
} from "@/src/components/ui/dropdown-menu";
import { type VisibilityState } from "@tanstack/react-table";
import { ChevronDown, Columns } from "lucide-react";
import { type LangfuseColumnDef } from "@/src/components/table/types";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import DocPopup from "@/src/components/layouts/doc-popup";
interface DataTableColumnVisibilityFilterProps<TData, TValue> {
columns: LangfuseColumnDef<TData, TValue>[];
@@ -22,6 +25,65 @@ interface DataTableColumnVisibilityFilterProps<TData, TValue> {
setColumnVisibility: Dispatch<SetStateAction<VisibilityState>>;
}
const calculateColumnCounts = <TData, TValue>(
columns: LangfuseColumnDef<TData, TValue>[],
columnVisibility: VisibilityState,
) => {
return columns.reduce(
(acc, column) => {
if (column.columns) {
const groupCounts = calculateColumnCounts(
column.columns,
columnVisibility,
);
acc.count += groupCounts.count;
acc.total += groupCounts.total;
} else if (column.enableHiding) {
acc.total++;
if (
column.accessorKey in columnVisibility &&
columnVisibility[column.accessorKey]
) {
acc.count++;
}
}
return acc;
},
{ count: 0, total: 0 },
);
};
function ColumnVisibilityDropdownItem<TData, TValue>({
column,
toggleColumn,
columnVisibility,
}: {
column: LangfuseColumnDef<TData, TValue>;
toggleColumn: (columnId: string) => void;
columnVisibility: VisibilityState;
}) {
if (column.enableHiding) {
return (
<DropdownMenuCheckboxItem
checked={columnVisibility[column.accessorKey]}
onCheckedChange={() => toggleColumn(column.accessorKey)}
>
<span className="capitalize">
{column.header && typeof column.header === "string"
? column.header
: column.accessorKey}
</span>
{column.headerTooltip && (
<DocPopup
description={column.headerTooltip.description}
href={column.headerTooltip.href}
/>
)}
</DropdownMenuCheckboxItem>
);
}
}
export function DataTableColumnVisibilityFilter<TData, TValue>({
columns,
columnVisibility,
@@ -49,27 +111,6 @@ export function DataTableColumnVisibilityFilter<TData, TValue>({
[setColumnVisibility],
);
const calculateColumnCounts = (
columns: LangfuseColumnDef<TData, TValue>[],
columnVisibility: VisibilityState,
) => {
return columns.reduce(
(acc, column) => {
if (column.enableHiding) {
acc.total++;
if (
column.accessorKey in columnVisibility &&
columnVisibility[column.accessorKey]
) {
acc.count++;
}
}
return acc;
},
{ count: 0, total: 0 },
);
};
const { count, total } = calculateColumnCounts(columns, columnVisibility);
return (
@@ -92,22 +133,38 @@ export function DataTableColumnVisibilityFilter<TData, TValue>({
onPointerDownOutside={() => setIsOpen(false)}
className="max-h-96 overflow-y-auto"
>
{columns.map(
(column, index) =>
"accessorKey" in column &&
column.enableHiding && (
<DropdownMenuCheckboxItem
key={index}
className="capitalize"
checked={columnVisibility[column.accessorKey]}
onCheckedChange={() =>
toggleColumn(column.accessorKey.toString())
}
>
{column.header?.toString() ?? column.accessorKey.toString()}
</DropdownMenuCheckboxItem>
),
)}
{columns.map((column, index) => {
if (!!column.columns && Boolean(column.columns.length)) {
const isFollowingGroup = "columns" in (columns[index - 1] ?? {});
return (
<div key={index}>
{!isFollowingGroup && <DropdownMenuSeparator />}
<DropdownMenuLabel>
{column.header && typeof column.header === "string"
? column.header
: column.accessorKey}
</DropdownMenuLabel>
{column.columns.map((column) => (
<ColumnVisibilityDropdownItem
key={column.accessorKey}
column={column}
columnVisibility={columnVisibility}
toggleColumn={toggleColumn}
/>
))}
<DropdownMenuSeparator />
</div>
);
} else
return (
<ColumnVisibilityDropdownItem
key={column.accessorKey}
column={column}
columnVisibility={columnVisibility}
toggleColumn={toggleColumn}
/>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
@@ -13,6 +13,11 @@ import {
} from "@/src/components/table/data-table-row-height-switch";
import { Search } from "lucide-react";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { TableDateRangeDropdown } from "@/src/components/date-range-dropdowns";
import {
type TableDateRange,
type TableDateRangeOptions,
} from "@/src/utils/date-range-utils";
interface SearchConfig {
placeholder: string;
@@ -32,6 +37,11 @@ interface DataTableToolbarProps<TData, TValue> {
rowHeight?: RowHeight;
setRowHeight?: Dispatch<SetStateAction<RowHeight>>;
columnsWithCustomSelect?: string[];
selectedOption?: TableDateRangeOptions;
setDateRangeAndOption?: (
option: TableDateRangeOptions,
date?: TableDateRange,
) => void;
}
export function DataTableToolbar<TData, TValue>({
@@ -46,6 +56,8 @@ export function DataTableToolbar<TData, TValue>({
rowHeight,
setRowHeight,
columnsWithCustomSelect,
selectedOption,
setDateRangeAndOption,
}: DataTableToolbarProps<TData, TValue>) {
const [searchString, setSearchString] = useState(
searchConfig?.currentQuery ?? "",
@@ -89,6 +101,12 @@ export function DataTableToolbar<TData, TValue>({
columnsWithCustomSelect={columnsWithCustomSelect}
/>
)}
{selectedOption && setDateRangeAndOption && (
<TableDateRangeDropdown
selectedOption={selectedOption}
setDateRangeAndOption={setDateRangeAndOption}
/>
)}
<div className="flex flex-row flex-wrap gap-2 pr-0.5 @6xl:ml-auto">
{!!columnVisibility && !!setColumnVisibility && (
<DataTableColumnVisibilityFilter
+143 -56
View File
@@ -1,5 +1,6 @@
"use client";
import { type OrderByState } from "@langfuse/shared";
import React, { useState, useMemo } from "react";
import DocPopup from "@/src/components/layouts/doc-popup";
import { DataTablePagination } from "@/src/components/table/data-table-pagination";
@@ -30,7 +31,6 @@ import {
type RowSelectionState,
type VisibilityState,
} from "@tanstack/react-table";
import { useState } from "react";
interface DataTableProps<TData, TValue> {
columns: LangfuseColumnDef<TData, TValue>[];
@@ -52,6 +52,7 @@ interface DataTableProps<TData, TValue> {
className?: string;
paginationClassName?: string;
isBorderless?: boolean;
shouldRenderGroupHeaders?: boolean;
}
export interface AsyncTableData<T> {
@@ -76,6 +77,7 @@ export function DataTable<TData extends object, TValue>({
className,
paginationClassName,
isBorderless = false,
shouldRenderGroupHeaders = false,
}: DataTableProps<TData, TValue>) {
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const rowheighttw = getRowHeightTailwindClass(rowHeight);
@@ -106,8 +108,40 @@ export function DataTable<TData extends object, TValue>({
rowSelection,
},
manualFiltering: true,
defaultColumn: {
minSize: 20,
size: 150,
maxSize: Number.MAX_SAFE_INTEGER,
},
columnResizeMode: "onChange",
});
// memo column sizes for performance
// https://tanstack.com/table/v8/docs/guide/column-sizing#advanced-column-resizing-performance
const columnSizeVars = useMemo(() => {
const headers = table.getFlatHeaders();
const colSizes: { [key: string]: number } = {};
for (let i = 0; i < headers.length; i++) {
const header = headers[i]!;
colSizes[`--header-${header.id}-size`] = header.getSize();
colSizes[`--col-${header.column.id}-size`] = header.column.getSize();
}
return colSizes;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
// eslint-disable-next-line react-hooks/exhaustive-deps
table.getState().columnSizingInfo,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.getState().columnSizing,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.getFlatHeaders(),
columnVisibility,
]);
const tableHeaders = shouldRenderGroupHeaders
? table.getHeaderGroups()
: [table.getHeaderGroups().slice(-1)[0]];
return (
<>
<div
@@ -121,10 +155,11 @@ export function DataTable<TData extends object, TValue>({
"w-full overflow-auto",
isBorderless ? "" : "rounded-md border",
)}
style={{ ...columnSizeVars }}
>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
{tableHeaders.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const columnDef = header.column
@@ -134,9 +169,12 @@ export function DataTable<TData extends object, TValue>({
<TableHead
key={header.id}
className={cn(
sortingEnabled ? "cursor-pointer" : null,
"whitespace-nowrap p-2",
"group p-1 first:pl-2",
sortingEnabled && "cursor-pointer",
)}
style={{
width: `calc(var(--header-${header.id}-size) * 1px)`,
}}
title={sortingEnabled ? "Sort by this column" : ""}
onClick={(event) => {
event.preventDefault(); // Add this line
@@ -181,20 +219,31 @@ export function DataTable<TData extends object, TValue>({
header.column.columnDef.header,
header.getContext(),
)}
{columnDef.headerTooltip && (
<DocPopup
description={
columnDef.headerTooltip.description
}
href={columnDef.headerTooltip.href}
size="xs"
/>
)}
{orderBy?.column === columnDef.id
? renderOrderingIndicator(orderBy)
: null}
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onDoubleClick={() => header.column.resetSize()}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={cn(
"absolute right-0 top-0 h-full w-1.5 cursor-col-resize touch-none select-none bg-secondary opacity-0 group-hover:opacity-100",
header.column.getIsResizing() &&
"bg-primary-accent opacity-100",
)}
/>
</div>
</>
)}
@@ -204,54 +253,23 @@ export function DataTable<TData extends object, TValue>({
</TableRow>
))}
</TableHeader>
<TableBody>
{data.isLoading || !data.data ? (
<TableRow className="h-svh">
<TableCell
colSpan={columns.length}
className="content-start border-b text-center"
>
Loading...
</TableCell>
</TableRow>
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="overflow-hidden whitespace-nowrap border-b px-2 py-1 text-xs first:pl-2"
>
<div className={cn("flex items-center", rowheighttw)}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</div>
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
<div>
No results.{" "}
{help && (
<DocPopup
description={help.description}
href={help.href}
size="sm"
/>
)}
</div>
</TableCell>
</TableRow>
)}
</TableBody>
{table.getState().columnSizingInfo.isResizingColumn ? (
<MemoizedTableBody
table={table}
rowheighttw={rowheighttw}
columns={columns}
data={data}
help={help}
/>
) : (
<TableBodyComponent
table={table}
rowheighttw={rowheighttw}
columns={columns}
data={data}
help={help}
/>
)}
</Table>
</div>
<div className="grow"></div>
@@ -274,7 +292,76 @@ export function DataTable<TData extends object, TValue>({
}
function renderOrderingIndicator(orderBy?: OrderByState) {
if (!orderBy) return;
if (!orderBy) return null;
if (orderBy.order === "ASC") return <span className="ml-1"></span>;
else return <span className="ml-1"></span>;
}
interface TableBodyComponentProps<TData> {
table: ReturnType<typeof useReactTable<TData>>;
rowheighttw?: string;
columns: LangfuseColumnDef<TData, any>[];
data: AsyncTableData<TData[]>;
help?: { description: string; href: string };
}
function TableBodyComponent<TData>({
table,
rowheighttw,
columns,
data,
help,
}: TableBodyComponentProps<TData>) {
return (
<TableBody>
{data.isLoading || !data.data ? (
<TableRow className="h-svh">
<TableCell
colSpan={columns.length}
className="content-start border-b text-center"
>
Loading...
</TableCell>
</TableRow>
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={cn(
"overflow-hidden border-b p-1 text-xs first:pl-2",
rowheighttw === "s" && "whitespace-nowrap",
)}
style={{
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
}}
>
<div className={cn("flex items-center", rowheighttw)}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
<div>
No results.{" "}
{help && (
<DocPopup description={help.description} href={help.href} />
)}
</div>
</TableCell>
</TableRow>
)}
</TableBody>
);
}
// memo tables for performance, should only re-render when data changes
// https://tanstack.com/table/v8/docs/guide/column-sizing#advanced-column-resizing-performance
const MemoizedTableBody = React.memo(TableBodyComponent, (prev, next) => {
return prev.table.options.data === next.table.options.data;
}) as typeof TableBodyComponent;
+8 -9
View File
@@ -1,30 +1,29 @@
import { cn } from "@/src/utils/tailwind";
import Link from "next/link";
export type TableLinkProps = {
path: string;
value: string;
truncateAt?: number;
icon?: React.ReactNode;
className?: string;
};
export default function TableLink({
path,
value,
truncateAt = 7,
icon,
className,
}: TableLinkProps) {
const truncatedValue =
value.length - truncateAt > 3
? `...${value.substring(value.length - truncateAt)}`
: value;
return (
<Link
className="inline-block rounded bg-primary-accent/20 px-2
py-1 text-xs font-semibold text-accent-dark-blue shadow-sm hover:bg-accent-light-blue/45"
className={cn(
"inline-block max-w-full overflow-hidden text-ellipsis text-nowrap rounded bg-primary-accent/20 px-2 py-1 text-xs font-semibold text-accent-dark-blue shadow-sm hover:bg-accent-light-blue/45",
className,
)}
href={path}
title={value}
>
{icon ? icon : truncatedValue}
{icon ? icon : value}
</Link>
);
}
+16 -5
View File
@@ -6,14 +6,25 @@ export type TableRowOptions = {
options: { label: string; value: number; icon?: LucideIcon }[];
};
export type LangfuseColumnDef<
TData extends RowData,
TValue = unknown,
> = ColumnDef<TData, TValue> & {
accessorKey: string;
// extends tanstack ColumnDef to include additional properties
type ExtendedColumnDef<TData extends RowData, TValue = unknown> = ColumnDef<
TData,
TValue
> & {
defaultHidden?: boolean;
headerTooltip?: {
description: string;
href?: string;
};
};
// limits types of defined tanstack ColumnDef properties to specific subset of tanstack type union
export type LangfuseColumnDef<
TData extends RowData,
TValue = unknown,
> = ExtendedColumnDef<TData, TValue> & {
// Enforce langfuse columns to be of type 'AccessorKeyColumnDefBase' with 'accessorKey' property of type string
accessorKey: string;
// Enforce langfuse group columns to have children of type 'LangfuseColumnDef'
columns?: LangfuseColumnDef<TData, TValue>[];
};
@@ -1,9 +1,8 @@
import { api, directApi } from "@/src/utils/api";
import { GroupedScoreBadges } from "@/src/components/grouped-score-badge";
import { DataTable } from "@/src/components/table/data-table";
import TableLink from "@/src/components/table/table-link";
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
import { useState } from "react";
import { useMemo, useState } from "react";
import { TokenUsageBadge } from "@/src/components/token-usage-badge";
import {
DropdownMenu,
@@ -21,10 +20,7 @@ import {
withDefault,
} from "use-query-params";
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
import {
formatIntervalSeconds,
localtimeDateOffsetByDays,
} from "@/src/utils/dates";
import { formatIntervalSeconds } from "@/src/utils/dates";
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
import { type LangfuseColumnDef } from "@/src/components/table/types";
import {
@@ -42,11 +38,17 @@ import {
} from "@langfuse/shared";
import { useOrderByState } from "@/src/features/orderBy/hooks/useOrderByState";
import type Decimal from "decimal.js";
import { type ScoreSimplified } from "@/src/server/api/routers/generations/getAllQuery";
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { useTableLookBackDays } from "@/src/hooks/useTableLookBackDays";
import {
getScoreGroupColumnProps,
verifyAndPrefixScoreDataAgainstKeys,
} from "@/src/features/scores/components/ScoreDetailColumnHelpers";
import { useTableDateRange } from "@/src/hooks/useTableDateRange";
import { useDebounce } from "@/src/hooks/useDebounce";
import { type ScoreAggregate } from "@/src/features/scores/lib/types";
import { useIndividualScoreColumns } from "@/src/features/scores/hooks/useIndividualScoreColumns";
export type GenerationsTableRow = {
id: string;
@@ -58,6 +60,8 @@ export type GenerationsTableRow = {
completionStartTime?: Date;
latency?: number;
timeToFirstToken?: number;
// scores holds grouped column with individual scores
scores: ScoreAggregate;
name?: string;
model?: string;
// i/o and metadata not set explicitly, but fetched from the server from the cell
@@ -68,7 +72,6 @@ export type GenerationsTableRow = {
outputCost?: Decimal;
totalCost?: Decimal;
traceName?: string;
scores?: ScoreSimplified[];
usage: {
promptTokens: number;
completionTokens: number;
@@ -109,15 +112,11 @@ export default function GenerationsTable({
"s",
);
const { selectedOption, dateRange, setDateRangeAndOption } =
useTableDateRange();
const [inputFilterState, setInputFilterState] = useQueryFilterState(
[
{
column: "Start Time",
type: "datetime",
operator: ">",
value: localtimeDateOffsetByDays(-useTableLookBackDays(projectId)),
},
],
[],
"generations",
);
@@ -148,7 +147,19 @@ export default function GenerationsTable({
]
: [];
const dateRangeFilter: FilterState = dateRange
? [
{
column: "Start Time",
type: "datetime",
operator: ">=",
value: dateRange.from,
},
]
: [];
const filterState = inputFilterState.concat([
...dateRangeFilter,
...promptNameFilter,
...promptVersionFilter,
]);
@@ -180,6 +191,13 @@ export default function GenerationsTable({
},
);
const { scoreColumns, scoreKeysAndProps, isColumnLoading } =
useIndividualScoreColumns<GenerationsTableRow>({
projectId,
scoreColumnKey: "scores",
selectedTimeOption: selectedOption,
});
const transformFilterOptions = (
filterOptions: ObservationOptions | undefined,
) => {
@@ -239,6 +257,7 @@ export default function GenerationsTable({
accessorKey: "id",
id: "id",
header: "ID",
size: 100,
cell: ({ row }) => {
const observationId = row.getValue("id");
const traceId = row.getValue("traceId");
@@ -256,12 +275,14 @@ export default function GenerationsTable({
accessorKey: "name",
id: "name",
header: "Name",
size: 150,
enableSorting: true,
},
{
accessorKey: "traceId",
id: "traceId",
header: "Trace ID",
size: 100,
cell: ({ row }) => {
const value = row.getValue("traceId");
return typeof value === "string" ? (
@@ -277,6 +298,7 @@ export default function GenerationsTable({
accessorKey: "traceName",
id: "traceName",
header: "Trace Name",
size: 150,
enableHiding: true,
enableSorting: true,
},
@@ -284,6 +306,7 @@ export default function GenerationsTable({
accessorKey: "startTime",
id: "startTime",
header: "Start Time",
size: 150,
enableHiding: true,
enableSorting: true,
cell: ({ row }) => {
@@ -295,6 +318,7 @@ export default function GenerationsTable({
accessorKey: "endTime",
id: "endTime",
header: "End Time",
size: 150,
enableHiding: true,
enableSorting: true,
},
@@ -302,6 +326,7 @@ export default function GenerationsTable({
accessorKey: "timeToFirstToken",
id: "timeToFirstToken",
header: "Time to First Token",
size: 150,
enableHiding: true,
enableSorting: true,
cell: ({ row }) => {
@@ -315,22 +340,12 @@ export default function GenerationsTable({
);
},
},
{
accessorKey: "scores",
id: "scores",
header: "Scores",
cell: ({ row }) => {
const values: ScoreSimplified[] | undefined = row.getValue("scores");
return (
values && <GroupedScoreBadges scores={values} variant="headings" />
);
},
enableHiding: true,
},
{ ...getScoreGroupColumnProps(isColumnLoading), columns: scoreColumns },
{
accessorKey: "latency",
id: "latency",
header: "Latency",
size: 100,
cell: ({ row }) => {
const latency: number | undefined = row.getValue("latency");
return latency !== undefined ? (
@@ -344,6 +359,7 @@ export default function GenerationsTable({
accessorKey: "timePerOutputToken",
id: "timePerOutputToken",
header: "Time per Output Token",
size: 200,
cell: ({ row }) => {
const latency: number | undefined = row.getValue("latency");
const usage: {
@@ -368,6 +384,7 @@ export default function GenerationsTable({
accessorKey: "inputCost",
id: "inputCost",
header: "Input Cost",
size: 120,
cell: ({ row }) => {
const value: Decimal | undefined = row.getValue("inputCost");
@@ -383,6 +400,7 @@ export default function GenerationsTable({
accessorKey: "outputCost",
id: "outputCost",
header: "Output Cost",
size: 120,
cell: ({ row }) => {
const value: Decimal | undefined = row.getValue("outputCost");
@@ -398,6 +416,7 @@ export default function GenerationsTable({
accessorKey: "totalCost",
header: "Total Cost",
id: "totalCost",
size: 120,
cell: ({ row }) => {
const value: Decimal | undefined = row.getValue("totalCost");
@@ -412,6 +431,12 @@ export default function GenerationsTable({
accessorKey: "level",
id: "level",
header: "Level",
size: 100,
headerTooltip: {
description:
"Use You can differentiate the importance of observations with the level attribute to control the verbosity of your traces and highlight errors and warnings.",
href: "https://langfuse.com/docs/tracing-features/log-levels",
},
enableHiding: true,
cell({ row }) {
const value: ObservationLevel | undefined = row.getValue("level");
@@ -433,6 +458,12 @@ export default function GenerationsTable({
accessorKey: "statusMessage",
header: "Status Message",
id: "statusMessage",
size: 150,
headerTooltip: {
description:
"Use a statusMessage to e.g. provide additional information on a status such as level=ERROR.",
href: "https://langfuse.com/docs/tracing-features/log-levels",
},
enableHiding: true,
defaultHidden: true,
},
@@ -440,6 +471,7 @@ export default function GenerationsTable({
accessorKey: "model",
id: "model",
header: "Model",
size: 150,
enableHiding: true,
enableSorting: true,
},
@@ -447,6 +479,7 @@ export default function GenerationsTable({
accessorKey: "inputTokens",
id: "inputTokens",
header: "Input Tokens",
size: 100,
enableHiding: true,
defaultHidden: true,
enableSorting: true,
@@ -463,6 +496,7 @@ export default function GenerationsTable({
accessorKey: "outputTokens",
id: "outputTokens",
header: "Output Tokens",
size: 100,
enableHiding: true,
defaultHidden: true,
enableSorting: true,
@@ -479,6 +513,7 @@ export default function GenerationsTable({
accessorKey: "totalTokens",
id: "totalTokens",
header: "Total Tokens",
size: 100,
enableHiding: true,
defaultHidden: true,
enableSorting: true,
@@ -495,6 +530,7 @@ export default function GenerationsTable({
accessorKey: "usage",
header: "Usage",
id: "usage",
size: 150,
cell: ({ row }) => {
const value: {
promptTokens: number;
@@ -517,6 +553,7 @@ export default function GenerationsTable({
accessorKey: "input",
header: "Input",
id: "input",
size: 300,
cell: ({ row }) => {
const observationId: string = row.getValue("id");
const traceId: string = row.getValue("traceId");
@@ -536,6 +573,7 @@ export default function GenerationsTable({
accessorKey: "output",
id: "output",
header: "Output",
size: 300,
cell: ({ row }) => {
const observationId: string = row.getValue("id");
const traceId: string = row.getValue("traceId");
@@ -554,6 +592,11 @@ export default function GenerationsTable({
{
accessorKey: "metadata",
header: "Metadata",
size: 300,
headerTooltip: {
description: "Add metadata to traces to track additional information.",
href: "https://langfuse.com/docs/tracing-features/metadata",
},
cell: ({ row }) => {
const observationId: string = row.getValue("id");
const traceId: string = row.getValue("traceId");
@@ -573,6 +616,11 @@ export default function GenerationsTable({
accessorKey: "version",
id: "version",
header: "Version",
size: 100,
headerTooltip: {
description: "Track changes via the version tag.",
href: "https://langfuse.com/docs/experimentation",
},
enableHiding: true,
enableSorting: true,
},
@@ -580,6 +628,11 @@ export default function GenerationsTable({
accessorKey: "promptName",
id: "promptName",
header: "Prompt",
headerTooltip: {
description: "Link to prompt version in Langfuse prompt management.",
href: "https://langfuse.com/docs/prompts",
},
size: 200,
enableHiding: true,
enableSorting: true,
cell: ({ row }) => {
@@ -592,49 +645,54 @@ export default function GenerationsTable({
<TableLink
path={`/project/${projectId}/prompts/${encodeURIComponent(promptName)}?version=${promptVersion}`}
value={value}
truncateAt={40}
/>
)
);
},
},
];
const [columnVisibility, setColumnVisibilityState] =
useColumnVisibility<GenerationsTableRow>(
"generationsColumnVisibility",
`generationsColumnVisibility-${projectId}`,
columns,
);
const rows: GenerationsTableRow[] = generations.isSuccess
? generations.data.generations.map((generation) => {
return {
id: generation.id,
traceId: generation.traceId ?? undefined,
traceName: generation.traceName ?? "",
startTime: generation.startTime,
endTime: generation.endTime?.toLocaleString() ?? undefined,
timeToFirstToken: generation.timeToFirstToken ?? undefined,
latency: generation.latency ?? undefined,
totalCost: generation.calculatedTotalCost ?? undefined,
inputCost: generation.calculatedInputCost ?? undefined,
outputCost: generation.calculatedOutputCost ?? undefined,
name: generation.name ?? undefined,
version: generation.version ?? "",
model: generation.model ?? "",
scores: generation.scores,
level: generation.level,
statusMessage: generation.statusMessage ?? undefined,
usage: {
promptTokens: generation.promptTokens,
completionTokens: generation.completionTokens,
totalTokens: generation.totalTokens,
},
promptId: generation.promptId ?? undefined,
promptName: generation.promptName ?? undefined,
promptVersion: generation.promptVersion ?? undefined,
};
})
: [];
const rows: GenerationsTableRow[] = useMemo(() => {
return generations.isSuccess
? generations.data.generations.map((generation) => {
return {
id: generation.id,
traceId: generation.traceId ?? undefined,
traceName: generation.traceName ?? "",
startTime: generation.startTime,
endTime: generation.endTime?.toLocaleString() ?? undefined,
timeToFirstToken: generation.timeToFirstToken ?? undefined,
scores: verifyAndPrefixScoreDataAgainstKeys(
scoreKeysAndProps,
generation.scores,
),
latency: generation.latency ?? undefined,
totalCost: generation.calculatedTotalCost ?? undefined,
inputCost: generation.calculatedInputCost ?? undefined,
outputCost: generation.calculatedOutputCost ?? undefined,
name: generation.name ?? undefined,
version: generation.version ?? "",
model: generation.model ?? "",
level: generation.level,
statusMessage: generation.statusMessage ?? undefined,
usage: {
promptTokens: generation.promptTokens,
completionTokens: generation.completionTokens,
totalTokens: generation.totalTokens,
},
promptId: generation.promptId ?? undefined,
promptName: generation.promptName ?? undefined,
promptVersion: generation.promptVersion ?? undefined,
};
})
: [];
}, [generations, scoreKeysAndProps]);
return (
<>
@@ -642,7 +700,7 @@ export default function GenerationsTable({
columns={columns}
filterColumnDefinition={transformFilterOptions(filterOptions.data)}
filterState={inputFilterState}
setFilterState={setInputFilterState}
setFilterState={useDebounce(setInputFilterState)}
searchConfig={{
placeholder: "Search by id, name, traceName, model",
updateQuery: setSearchQuery,
@@ -652,6 +710,8 @@ export default function GenerationsTable({
setColumnVisibility={setColumnVisibilityState}
rowHeight={rowHeight}
setRowHeight={setRowHeight}
selectedOption={selectedOption}
setDateRangeAndOption={setDateRangeAndOption}
actionButtons={
<DropdownMenu>
<DropdownMenuTrigger asChild>
+60 -35
View File
@@ -17,6 +17,9 @@ import Decimal from "decimal.js";
import { Trash } from "lucide-react";
import { useQueryParams, withDefault, NumberParam } from "use-query-params";
import { cn } from "@/src/utils/tailwind";
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
export type ModelTableRow = {
modelId: string;
@@ -62,12 +65,15 @@ export default function ModelTable({ projectId }: { projectId: string }) {
});
const totalCount = models.data?.totalCount ?? 0;
const [rowHeight, setRowHeight] = useRowHeightLocalStorage("models", "s");
const columns: LangfuseColumnDef<ModelTableRow>[] = [
{
accessorKey: "maintainer",
id: "maintainer",
enableColumnFilter: true,
header: "Maintainer",
size: 100,
},
{
accessorKey: "modelName",
@@ -76,6 +82,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
headerTooltip: {
description: modelConfigDescriptions.modelName,
},
size: 150,
},
{
accessorKey: "startDate",
@@ -84,6 +91,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
headerTooltip: {
description: modelConfigDescriptions.startDate,
},
size: 100,
cell: ({ row }) => {
const value: Date | undefined = row.getValue("startDate");
@@ -101,14 +109,13 @@ export default function ModelTable({ projectId }: { projectId: string }) {
description: modelConfigDescriptions.matchPattern,
},
header: "Match Pattern",
size: 200,
cell: ({ row }) => {
const value: string = row.getValue("matchPattern");
return (
<code className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-xs ">
{value}
</code>
);
return value ? (
<IOTableCell data={value} singleLine={rowHeight === "s"} />
) : null;
},
},
{
@@ -125,6 +132,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
headerTooltip: {
description: modelConfigDescriptions.inputPrice,
},
size: 170,
cell: ({ row }) => {
const value: Decimal | undefined = row.getValue("inputPrice");
@@ -136,6 +144,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
<span className="text-xs">-</span>
);
},
enableHiding: true,
},
{
accessorKey: "outputPrice",
@@ -151,6 +160,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
</>
);
},
size: 170,
cell: ({ row }) => {
const value: Decimal | undefined = row.getValue("outputPrice");
@@ -162,6 +172,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
<span className="text-xs">-</span>
);
},
enableHiding: true,
},
{
accessorKey: "totalPrice",
@@ -177,6 +188,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
headerTooltip: {
description: modelConfigDescriptions.totalPrice,
},
size: 170,
cell: ({ row }) => {
const value: Decimal | undefined = row.getValue("totalPrice");
@@ -188,6 +200,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
<span className="text-xs">-</span>
);
},
enableHiding: true,
},
{
accessorKey: "unit",
@@ -197,6 +210,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
description: modelConfigDescriptions.unit,
},
enableHiding: true,
size: 110,
},
{
accessorKey: "tokenizerId",
@@ -206,6 +220,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
description: modelConfigDescriptions.tokenizerId,
},
enableHiding: true,
size: 110,
},
{
accessorKey: "config",
@@ -215,19 +230,19 @@ export default function ModelTable({ projectId }: { projectId: string }) {
description: modelConfigDescriptions.config,
},
enableHiding: true,
size: 200,
cell: ({ row }) => {
const value: Prisma.JsonValue | undefined = row.getValue("config");
return value ? (
<span className="text-xs">{JSON.stringify(value)}</span>
) : (
<span className="text-xs">-</span>
);
<IOTableCell data={value} singleLine={rowHeight === "s"} />
) : null;
},
},
{
accessorKey: "actions",
header: "Actions",
size: 70,
cell: ({ row }) => {
return (
<DeleteModelButton
@@ -241,7 +256,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
];
const [columnVisibility, setColumnVisibility] =
useColumnVisibility<ModelTableRow>("scoresColumnVisibility", columns);
useColumnVisibility<ModelTableRow>("modelsColumnVisibility", columns);
const convertToTableRow = (model: Model): ModelTableRow => {
return {
@@ -262,31 +277,41 @@ export default function ModelTable({ projectId }: { projectId: string }) {
};
return (
<DataTable
columns={columns}
data={
models.isLoading
? { isLoading: true, isError: false }
: models.isError
? {
isLoading: false,
isError: true,
error: models.error.message,
}
: {
isLoading: false,
isError: false,
data: models.data.models.map((t) => convertToTableRow(t)),
}
}
pagination={{
pageCount: Math.ceil(totalCount / paginationState.pageSize),
onChange: setPaginationState,
state: paginationState,
}}
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
/>
<>
<DataTableToolbar
columns={columns}
columnVisibility={columnVisibility}
setColumnVisibility={setColumnVisibility}
rowHeight={rowHeight}
setRowHeight={setRowHeight}
/>
<DataTable
columns={columns}
data={
models.isLoading
? { isLoading: true, isError: false }
: models.isError
? {
isLoading: false,
isError: true,
error: models.error.message,
}
: {
isLoading: false,
isError: false,
data: models.data.models.map((t) => convertToTableRow(t)),
}
}
pagination={{
pageCount: Math.ceil(totalCount / paginationState.pageSize),
onChange: setPaginationState,
state: paginationState,
}}
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
rowHeight={rowHeight}
/>
</>
);
}
@@ -13,7 +13,7 @@ import {
isBooleanDataType,
isCategoricalDataType,
isNumericDataType,
} from "@/src/features/manual-scoring/lib/helpers";
} from "@/src/features/scores/lib/helpers";
import { Archive } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
@@ -107,6 +107,7 @@ export function ScoreConfigsTable({ projectId }: { projectId: string }) {
accessorKey: "dataType",
id: "dataType",
header: "Data Type",
size: 80,
enableHiding: true,
},
{
@@ -114,6 +115,7 @@ export function ScoreConfigsTable({ projectId }: { projectId: string }) {
id: "range",
header: "Range",
enableHiding: true,
size: 300,
cell: ({ row }) => {
const range = getConfigRange(row.original);
@@ -153,6 +155,7 @@ export function ScoreConfigsTable({ projectId }: { projectId: string }) {
accessorKey: "isArchived",
id: "isArchived",
header: "Status",
size: 80,
enableHiding: true,
cell: ({ row }) => {
const { isArchived } = row.original;
@@ -162,6 +165,7 @@ export function ScoreConfigsTable({ projectId }: { projectId: string }) {
{
accessorKey: "action",
header: "Action",
size: 70,
enableHiding: true,
cell: ({ row }) => {
const { id: configId, isArchived, name } = row.original;
+49 -31
View File
@@ -7,15 +7,15 @@ import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
import { Avatar, AvatarImage } from "@/src/components/ui/avatar";
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
import { isNumericDataType } from "@/src/features/manual-scoring/lib/helpers";
import { isNumericDataType } from "@/src/features/scores/lib/helpers";
import { useOrderByState } from "@/src/features/orderBy/hooks/useOrderByState";
import { useTableLookBackDays } from "@/src/hooks/useTableLookBackDays";
import { useDebounce } from "@/src/hooks/useDebounce";
import { useTableDateRange } from "@/src/hooks/useTableDateRange";
import {
type ScoreOptions,
scoresTableColsWithOptions,
} from "@/src/server/api/definitions/scoresTable";
import { api } from "@/src/utils/api";
import { localtimeDateOffsetByDays } from "@/src/utils/dates";
import { isPresent } from "@/src/utils/typeChecks";
import type { RouterOutput, RouterInput } from "@/src/utils/types";
import type { FilterState, ScoreDataType } from "@langfuse/shared";
@@ -84,20 +84,27 @@ export default function ScoresTable({
});
const [rowHeight, setRowHeight] = useRowHeightLocalStorage("scores", "s");
const { selectedOption, dateRange, setDateRangeAndOption } =
useTableDateRange();
const [userFilterState, setUserFilterState] = useQueryFilterState(
[
{
column: "Timestamp",
type: "datetime",
operator: ">",
value: localtimeDateOffsetByDays(-useTableLookBackDays(projectId)),
},
],
[],
"scores",
);
const filterState = createFilterState(userFilterState, [
const dateRangeFilter: FilterState = dateRange
? [
{
column: "Timestamp",
type: "datetime",
operator: ">=",
value: dateRange.from,
},
]
: [];
const combinedFilter = userFilterState.concat(dateRangeFilter);
const filterState = createFilterState(combinedFilter, [
...(userId ? [{ key: "User ID", value: userId }] : []),
...(traceId ? [{ key: "Trace ID", value: traceId }] : []),
...(observationId ? [{ key: "Observation ID", value: observationId }] : []),
@@ -135,8 +142,9 @@ export default function ScoresTable({
accessorKey: "traceId",
id: "traceId",
enableColumnFilter: true,
header: "Trace ID",
header: "Trace",
enableSorting: true,
size: 100,
cell: ({ row }) => {
const value = row.getValue("traceId");
return typeof value === "string" ? (
@@ -152,8 +160,9 @@ export default function ScoresTable({
{
accessorKey: "observationId",
id: "observationId",
header: "Observation ID",
header: "Observation",
enableSorting: true,
size: 100,
cell: ({ row }) => {
const observationId = row.getValue(
"observationId",
@@ -173,6 +182,7 @@ export default function ScoresTable({
id: "traceName",
enableHiding: true,
enableSorting: true,
size: 150,
cell: ({ row }) => {
const value = row.getValue("traceName") as ScoresTableRow["traceName"];
const filter = encodeURIComponent(
@@ -182,14 +192,13 @@ export default function ScoresTable({
<TableLink
path={`/project/${projectId}/traces?filter=${value ? filter : ""}`}
value={value}
truncateAt={40}
/>
) : undefined;
},
},
{
accessorKey: "userId",
header: "Trace User ID",
header: "User",
id: "userId",
headerTooltip: {
description: "The user ID associated with the trace.",
@@ -197,6 +206,7 @@ export default function ScoresTable({
},
enableHiding: true,
enableSorting: true,
size: 100,
cell: ({ row }) => {
const value = row.getValue("userId");
return typeof value === "string" ? (
@@ -204,7 +214,6 @@ export default function ScoresTable({
<TableLink
path={`/project/${projectId}/users/${value}`}
value={value}
truncateAt={40}
/>
</>
) : undefined;
@@ -216,6 +225,7 @@ export default function ScoresTable({
id: "timestamp",
enableHiding: true,
enableSorting: true,
size: 150,
},
{
accessorKey: "source",
@@ -223,6 +233,7 @@ export default function ScoresTable({
id: "source",
enableHiding: true,
enableSorting: true,
size: 100,
},
{
accessorKey: "name",
@@ -230,6 +241,7 @@ export default function ScoresTable({
id: "name",
enableHiding: true,
enableSorting: true,
size: 150,
},
{
accessorKey: "dataType",
@@ -237,6 +249,7 @@ export default function ScoresTable({
id: "dataType",
enableHiding: true,
enableSorting: true,
size: 100,
},
{
accessorKey: "value",
@@ -244,12 +257,27 @@ export default function ScoresTable({
id: "value",
enableHiding: true,
enableSorting: true,
size: 100,
},
{
accessorKey: "comment",
header: "Comment",
id: "comment",
enableHiding: true,
size: 400,
cell: ({ row }) => {
const value = row.getValue("comment") as ScoresTableRow["comment"];
return (
!!value && <IOTableCell data={value} singleLine={rowHeight === "s"} />
);
},
},
{
accessorKey: "author",
id: "author",
header: "Author",
enableHiding: true,
size: 150,
cell: ({ row }) => {
const { name, image } = row.getValue(
"author",
@@ -277,6 +305,7 @@ export default function ScoresTable({
},
enableHiding: true,
enableSorting: true,
size: 150,
cell: ({ row }) => {
const value = row.getValue("jobConfigurationId");
return typeof value === "string" ? (
@@ -284,24 +313,11 @@ export default function ScoresTable({
<TableLink
path={`/project/${projectId}/evals/configs/${value}`}
value={value}
truncateAt={40}
/>
</>
) : undefined;
},
},
{
accessorKey: "comment",
header: "Comment",
id: "comment",
enableHiding: true,
cell: ({ row }) => {
const value = row.getValue("comment") as ScoresTableRow["comment"];
return (
!!value && <IOTableCell data={value} singleLine={rowHeight === "s"} />
);
},
},
];
const columns = rawColumns.filter(
@@ -353,11 +369,13 @@ export default function ScoresTable({
columns={columns}
filterColumnDefinition={transformFilterOptions(filterOptions.data)}
filterState={userFilterState}
setFilterState={setUserFilterState}
setFilterState={useDebounce(setUserFilterState)}
columnVisibility={columnVisibility}
setColumnVisibility={setColumnVisibility}
rowHeight={rowHeight}
setRowHeight={setRowHeight}
selectedOption={selectedOption}
setDateRangeAndOption={setDateRangeAndOption}
/>
<DataTable
columns={columns}
+38 -18
View File
@@ -14,17 +14,15 @@ import {
import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context";
import { useOrderByState } from "@/src/features/orderBy/hooks/useOrderByState";
import { api } from "@/src/utils/api";
import {
formatIntervalSeconds,
localtimeDateOffsetByDays,
} from "@/src/utils/dates";
import { formatIntervalSeconds } from "@/src/utils/dates";
import { numberFormatter, usdFormatter } from "@/src/utils/numbers";
import { type RouterOutput } from "@/src/utils/types";
import type Decimal from "decimal.js";
import { useEffect } from "react";
import { NumberParam, useQueryParams, withDefault } from "use-query-params";
import { useTableLookBackDays } from "@/src/hooks/useTableLookBackDays";
import { BatchExportTableButton } from "@/src/components/BatchExportTableButton";
import { useTableDateRange } from "@/src/hooks/useTableDateRange";
import { useDebounce } from "@/src/hooks/useDebounce";
export type SessionTableRow = {
id: string;
@@ -53,16 +51,11 @@ export default function SessionsTable({
omittedFilter = [],
}: SessionTableProps) {
const { setDetailPageList } = useDetailPageLists();
const { selectedOption, dateRange, setDateRangeAndOption } =
useTableDateRange();
const [userFilterState, setUserFilterState] = useQueryFilterState(
[
{
column: "Created At",
type: "datetime",
operator: ">",
value: localtimeDateOffsetByDays(-useTableLookBackDays(projectId)),
},
],
[],
"sessions",
);
@@ -77,7 +70,18 @@ export default function SessionsTable({
]
: [];
const filterState = userFilterState.concat(userIdFilter);
const dateRangeFilter: FilterState = dateRange
? [
{
column: "createdAt",
type: "datetime",
operator: ">=",
value: dateRange.from,
},
]
: [];
const filterState = userFilterState.concat(userIdFilter, dateRangeFilter);
const [paginationState, setPaginationState] = useQueryParams({
pageIndex: withDefault(NumberParam, 0),
@@ -145,6 +149,7 @@ export default function SessionsTable({
accessorKey: "bookmarked",
id: "bookmarked",
header: undefined,
size: 50,
cell: ({ row }) => {
const bookmarked = row.getValue("bookmarked");
const sessionId = row.getValue("id");
@@ -165,13 +170,13 @@ export default function SessionsTable({
accessorKey: "id",
id: "id",
header: "ID",
size: 200,
cell: ({ row }) => {
const value = row.getValue("id");
return value && typeof value === "string" ? (
<TableLink
path={`/project/${projectId}/sessions/${encodeURIComponent(value)}`}
value={value}
truncateAt={40}
/>
) : undefined;
},
@@ -181,6 +186,7 @@ export default function SessionsTable({
accessorKey: "createdAt",
id: "createdAt",
header: "Created At",
size: 150,
enableHiding: true,
enableSorting: true,
},
@@ -188,6 +194,7 @@ export default function SessionsTable({
accessorKey: "sessionDuration",
id: "sessionDuration",
header: "Duration",
size: 130,
enableHiding: true,
cell: ({ row }) => {
const value = row.getValue("sessionDuration");
@@ -202,6 +209,7 @@ export default function SessionsTable({
enableColumnFilter: !omittedFilter.find((f) => f === "userIds"),
id: "userIds",
header: "User IDs",
size: 200,
enableHiding: true,
cell: ({ row }) => {
const value = row.getValue("userIds");
@@ -212,7 +220,6 @@ export default function SessionsTable({
key={user}
path={`/project/${projectId}/users/${encodeURIComponent(user)}`}
value={user}
truncateAt={40}
/>
))}
</div>
@@ -222,7 +229,11 @@ export default function SessionsTable({
{
accessorKey: "countTraces",
id: "countTraces",
header: "Traces Count",
header: "Traces",
size: 100,
headerTooltip: {
description: "The number of traces in the session.",
},
enableHiding: true,
enableSorting: true,
},
@@ -230,6 +241,7 @@ export default function SessionsTable({
accessorKey: "inputCost",
id: "inputCost",
header: "Input Cost",
size: 110,
enableHiding: true,
defaultHidden: true,
enableSorting: true,
@@ -244,6 +256,7 @@ export default function SessionsTable({
accessorKey: "outputCost",
id: "outputCost",
header: "Output Cost",
size: 110,
enableHiding: true,
enableSorting: true,
defaultHidden: true,
@@ -259,6 +272,7 @@ export default function SessionsTable({
accessorKey: "totalCost",
id: "totalCost",
header: "Total Cost",
size: 110,
enableHiding: true,
enableSorting: true,
cell: ({ row }) => {
@@ -273,6 +287,7 @@ export default function SessionsTable({
accessorKey: "inputTokens",
id: "inputTokens",
header: "Input Tokens",
size: 110,
enableHiding: true,
defaultHidden: true,
enableSorting: true,
@@ -288,6 +303,7 @@ export default function SessionsTable({
accessorKey: "outputTokens",
id: "outputTokens",
header: "Output Tokens",
size: 110,
enableHiding: true,
defaultHidden: true,
enableSorting: true,
@@ -303,6 +319,7 @@ export default function SessionsTable({
accessorKey: "totalTokens",
id: "totalTokens",
header: "Total Tokens",
size: 110,
enableHiding: true,
defaultHidden: true,
enableSorting: true,
@@ -317,6 +334,7 @@ export default function SessionsTable({
accessorKey: "usage",
id: "usage",
header: "Usage",
size: 220,
enableHiding: true,
enableSorting: true,
cell: ({ row }) => {
@@ -349,7 +367,7 @@ export default function SessionsTable({
<DataTableToolbar
filterColumnDefinition={transformFilterOptions()}
filterState={userFilterState}
setFilterState={setUserFilterState}
setFilterState={useDebounce(setUserFilterState)}
columns={columns}
columnVisibility={columnVisibility}
setColumnVisibility={setColumnVisibility}
@@ -360,6 +378,8 @@ export default function SessionsTable({
key="batchExport"
/>,
]}
selectedOption={selectedOption}
setDateRangeAndOption={setDateRangeAndOption}
columnsWithCustomSelect={["userIds"]}
/>
<DataTable
+134 -69
View File
@@ -1,4 +1,3 @@
import { GroupedScoreBadges } from "@/src/components/grouped-score-badge";
import { StarTraceToggle } from "@/src/components/star-toggle";
import { DataTable } from "@/src/components/table/data-table";
import { TraceTableMultiSelectAction } from "@/src/components/table/data-table-multi-select-actions/trace-table-multi-select-action";
@@ -11,13 +10,10 @@ import { Checkbox } from "@/src/components/ui/checkbox";
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
import { api } from "@/src/utils/api";
import {
formatIntervalSeconds,
localtimeDateOffsetByDays,
} from "@/src/utils/dates";
import { type RouterInput, type RouterOutput } from "@/src/utils/types";
import { formatIntervalSeconds } from "@/src/utils/dates";
import { type RouterInput } from "@/src/utils/types";
import { type RowSelectionState } from "@tanstack/react-table";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
NumberParam,
StringParam,
@@ -40,8 +36,14 @@ import {
} from "@langfuse/shared";
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
import { useTableLookBackDays } from "@/src/hooks/useTableLookBackDays";
import { type APIScore } from "@/src/features/public-api/types/scores";
import {
getScoreGroupColumnProps,
verifyAndPrefixScoreDataAgainstKeys,
} from "@/src/features/scores/components/ScoreDetailColumnHelpers";
import { useTableDateRange } from "@/src/hooks/useTableDateRange";
import { useDebounce } from "@/src/hooks/useDebounce";
import { type ScoreAggregate } from "@/src/features/scores/lib/types";
import { useIndividualScoreColumns } from "@/src/features/scores/hooks/useIndividualScoreColumns";
export type TracesTableRow = {
bookmarked: boolean;
@@ -51,6 +53,8 @@ export type TracesTableRow = {
userId: string;
level: ObservationLevel;
observationCount: number;
// scores holds grouped column with individual scores
scores: ScoreAggregate;
latency?: number;
release?: string;
version?: string;
@@ -59,7 +63,6 @@ export type TracesTableRow = {
input?: unknown;
output?: unknown;
metadata?: unknown;
scores: APIScore[];
tags: string[];
usage: {
promptTokens: bigint;
@@ -92,15 +95,10 @@ export default function TracesTable({
withDefault(StringParam, null),
);
const { selectedOption, dateRange, setDateRangeAndOption } =
useTableDateRange();
const [userFilterState, setUserFilterState] = useQueryFilterState(
[
{
column: "Timestamp",
type: "datetime",
operator: ">",
value: localtimeDateOffsetByDays(-useTableLookBackDays(projectId)),
},
],
[],
"traces",
);
const [orderByState, setOrderByState] = useOrderByState({
@@ -108,6 +106,16 @@ export default function TracesTable({
order: "DESC",
});
const dateRangeFilter: FilterState = dateRange
? [
{
column: "Timestamp",
type: "datetime",
operator: ">=",
value: dateRange.from,
},
]
: [];
const userIdFilter: FilterState = userId
? [
{
@@ -119,11 +127,12 @@ export default function TracesTable({
]
: [];
const filterState = userFilterState.concat(userIdFilter);
const filterState = userFilterState.concat(userIdFilter, dateRangeFilter);
const [paginationState, setPaginationState] = useQueryParams({
pageIndex: withDefault(NumberParam, 0),
pageSize: withDefault(NumberParam, 50),
});
const tracesAllQueryFilter = {
page: paginationState.pageIndex,
limit: paginationState.pageSize,
@@ -148,12 +157,13 @@ export default function TracesTable({
// loading filter options individually from the remaining calls
// traces.all should load first together with everything else.
// This here happens in the background.
const timestampFilter = filterState.find((f) => f.column === "Timestamp");
const traceFilterOptions = api.traces.filterOptions.useQuery(
{
projectId,
timestampFilter:
timestampFilter?.type === "datetime" ? timestampFilter : undefined,
dateRangeFilter[0]?.type === "datetime"
? dateRangeFilter[0]
: undefined,
},
{
trpc: {
@@ -172,40 +182,19 @@ export default function TracesTable({
);
};
const convertToTableRow = (
trace: RouterOutput["traces"]["all"]["traces"][0],
): TracesTableRow => {
return {
bookmarked: trace.bookmarked,
id: trace.id,
timestamp: trace.timestamp.toLocaleString(),
name: trace.name ?? "",
level: trace.level,
observationCount: trace.observationCount,
release: trace.release ?? undefined,
version: trace.version ?? undefined,
userId: trace.userId ?? "",
scores: trace.scores,
sessionId: trace.sessionId ?? undefined,
latency: trace.latency === null ? undefined : trace.latency,
tags: trace.tags,
usage: {
promptTokens: trace.promptTokens,
completionTokens: trace.completionTokens,
totalTokens: trace.totalTokens,
},
inputCost: trace.calculatedInputCost ?? undefined,
outputCost: trace.calculatedOutputCost ?? undefined,
totalCost: trace.calculatedTotalCost ?? undefined,
};
};
const [rowHeight, setRowHeight] = useRowHeightLocalStorage("traces", "s");
const { scoreColumns, scoreKeysAndProps, isColumnLoading } =
useIndividualScoreColumns<TracesTableRow>({
projectId,
scoreColumnKey: "scores",
selectedTimeOption: selectedOption,
});
const columns: LangfuseColumnDef<TracesTableRow>[] = [
{
id: "select",
accessorKey: "select",
size: 30,
header: ({ table }) => (
<Checkbox
checked={
@@ -238,6 +227,7 @@ export default function TracesTable({
accessorKey: "bookmarked",
header: undefined,
id: "bookmarked",
size: 30,
cell: ({ row }) => {
const bookmarked: TracesTableRow["bookmarked"] =
row.getValue("bookmarked");
@@ -259,6 +249,7 @@ export default function TracesTable({
accessorKey: "id",
header: "ID",
id: "id",
size: 90,
cell: ({ row }) => {
const value: TracesTableRow["id"] = row.getValue("id");
return value && typeof value === "string" ? (
@@ -274,6 +265,7 @@ export default function TracesTable({
accessorKey: "timestamp",
header: "Timestamp",
id: "timestamp",
size: 150,
enableHiding: true,
enableSorting: true,
},
@@ -281,20 +273,25 @@ export default function TracesTable({
accessorKey: "name",
header: "Name",
id: "name",
size: 150,
enableHiding: true,
enableSorting: true,
},
{
accessorKey: "userId",
header: "User ID",
header: "User",
id: "userId",
size: 150,
headerTooltip: {
description: "Add `userId` to traces to track users.",
href: "https://langfuse.com/docs/tracing-features/users",
},
cell: ({ row }) => {
const value: TracesTableRow["userId"] = row.getValue("userId");
return value && typeof value === "string" ? (
<TableLink
path={`/project/${projectId}/users/${encodeURIComponent(value)}`}
value={value}
truncateAt={40}
/>
) : undefined;
},
@@ -305,14 +302,18 @@ export default function TracesTable({
accessorKey: "sessionId",
enableColumnFilter: !omittedFilter.find((f) => f === "sessionId"),
id: "sessionId",
header: "Session ID",
header: "Session",
size: 150,
headerTooltip: {
description: "Add `sessionId` to traces to track sessions.",
href: "https://langfuse.com/docs/tracing-features/sessions",
},
cell: ({ row }) => {
const value: TracesTableRow["sessionId"] = row.getValue("sessionId");
return value && typeof value === "string" ? (
<TableLink
path={`/project/${projectId}/sessions/${encodeURIComponent(value)}`}
value={value}
truncateAt={40}
/>
) : undefined;
},
@@ -323,6 +324,7 @@ export default function TracesTable({
accessorKey: "latency",
id: "latency",
header: "Latency",
size: 70,
// add seconds to the end of the latency
cell: ({ row }) => {
const value: TracesTableRow["latency"] = row.getValue("latency");
@@ -335,6 +337,7 @@ export default function TracesTable({
accessorKey: "inputTokens",
id: "inputTokens",
header: "Input Tokens",
size: 110,
cell: ({ row }) => {
const value: TracesTableRow["usage"] = row.getValue("usage");
return <span>{numberFormatter(value.promptTokens, 0)}</span>;
@@ -347,6 +350,7 @@ export default function TracesTable({
accessorKey: "outputTokens",
id: "outputTokens",
header: "Output Tokens",
size: 110,
cell: ({ row }) => {
const value: TracesTableRow["usage"] = row.getValue("usage");
return <span>{numberFormatter(value.completionTokens, 0)}</span>;
@@ -359,6 +363,7 @@ export default function TracesTable({
accessorKey: "totalTokens",
id: "totalTokens",
header: "Total Tokens",
size: 110,
cell: ({ row }) => {
const value: TracesTableRow["usage"] = row.getValue("usage");
return <span>{numberFormatter(value.totalTokens, 0)}</span>;
@@ -368,10 +373,10 @@ export default function TracesTable({
enableSorting: true,
},
{
// TODO: Enable Ordering By Usage (not covered by API yet)
accessorKey: "usage",
header: "Usage",
id: "usage",
size: 220,
cell: ({ row }) => {
const value: TracesTableRow["usage"] = row.getValue("usage");
return (
@@ -386,10 +391,12 @@ export default function TracesTable({
enableSorting: true,
enableHiding: true,
},
{ ...getScoreGroupColumnProps(isColumnLoading), columns: scoreColumns },
{
accessorKey: "inputCost",
id: "inputCost",
header: "Input Cost",
size: 100,
cell: ({ row }) => {
const cost: TracesTableRow["inputCost"] = row.getValue("inputCost");
return (
@@ -410,6 +417,7 @@ export default function TracesTable({
accessorKey: "outputCost",
id: "outputCost",
header: "Output Cost",
size: 100,
cell: ({ row }) => {
const cost: TracesTableRow["outputCost"] = row.getValue("outputCost");
return (
@@ -430,6 +438,7 @@ export default function TracesTable({
accessorKey: "totalCost",
id: "totalCost",
header: "Total Cost",
size: 100,
cell: ({ row }) => {
const cost: TracesTableRow["totalCost"] = row.getValue("totalCost");
return (
@@ -445,21 +454,11 @@ export default function TracesTable({
enableHiding: true,
enableSorting: true,
},
{
accessorKey: "scores",
id: "scores",
header: "Scores",
enableColumnFilter: !omittedFilter.find((f) => f === "scores"),
cell: ({ row }) => {
const values: TracesTableRow["scores"] = row.getValue("scores");
return <GroupedScoreBadges scores={values} variant="headings" />;
},
enableHiding: true,
},
{
accessorKey: "input",
header: "Input",
id: "input",
size: 400,
cell: ({ row }) => {
const traceId: TracesTableRow["id"] = row.getValue("id");
return (
@@ -477,6 +476,7 @@ export default function TracesTable({
accessorKey: "output",
header: "Output",
id: "output",
size: 400,
cell: ({ row }) => {
const traceId: TracesTableRow["id"] = row.getValue("id");
return (
@@ -493,6 +493,11 @@ export default function TracesTable({
{
accessorKey: "metadata",
header: "Metadata",
size: 400,
headerTooltip: {
description: "Add metadata to traces to track additional information.",
href: "https://langfuse.com/docs/tracing-features/metadata",
},
cell: ({ row }) => {
const traceId: TracesTableRow["id"] = row.getValue("id");
return (
@@ -510,6 +515,7 @@ export default function TracesTable({
accessorKey: "level",
id: "level",
header: "Level",
size: 75,
cell: ({ row }) => {
const value: TracesTableRow["level"] = row.getValue("level");
return (
@@ -531,7 +537,11 @@ export default function TracesTable({
{
accessorKey: "observationCount",
id: "observationCount",
header: "Observation Count",
header: "Observations",
size: 120,
headerTooltip: {
description: "The number of observations in the trace.",
},
enableHiding: true,
defaultHidden: true,
},
@@ -539,6 +549,11 @@ export default function TracesTable({
accessorKey: "version",
id: "version",
header: "Version",
size: 100,
headerTooltip: {
description: "Track changes via the version tag.",
href: "https://langfuse.com/docs/experimentation",
},
enableHiding: true,
enableSorting: true,
defaultHidden: true,
@@ -547,6 +562,11 @@ export default function TracesTable({
accessorKey: "release",
id: "release",
header: "Release",
size: 100,
headerTooltip: {
description: "Track changes to your application via the release tag.",
href: "https://langfuse.com/docs/experimentation",
},
enableHiding: true,
enableSorting: true,
defaultHidden: true,
@@ -555,6 +575,11 @@ export default function TracesTable({
accessorKey: "tags",
id: "tags",
header: "Tags",
size: 150,
headerTooltip: {
description: "Group traces with tags.",
href: "https://langfuse.com/docs/tracing-features/tags",
},
cell: ({ row }) => {
const tags: TracesTableRow["tags"] = row.getValue("tags");
const traceId: TracesTableRow["id"] = row.getValue("id");
@@ -567,6 +592,7 @@ export default function TracesTable({
projectId={projectId}
traceId={traceId}
tracesFilter={tracesAllQueryFilter}
className={cn(rowHeight !== "s" && "flex-wrap")}
/>
);
},
@@ -575,6 +601,7 @@ export default function TracesTable({
{
accessorKey: "action",
header: "Action",
size: 70,
cell: ({ row }) => {
const traceId: TracesTableRow["id"] = row.getValue("id");
return traceId && typeof traceId === "string" ? (
@@ -592,7 +619,43 @@ export default function TracesTable({
];
const [columnVisibility, setColumnVisibility] =
useColumnVisibility<TracesTableRow>("tracesColumnVisibility", columns);
useColumnVisibility<TracesTableRow>(
`tracesColumnVisibility-${projectId}`,
columns,
);
const rows = useMemo(() => {
return traces.isSuccess
? traces.data.traces.map((trace) => {
return {
bookmarked: trace.bookmarked,
id: trace.id,
timestamp: trace.timestamp.toLocaleString(),
name: trace.name ?? "",
level: trace.level,
observationCount: trace.observationCount,
release: trace.release ?? undefined,
version: trace.version ?? undefined,
userId: trace.userId ?? "",
sessionId: trace.sessionId ?? undefined,
latency: trace.latency === null ? undefined : trace.latency,
tags: trace.tags,
usage: {
promptTokens: trace.promptTokens,
completionTokens: trace.completionTokens,
totalTokens: trace.totalTokens,
},
scores: verifyAndPrefixScoreDataAgainstKeys(
scoreKeysAndProps,
trace.scores,
),
inputCost: trace.calculatedInputCost ?? undefined,
outputCost: trace.calculatedOutputCost ?? undefined,
totalCost: trace.calculatedTotalCost ?? undefined,
};
})
: [];
}, [traces, scoreKeysAndProps]);
return (
<>
@@ -605,7 +668,7 @@ export default function TracesTable({
currentQuery: searchQuery ?? undefined,
}}
filterState={userFilterState}
setFilterState={setUserFilterState}
setFilterState={useDebounce(setUserFilterState)}
actionButtons={
Object.keys(selectedRows).filter((traceId) =>
traces.data?.traces.map((t) => t.id).includes(traceId),
@@ -626,6 +689,8 @@ export default function TracesTable({
setColumnVisibility={setColumnVisibility}
rowHeight={rowHeight}
setRowHeight={setRowHeight}
selectedOption={selectedOption}
setDateRangeAndOption={setDateRangeAndOption}
/>
<DataTable
columns={columns}
@@ -641,7 +706,7 @@ export default function TracesTable({
: {
isLoading: false,
isError: false,
data: traces.data.traces.map((t) => convertToTableRow(t)),
data: rows,
}
}
pagination={{
@@ -21,8 +21,9 @@ import { withDefault, StringParam, useQueryParam } from "use-query-params";
import ScoresTable from "@/src/components/table/use-cases/scores";
import { ScoresPreview } from "@/src/components/trace/ScoresPreview";
import { JumpToPlaygroundButton } from "@/src/ee/features/playground/page/components/JumpToPlaygroundButton";
import { AnnotateDrawer } from "@/src/features/manual-scoring/components/AnnotateDrawer";
import { AnnotateDrawer } from "@/src/features/scores/components/AnnotateDrawer";
import { type APIScore } from "@/src/features/public-api/types/scores";
import useLocalStorage from "@/src/components/useLocalStorage";
export const ObservationPreview = (props: {
observations: Array<ObservationReturnType>;
@@ -35,6 +36,9 @@ export const ObservationPreview = (props: {
"view",
withDefault(StringParam, "preview"),
);
const [emptySelectedConfigIds, setEmptySelectedConfigIds] = useLocalStorage<
string[]
>("emptySelectedConfigIds", []);
const observationWithInputAndOutput = api.observations.byId.useQuery({
observationId: props.currentObservationId,
@@ -164,6 +168,8 @@ export const ObservationPreview = (props: {
traceId={preloadedObservation.traceId}
observationId={preloadedObservation.id}
scores={props.scores}
emptySelectedConfigIds={emptySelectedConfigIds}
setEmptySelectedConfigIds={setEmptySelectedConfigIds}
type="observation"
key={"annotation-drawer" + preloadedObservation.id}
/>
+7 -1
View File
@@ -18,8 +18,9 @@ import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
import { withDefault, StringParam, useQueryParam } from "use-query-params";
import ScoresTable from "@/src/components/table/use-cases/scores";
import { ScoresPreview } from "@/src/components/trace/ScoresPreview";
import { AnnotateDrawer } from "@/src/features/manual-scoring/components/AnnotateDrawer";
import { AnnotateDrawer } from "@/src/features/scores/components/AnnotateDrawer";
import { type APIScore } from "@/src/features/public-api/types/scores";
import useLocalStorage from "@/src/components/useLocalStorage";
export const TracePreview = ({
trace,
@@ -34,6 +35,9 @@ export const TracePreview = ({
"view",
withDefault(StringParam, "preview"),
);
const [emptySelectedConfigIds, setEmptySelectedConfigIds] = useLocalStorage<
string[]
>("emptySelectedConfigIds", []);
const traceScores = scores.filter((s) => s.observationId === null);
const traceScoresBySource = traceScores.reduce((acc, score) => {
@@ -100,6 +104,8 @@ export const TracePreview = ({
projectId={trace.projectId}
traceId={trace.id}
scores={scores}
emptySelectedConfigIds={emptySelectedConfigIds}
setEmptySelectedConfigIds={setEmptySelectedConfigIds}
key={"annotation-drawer" + trace.id}
/>
<NewDatasetItemFromTrace
+3 -22
View File
@@ -24,7 +24,6 @@ import {
ChevronsUpDown,
ListTree,
Network,
Terminal,
} from "lucide-react";
import { usdFormatter } from "@/src/utils/numbers";
import Decimal from "decimal.js";
@@ -33,7 +32,6 @@ import { DeleteButton } from "@/src/components/deleteButton";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
import { TraceTimelineView } from "@/src/components/trace/TraceTimelineView";
import { Alert, AlertDescription, AlertTitle } from "@/src/components/ui/alert";
import { type APIScore } from "@/src/features/public-api/types/scores";
export function Trace(props: {
@@ -295,9 +293,9 @@ export function TracePage({ traceId }: { traceId: string }) {
</Badge>
) : undefined}
</div>
<div className="mt-4 rounded-lg border bg-card font-semibold text-card-foreground shadow-sm">
<div className="flex flex-row items-center gap-3 p-2.5">
Tags
<div className="mt-3 rounded-lg border bg-card font-semibold text-card-foreground">
<div className="flex flex-row items-center gap-3 px-3 py-1">
<span className="text-sm">Tags</span>
<TagTraceDetailsPopover
tags={trace.data.tags}
availableTags={allTags}
@@ -329,7 +327,6 @@ export function TracePage({ traceId }: { traceId: string }) {
>
<ListTree className="mr-1 h-4 w-4"></ListTree>
Timeline
<Badge className="pointer-events-none ml-2 px-1.5">Beta</Badge>
</TabsTrigger>
</TabsList>
</Tabs>
@@ -346,22 +343,6 @@ export function TracePage({ traceId }: { traceId: string }) {
)}
{selectedTab === "timeline" && (
<div className="mt-5 flex-1 flex-col space-y-5 overflow-hidden">
<Alert>
<Terminal className="h-4 w-4" />
<AlertTitle>New Trace Timeline (beta)</AlertTitle>
<AlertDescription>
We value your feedback! Share your thoughts on{" "}
<a
href="https://github.com/orgs/langfuse/discussions/2195"
target="_blank"
className="underline"
rel="noopener noreferrer"
>
GitHub discussions
</a>
.
</AlertDescription>
</Alert>
<TraceTimelineView
key={trace.data.id}
trace={trace.data}
+3 -3
View File
@@ -191,11 +191,11 @@ export const IOTableCell = ({
return (
<>
{isLoading ? (
<JsonSkeleton className="h-full w-[400px] overflow-hidden px-2 py-1" />
<JsonSkeleton className="h-full w-full overflow-hidden px-2 py-1" />
) : singleLine ? (
<div
className={cn(
"h-full w-[400px] self-stretch overflow-hidden overflow-y-auto truncate rounded-sm border px-2 py-0.5",
"h-full w-full self-stretch overflow-hidden overflow-y-auto truncate rounded-sm border px-2 py-0.5",
className,
)}
>
@@ -205,7 +205,7 @@ export const IOTableCell = ({
<JSONView
json={stringifyJsonNode(data)}
className={cn(
"h-full w-[400px] self-stretch overflow-y-auto rounded-sm ",
"h-full w-full self-stretch overflow-y-auto rounded-sm ",
className,
)}
codeClassName="py-1 px-2"
+15 -7
View File
@@ -44,8 +44,16 @@ const isChecklist = (children: ReactNode) =>
* Security risks are taken care of by a validation in api.utilities.validateImgUrl
* Fetching image will fail if SSL/TLS certificate is invalid or expired, will be handled by onError
* Do not use this customLoader in production if you are not using the above mentioned security measures */
const customLoader = ({ src }: { src: string }) => {
return src;
const customLoader = ({
src,
width,
quality,
}: {
src: string;
width: number;
quality?: number;
}) => {
return `${src}?w=${width}&q=${quality || 75}`;
};
const ImageErrorDisplay = ({
@@ -80,8 +88,8 @@ const MarkdownImage: Components["img"] = ({ src, alt }) => {
);
}
const isHttp = new URL(src).protocol === "http:";
const errorDescription = `Cannot load image. ${isHttp ? "Http images are not rendered in Langfuse for security reasons." : ""} Click to view image in new tab`;
const errorDescription =
"Cannot load image. Http images are not rendered in Langfuse for security reasons";
if (isValidImage.data?.isValid) {
return (
@@ -240,11 +248,11 @@ export function MarkdownView({
if (isChecklist(children))
return <ul className="list-none">{children}</ul>;
return <ul className="list-inside list-disc pl-2">{children}</ul>;
return <ul className="list-outside list-disc pl-4">{children}</ul>;
},
ol({ children }) {
return (
<ol className="list-inside list-decimal pl-2">{children}</ol>
<ol className="list-outside list-decimal pl-4">{children}</ol>
);
},
li({ children }) {
@@ -310,7 +318,7 @@ export function MarkdownView({
},
table({ children }) {
return (
<div className="overflow-hidden rounded border">
<div className="overflow-x-auto rounded border text-xs">
<table className="min-w-full divide-y">{children}</table>
</div>
);
+3 -6
View File
@@ -9,7 +9,7 @@ const Table = React.forwardRef<
<table
ref={ref}
className={cn(
"w-full table-auto caption-bottom border-separate border-spacing-0 space-y-4 overflow-auto text-sm",
"w-full table-fixed caption-bottom border-separate border-spacing-0 space-y-4 overflow-auto text-sm",
className,
)}
{...props}
@@ -74,7 +74,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
"sticky top-0 z-10 rounded-t-md border-b bg-background px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
"sticky top-0 z-10 border-b bg-background px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
@@ -88,10 +88,7 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"whitespace-nowrap p-4 align-middle [&:has([role=checkbox])]:pr-0",
className,
)}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
));
@@ -201,3 +201,18 @@ export function display12HourValue(hours: number) {
if (hours % 12 > 9) return `${hours}`;
return `0${hours % 12}`;
}
export function combineDateAndTime(
date: Date | undefined,
time: Date | undefined,
) {
if (!date || !time) return undefined;
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
time.getHours(),
time.getMinutes(),
time.getSeconds(),
);
}
+14 -4
View File
@@ -6,15 +6,20 @@ import { TimePeriodSelect } from "./time-period-select";
import { type Period } from "./time-picker-utils";
import { getTimezoneDetails, getShortLocalTimezone } from "@/src/utils/dates";
import { TimeIcon } from "@/src/components/ui/time-icon";
import { cn } from "@/src/utils/tailwind";
interface TimePickerProps {
date: Date | undefined;
setDate: (date: Date | undefined) => void;
className?: string;
}
export function TimePicker({ date, setDate }: TimePickerProps) {
const [period, setPeriod] = React.useState<Period>("AM");
export function TimePicker({ date, setDate, className }: TimePickerProps) {
const getInitialPeriod = (date: Date | undefined): Period => {
if (!date) return "AM";
return date.getHours() >= 12 ? "PM" : "AM";
};
const [period, setPeriod] = React.useState<Period>(getInitialPeriod(date));
const minuteRef = React.useRef<HTMLInputElement>(null);
const hourRef = React.useRef<HTMLInputElement>(null);
const secondRef = React.useRef<HTMLInputElement>(null);
@@ -24,7 +29,12 @@ export function TimePicker({ date, setDate }: TimePickerProps) {
const timezoneDetails = React.useMemo(() => getTimezoneDetails(), []);
return (
<div className="flex w-full flex-1 items-center gap-1 rounded-b-md border-t-2 bg-transparent px-3 py-2 text-sm ring-offset-background">
<div
className={cn(
"flex w-full flex-1 items-center gap-1 rounded-b-md border-t-2 bg-transparent px-3 py-2 text-sm ring-offset-background",
className,
)}
>
<div className="mx-1 grid gap-1 text-center">
<TimeIcon time={date ?? period} />
</div>
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v2.65.0";
export const VERSION = "v2.69.0";
@@ -93,7 +93,6 @@ export const EvalConfigDetail = () => {
`${existingEvalConfig.evalTemplate.name} (v${existingEvalConfig.evalTemplate.version})` ??
""
}
truncateAt={40}
/>
</div>

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