Compare commits

...
142 Commits
Author SHA1 Message Date
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
Marc Klingen 12c8968029 chore: release v2.65.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-22 19:13:53 +02:00
Marc KlingenandGitHub 05dd559d96 fix: cors policy on generated api specification (#2707) 2024-07-22 17:01:26 +00:00
marliessophieandGitHub fffa14d0e9 chore: enhance security measures in image verification (#2701)
* chore: enhance security measures in image verification
* style: add specific title for http image render error
2024-07-22 15:49:20 +02:00
Marc KlingenandGitHub bb30bb3c00 fix: build shared package before running models:migrate (#2703) 2024-07-22 11:52:39 +02:00
Marc KlingenandGitHub b25af3bc98 chrore: require node 20 in package.json (#2696)
chrore: require node 20 in package
2024-07-20 17:36:41 +00:00
Marc KlingenandGitHub e07024f61f chore(ui): increase precision of model price preview in new model form (#2695) 2024-07-20 11:38:14 +00:00
Marc KlingenandGitHub af89b4594b chore: move external image verification to a protected api route (#2692) 2024-07-19 19:13:34 +00:00
Marc KlingenandGitHub 7c9564374e fix(ui): sort selects in evals ui (#2691) 2024-07-19 18:59:55 +00:00
Marc KlingenandGitHub ca1a673687 fix(ui): improve llm api key form verification errors (#2689) 2024-07-19 18:27:11 +00:00
Richard KrümmelandGitHub 2dd0777b88 feat(filter): extend date filters to datetime (#2330) 2024-07-19 16:47:05 +00:00
e93c06c938 feat(ui): support image rendering in markdown view when image url provided (#2636)
Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-07-19 16:26:47 +00:00
Marc KlingenandGitHub b918f42971 chore(ui): slight ui improvements of evals (#2686)
* add breadcrumbs

* ui misc
2024-07-19 15:05:49 +00:00
marliessophieandGitHub 81485397e7 refactor(markdown): create context for markdown (#2670)
* refactor(markdown): create context for markdown
2024-07-19 16:16:29 +02:00
Marc Klingen 1188e914c9 fix(ui): expand IO on sessions view 2024-07-19 15:55:06 +02:00
marliessophieandGitHub 0661090238 fix(ui): ensure trace score updates never target child observation scores (#2685)
* Added condition to handle undefined observationId correctly with prisma
2024-07-19 10:24:34 +00:00
Max DeichmannandGitHub 48d5fe700b fix: increase rest request fetch timeout (#2683)
types
2024-07-19 07:00:42 +00:00
Marc KlingenandGitHub 8d4fbfad1d feat: add gpt-4o mini to playground and evals (#2681) 2024-07-19 00:29:08 +02:00
Marc KlingenandGitHub a9e2bed00d chore(deps): bump turbo (#2679) 2024-07-19 00:27:10 +02:00
Marc Klingen acf11efbe1 docs: require pnpm v9.5.0 2024-07-19 00:24:44 +02:00
Max DeichmannandGitHub c92d00f251 chore: upgrade pnpm (#2675) 2024-07-18 23:53:34 +02:00
Max Deichmann 3e61592cf7 chore: release v2.64.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-18 19:56:34 +02:00
Marc KlingenandGitHub 83952769b7 feat(models): add gpt-4o-mini (#2674) 2024-07-18 19:54:03 +02:00
Marc KlingenandGitHub 2291ac6625 feat(models): add new REQUESTS unit (#2651) 2024-07-18 16:44:34 +02:00
Marc KlingenandGitHub 273e206e2e chore(ui): md viewer ui nits (#2663)
* less padding for code blocks

* fix corners with overflow-hidden

* corner of regular json view

* use non-mono font for md viewer

* support inline code and code blocks without language
2024-07-18 11:33:48 +02:00
Marc KlingenandGitHub a24c0d776d feat(ui): collapsed strings, add "... expand" and retain whitespace (#2662) 2024-07-17 23:33:53 +00:00
Marc KlingenandGitHub 865ef2fbef docs: Update CONTRIBUTING.md 2024-07-17 19:57:45 +02:00
marliessophieandGitHub 6f62cf5f47 fix: remove markdown sanitisation to keep custom html tags for users (#2656) 2024-07-17 12:31:55 +00:00
Max DeichmannandGitHub 31a496880d feat: send prisma stats to sentry (#2655) 2024-07-17 11:53:29 +00:00
Max DeichmannandGitHub f32255514d feat: fail worker health check after sigterm/sigint (#2654) 2024-07-17 12:10:34 +02:00
Max DeichmannandGitHub 585ee0ae37 feat: worker closes all connections correctly on shutdown (#2646) 2024-07-17 07:33:08 +00:00
Marc KlingenandGitHub 6f0c661114 ci(web): enforce linting and resolve lint issues (#2647) 2024-07-16 22:32:32 +00:00
Marc KlingenandGitHub febeec4534 ci(ee): enforce linting (#2649) 2024-07-17 00:18:56 +02:00
Marc Klingen 40a46f1f84 chore: release v2.63.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-16 23:49:11 +02:00
Marc KlingenandGitHub a24d6ec742 fix(ui): integer overflow on traces table (#2644) 2024-07-16 21:32:22 +00:00
Marc KlingenandGitHub 4a55e709ae fix: remove unused import (#2645)
chore: remove unused import
2024-07-16 19:21:44 +00:00
marliessophieandGitHub e591f1212d fix: only show markdown icon if content contains markdown (#2643)
* fix: only show markdown icon if content contains markdown

* implement copy button on all jsonviews with title
2024-07-16 21:10:16 +02:00
Marlies Mayerhofer 78f86264c2 chore: release v2.63.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-16 19:05:06 +02:00
Marlies Mayerhofer 4f64c37dad chore: release v2.62.1 2024-07-16 19:03:43 +02:00
Max DeichmannandGitHub 8746bcf54f chore: increase web container grace period (#2641)
something
2024-07-16 16:59:37 +00:00
marliessophieandGitHub 8c8e8c11e1 feat(ui): add button to enable/disable markdown when selecting pretty format (#2634) 2024-07-16 14:07:35 +00:00
Max DeichmannandGitHub 2a68c0610b fix: increase web container shutdown timeout (#2637) 2024-07-16 14:39:33 +02:00
Max DeichmannandGitHub 5c57b2b8e1 feat: shut down web container gracefully (#2635) 2024-07-16 12:22:49 +02:00
marliessophieandGitHub 3a1f34a54c fix(ui): markdown - properly render list items with styled text elements (#2633) 2024-07-15 18:33:46 +00:00
Marlies Mayerhofer 53a6769afb chore: release v2.62.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-15 15:52:03 +02:00
Marc KlingenandGitHub 0df3b996cc fix(ui): render observations with non-existing parentObservationId (#2630) 2024-07-15 13:05:09 +00:00
04b8137299 feat: add markdown rendering for Trace page (#2584)
* feat: add markdown rendering for Trace page (#2467)

Co-authored-by: JannisConen <121068348+JannisConen@users.noreply.github.com>
2024-07-15 12:36:33 +00:00
Marc KlingenandGitHub 52b2daf40e feat(ui): do not collapse strings in tables as rows have fixed height (#2626) 2024-07-15 12:02:53 +02:00
Marc KlingenandGitHub 69442a5e1f chore: remove unused import (#2615) 2024-07-11 12:18:52 +00:00
Max DeichmannandGitHub 8a9214c61b fix: remove dd from web (#2614) 2024-07-11 07:42:40 +00:00
Max DeichmannandGitHub 7b7c428fc3 fix: fix eval var mapping UI (#2613) 2024-07-10 20:57:26 +00:00
Max DeichmannandGitHub 4c6817731f chore: add logging for failing health checks (#2611) 2024-07-10 19:15:41 +00:00
Max DeichmannandGitHub 1ed426a5e7 perf: add dd tracing (#2609) 2024-07-10 16:33:20 +00:00
Marlies Mayerhofer 0106bf6e98 chore: release v2.61.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-10 18:16:47 +02:00
marliessophieandGitHub c2a68ab339 fix(ui_annotation_drawer): fix state management of form reset (#2607)
* fix(ui_annotation_drawer): fix state management of form reset

* push

* push

* push

* push
2024-07-10 17:58:34 +02:00
Marc Klingen 2b27698b8d docs(api): fix fern type and update openapi spec 2024-07-10 16:26:30 +02:00
Marc Klingen 796462c20c chore: release v2.61.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-10 16:10:43 +02:00
Marc KlingenandGitHub 405b017814 perf(dashboard): default to 1d date range (#2606) 2024-07-10 13:17:19 +00:00
Marc KlingenandGitHub b0e056a5b9 refactor(api): move strict check to api types instead of test util and middleware (#2604)
move strict to types instead of test/middleware code
2024-07-10 12:59:07 +00:00
Hassieb PakzadandGitHub 61641aca3b fix(tokencost): write total cost if input or output cost provided by user (#2603) 2024-07-10 12:12:15 +00:00
Marc KlingenandGitHub d045869dc4 feat(api): add GET /sessions, more time cursors to list endpoints, use withMiddleware and strict zod types for many existing api routes (#2551) 2024-07-10 13:49:50 +02:00
marliessophieandGitHub 6b9291fef4 fix(score_configs): migrate to set categories NULL for numeric scores (#2602)
* fix(score_configs): set categories NULL for numeric scores

* push

* push

* simplicity
2024-07-10 13:01:49 +02:00
marliessophieandGitHub 87f16f58c5 fix(UI): small glitch in creating score config (#2601) 2024-07-10 10:00:35 +00:00
Marc KlingenandGitHub 700a3cd445 fix(sentry): scope down exception tracking in withMiddleware to 5xx (#2598) 2024-07-10 11:11:20 +02:00
Max DeichmannandGitHub 0041078830 fix: add all tiktoken models (#2588) 2024-07-09 22:05:47 +00:00
Max DeichmannandGitHub 33e4f8743c fix: fix worker sentry (#2594) 2024-07-09 23:48:59 +02:00
Max DeichmannandGitHub 6228956384 fix: add sentry back to server (#2593)
something
2024-07-09 21:11:57 +00:00
Marc Klingen 454773f1c9 Revert "deps: upgrade sentry (#2587)"
This reverts commit 5532de5871.
2024-07-09 21:05:19 +02:00
Max DeichmannandGitHub 682983d252 fix: remove sentry api wrapper (#2592) 2024-07-09 20:44:22 +02:00
Max DeichmannandGitHub 6462d45790 fix: remove otel from web container (#2591) 2024-07-09 20:38:15 +02:00
Max DeichmannandGitHub 7413b329d1 fix: remove sentry backend (#2589) 2024-07-09 20:21:42 +02:00
Max DeichmannandGitHub 5532de5871 deps: upgrade sentry (#2587)
* something

* something
2024-07-09 16:10:43 +00:00
marliessophieandGitHub c32e0dbeca docs: ensure CreateScoreValue is consistent with pydantic type expectation (#2582) 2024-07-09 15:57:27 +00:00
Hassieb PakzadandGitHub ab5a281e57 fix(ingestion): allow nested objects in modelParams (#2585) 2024-07-09 17:05:15 +02:00
Marc KlingenandGitHub dc19bee8e5 fix(ui): deletion of traces led to refetching of deleted trace and logged warning (#2580) 2024-07-09 16:22:52 +02:00
Marc KlingenandGitHub dd9bf14204 fix(ui): pretty UI for chatml traces with custom roles (#2581) 2024-07-09 16:04:01 +02:00
Max DeichmannandGitHub 82b77bce04 refactor: refactor code (#2583)
push
2024-07-09 15:42:18 +02:00
Max DeichmannandGitHub d9659ee339 fix: remove betterstack (#2575) 2024-07-09 11:17:21 +02:00
Hassieb PakzadandGitHub 324d96fc2e fix: hide export button if insufficient access rights (#2572) 2024-07-08 19:08:09 +02:00
Marc KlingenandGitHub ccbe233ff6 chore(deps): upgrade zod (#2570) 2024-07-08 14:44:37 +00:00
0223c7eff1 refactor(scores-api): simplify types, ensure return types of API are consistent in documentation and return (#2565)
* api: add `authorUserId`, `projectId`, `createdAt` and `updatedAt` to get scores response

* refactor: improve score config types

* refactor: rename and add comments

* fern

* typo

* fix userId nullish

* simplify fern definition

* test: create score with minimal trace and score data

* push

* push

---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-07-08 12:36:36 +00:00
Max Deichmann 4e44987e12 chore: release v2.60.4
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-08 14:03:26 +02:00
Max DeichmannandGitHub e0f605a0f2 fix: correctly sample evals (#2562) 2024-07-08 14:00:07 +02:00
Marc Klingen 05a4935a91 chore: release v2.60.3
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-08 12:51:42 +02:00
Marc Klingen 27e1dca4df fix(api): scores parsing error on GET /scores leading to dropped scores 2024-07-08 12:51:21 +02:00
Hassieb PakzadandGitHub 53e4433032 perf(dashboards): read directly from observations table for costs (#2568) 2024-07-08 11:52:50 +02:00
Marc Klingen c777d91f13 chore: typo in prisma schema 2024-07-08 11:41:06 +02:00
Marc KlingenandGitHub ce1e4318e4 chore: run arm builds on tagged commits only (#2561) 2024-07-08 10:02:55 +02:00
248 changed files with 24725 additions and 17035 deletions
+11 -11
View File
@@ -1,14 +1,14 @@
{
"name": "langfuse dev",
"image": "mcr.microsoft.com/devcontainers/javascript-node:20-bookworm",
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"version": "latest",
"moby": true
}
},
"forwardPorts": [3000],
"name": "langfuse dev",
"image": "mcr.microsoft.com/devcontainers/javascript-node:20-bookworm",
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"version": "latest",
"moby": true
}
},
"forwardPorts": [3000],
"onCreateCommand": "npm install -g pnpm@8.15.5",
"postCreateCommand": "cp .env.dev.example .env && pnpm run dx-f"
"onCreateCommand": "npm install -g pnpm@9.5.0",
"postCreateCommand": "cp .env.dev.example .env && pnpm run dx-f"
}
+1 -1
View File
@@ -3,6 +3,6 @@ Dockerfile
node_modules
npm-debug.log
README.md
.next
**/.next
.git
**/node_modules
+12 -9
View File
@@ -44,6 +44,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 +105,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
@@ -134,17 +132,11 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
# Used to determine the Sentry sample rate
# LANGFUSE_TRACING_SAMPLE_RATE=
# NewRelic
# NEW_RELIC_API_KEY=
# OTLP_ENDPOINT=
# Cloudflare Turnstile
# NEXT_PUBLIC_TURNSTILE_SITE_KEY=
# TURNSTILE_SECRET_KEY=
# Betterstack
# LANGFUSE_TEAM_BETTERSTACK_TOKEN=
# Demo project that users can use to try the platform
# NEXT_PUBLIC_DEMO_PROJECT_ID=
@@ -157,5 +149,16 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
# 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
+10 -8
View File
@@ -19,7 +19,7 @@ jobs:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v3
with:
version: 8.15.5
version: 9.5.0
- uses: actions/setup-node@v3
with:
node-version: 20
@@ -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:
@@ -78,7 +79,7 @@ jobs:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v3
with:
version: 8.15.5
version: 9.5.0
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
@@ -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:
@@ -133,7 +135,7 @@ jobs:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v3
with:
version: 8.15.5
version: 9.5.0
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
@@ -173,7 +175,7 @@ jobs:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v3
with:
version: 8.15.5
version: 9.5.0
- uses: actions/setup-node@v3
with:
node-version: 20
@@ -236,7 +238,7 @@ jobs:
steps:
- uses: pnpm/action-setup@v3
with:
version: 8.15.5
version: 9.5.0
- name: Setup node
uses: actions/setup-node@v3
with:
@@ -292,7 +294,7 @@ jobs:
labels: ${{ steps.meta-web.outputs.labels }}
platforms: |
linux/amd64
linux/arm64
${{ startsWith(github.ref, 'refs/tags/') && 'linux/arm64' || '' }}
- name: Extract metadata (tags, labels) for Docker
id: meta-worker
@@ -319,4 +321,4 @@ jobs:
labels: ${{ steps.meta-worker.outputs.labels }}
platforms: |
linux/amd64
linux/arm64
${{ startsWith(github.ref, 'refs/tags/') && 'linux/arm64' || '' }}
@@ -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
@@ -1,5 +1,4 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
**/newrelic_agent.log
# dependencies
/node_modules
+60 -10
View File
@@ -53,9 +53,7 @@ A good first step is to search for open [issues](https://github.com/langfuse/lan
### Architecture Overview
> [!NOTE]
> Infrastructure will change in Langfuse version 3.0. More in the [GitHub Discussions](https://github.com/orgs/langfuse/discussions/1902).
> `langfuse/langfuse/worker` is under active development and not recommended for production use in Langfuse 2.x.
**Langfuse v2**
```mermaid
flowchart TB
@@ -93,15 +91,66 @@ flowchart TB
I --- Prisma
end
subgraph s5["Application (langfuse/langfuse/worker)"]
Worker_API[Public HTTP API]
Prisma --- DB
JS --- API
Python --- API
```
**Langfuse v3 (work in progress, not released yet)**
> [!NOTE]
> Infrastructure will change in Langfuse version 3.0. More in the [GitHub Discussions](https://github.com/orgs/langfuse/discussions/1902).
> `langfuse/langfuse/worker` is under active development and not recommended for production use in Langfuse 2.x.
```mermaid
flowchart TB
subgraph s4["Clients"]
subgraph s2["langfuse/langfuse-python"]
Python["Python low-level SDK"]
Decorator["observe() decorator"] -->|extends| Python
OAI["OpenAI drop-in replacement"] -->|extends| Python
Llamaindex["LlamaIndex Integration"] -->|extends| Python
LCPYTHON["Langchain Python Integration"] -->|extends| Python
Langflow -->|uses| LCPYTHON
LiteLLM -->|uses| Python
end
subgraph s3["langfuse/langfuse-js"]
JS["JS SDK"]
LCJS["Langchain JS Integration"] -->|extends| JS
Flowise -->|uses| LCJS
end
end
API --> Worker_API
Worker_API --- DB
Worker_API --- Redis
DB[Postgres Database]
Redis[Redis Cache/Queue]
Clickhouse[Clickhouse Database]
subgraph s1["Application (langfuse/langfuse/web)"]
API[Public HTTP API]
G[TRPC API]
I[NextAuth]
H[React Frontend]
ORM
H --> G
H --> I
G --> I
G --- ORM
API --- ORM
I --- ORM
end
subgraph s5["Application (langfuse/langfuse/worker)"]
Worker
end
Worker --- DB
Worker --- Redis
Worker --- Clickhouse
ORM --- DB
ORM --- Redis
ORM --- Clickhouse
Prisma --- DB
JS --- API
Python --- API
```
@@ -116,7 +165,7 @@ flowchart LR
Browser ---|Web UI & TRPC API| App
Integrations/SDKs ---|Public HTTP API| App
subgraph i1["Application Network"]
App["Langfuse Application (Docker or Serverless)"]
App["Langfuse Application"]
end
subgraph i2["Database Network"]
DB["Postgres Database"]
@@ -149,6 +198,7 @@ We built a monorepo using [pnpm](https://pnpm.io/motivation) and [turbo](https:/
Requirements
- Node.js 20 as specified in the [.nvmrc](.nvmrc)
- Pnpm v.9.5.0
- Docker to run the database locally
**Note:** You can also simply run Langfuse in a **GitHub Codespace** via the provided devcontainer. To do this, click on the green "Code" button in the top right corner of the repository and select "Open with Codespaces".
-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
+5 -5
View File
@@ -15,23 +15,23 @@
}
},
"engines": {
"node": ">=20"
"node": "20"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 0",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix"
},
"dependencies": {
"@langfuse/shared": "workspace:*",
"next": "^14.2.3",
"next-auth": "^4.24.7",
"zod": "^3.22.4"
"zod": "^3.23.8"
},
"devDependencies": {
"@repo/eslint-config": "*",
"@repo/typescript-config": "*",
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/node": "^20.11.29",
"@typescript-eslint/parser": "^7.12.0",
"eslint": "^8.57.0",
-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) };
+13 -26
View File
@@ -9,7 +9,6 @@ service:
method: POST
path: /scores
request: CreateScoreRequest
response: Score
types:
CreateScoreRequest:
@@ -65,57 +64,45 @@ types:
dataType: "BOOLEAN"
configId: "1234-5678-90ab-cdef"
traceId: "cdef-1234-5678-90ab"
NumericScore:
BaseScore:
properties:
id: string
traceId: string
name: string
value:
type: double
docs: The numeric value of the score
source: ScoreSource
observationId: optional<string>
timestamp: datetime
createdAt: datetime
updatedAt: datetime
authorUserId: optional<string>
comment: optional<string>
configId:
type: optional<string>
docs: Reference a score config on a score. When set, config and score name must be equal and value must comply to optionally defined numerical range
NumericScore:
extends: BaseScore
properties:
value:
type: double
docs: The numeric value of the score
BooleanScore:
extends: BaseScore
properties:
id: string
traceId: string
name: string
value:
type: double
docs: The numeric value of the score. Equals 1 for "True" and 0 for "False"
stringValue:
type: string
docs: The string representation of the score value. Is inferred from the numeric value and equals "True" or "False"
source: ScoreSource
observationId: optional<string>
timestamp: datetime
comment: optional<string>
configId:
type: optional<string>
docs: Reference a score config on a score. When set, config and score name must be equal
CategoricalScore:
extends: BaseScore
properties:
id: string
traceId: string
name: string
value:
type: optional<double>
docs: Only defined if a config is linked. Represents the numeric category mapping of the stringValue
stringValue:
type: string
docs: The string representation of the score value. If no config is linked, can be any string. Otherwise, must map to a config category
source: ScoreSource
observationId: optional<string>
timestamp: datetime
comment: optional<string>
configId:
type: optional<string>
docs: Reference a score config on a score. When set, config and score name must be equal and stringValue must map to a config category
Score:
discriminant: "dataType"
union:
@@ -142,6 +129,6 @@ types:
CreateScoreValue:
discriminated: false
union:
- string
- double
- string
docs: The value of the score. Must be passed as string for categorical scores, and numeric for boolean and numeric scores
+164 -71
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
@@ -42,11 +62,18 @@ types:
htmlPath:
type: string
docs: Path of trace in Langfuse UI
latency:
type: double
docs: Latency of trace in seconds
totalCost:
type: double
docs: Cost of trace in USD
observations: list<ObservationsView>
scores: list<Score>
observations:
type: list<ObservationsView>
docs: List of observations
scores:
type: list<Score>
docs: List of scores
Session:
properties:
id: string
@@ -58,36 +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>
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
@@ -137,57 +225,45 @@ types:
properties:
value: double
label: string
NumericScore:
BaseScore:
properties:
id: string
traceId: string
name: string
value:
type: double
docs: The numeric value of the score
source: ScoreSource
observationId: optional<string>
timestamp: datetime
createdAt: datetime
updatedAt: datetime
authorUserId: optional<string>
comment: optional<string>
configId:
type: optional<string>
docs: Reference a score config on a score. When set, config and score name must be equal and value must comply to optionally defined numerical range
NumericScore:
extends: BaseScore
properties:
value:
type: double
docs: The numeric value of the score
BooleanScore:
extends: BaseScore
properties:
id: string
traceId: string
name: string
value:
type: double
docs: The numeric value of the score. Equals 1 for "True" and 0 for "False"
stringValue:
type: string
docs: The string representation of the score value. Is inferred from the numeric value and equals "True" or "False"
source: ScoreSource
observationId: optional<string>
timestamp: datetime
comment: optional<string>
configId:
type: optional<string>
docs: Reference a score config on a score. When set, config and score name must be equal
CategoricalScore:
extends: BaseScore
properties:
id: string
traceId: string
name: string
value:
type: optional<double>
docs: Only defined if a config is linked. Represents the numeric category mapping of the stringValue
stringValue:
type: string
docs: The string representation of the score value. If no config is linked, can be any string. Otherwise, must map to a config category
source: ScoreSource
observationId: optional<string>
timestamp: datetime
comment: optional<string>
configId:
type: optional<string>
docs: Reference a score config on a score. When set, config and score name must be equal and stringValue must map to a config category
Score:
discriminant: "dataType"
union:
@@ -204,8 +280,8 @@ types:
CreateScoreValue:
discriminated: false
union:
- string
- double
- string
docs: The value of the score. Must be passed as string for categorical scores, and numeric for boolean and numeric scores
Dataset:
properties:
@@ -241,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:
@@ -296,6 +388,7 @@ types:
- MILLISECONDS
- SECONDS
- IMAGES
- REQUESTS
ObservationLevel:
enum:
- DEBUG
@@ -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
+7 -1
View File
@@ -35,7 +35,13 @@ service:
parentObservationId: optional<string>
fromStartTime:
type: optional<datetime>
docs: Retrieve only observations with a start_time greater than this datetime (ISO 8601).
docs: Retrieve only observations with a start_time or or after this datetime (ISO 8601).
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 -3
View File
@@ -11,7 +11,7 @@ service:
method: POST
path: /scores
request: CreateScoreRequest
response: commons.Score
response: CreateScoreResponse
get:
docs: Get a list of scores
method: GET
@@ -33,7 +33,10 @@ service:
docs: Retrieve only scores with this name.
fromTimestamp:
type: optional<datetime>
docs: Retrieve only scores newer than this datetime (ISO 8601).
docs: Optional filter to only include scores created on or after a certain datetime (ISO 8601)
toTimestamp:
type: optional<datetime>
docs: Optional filter to only include scores created before a certain datetime (ISO 8601)
source:
type: optional<commons.ScoreSource>
docs: Retrieve only scores from a specific source.
@@ -124,7 +127,11 @@ types:
dataType: "BOOLEAN"
configId: "1234-5678-90ab-cdef"
traceId: "cdef-1234-5678-90ab"
CreateScoreResponse:
properties:
id:
type: string
docs: The id of the created object in Langfuse
Scores:
properties:
data: list<commons.Score>
+25
View File
@@ -6,6 +6,26 @@ service:
auth: true
base-path: /api/public
endpoints:
list:
docs: Get sessions
method: GET
path: /sessions
request:
name: GetSessionsRequest
query-parameters:
page:
type: optional<integer>
docs: Page number, starts at 1
limit:
type: optional<integer>
docs: Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit.
fromTimestamp:
type: optional<datetime>
docs: Optional filter to only include sessions created on or after a certain datetime (ISO 8601)
toTimestamp:
type: optional<datetime>
docs: Optional filter to only include sessions created before a certain datetime (ISO 8601)
response: PaginatedSessions
get:
docs: Get a session. Please note that `traces` on this endpoint are not paginated, if you plan to fetch large sessions, consider `GET /api/public/traces?sessionId=<sessionId>`
method: GET
@@ -15,3 +35,8 @@ service:
type: string
docs: The unique id of a session
response: commons.SessionWithTraces
types:
PaginatedSessions:
properties:
data: list<commons.Session>
meta: pagination.MetaResponse
+11 -2
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:
@@ -33,7 +33,10 @@ service:
sessionId: optional<string>
fromTimestamp:
type: optional<datetime>
docs: Retrieve only traces newer than this datetime (ISO 8601).
docs: Optional filter to only include traces with a trace.timestamp on or after a certain datetime (ISO 8601)
toTimestamp:
type: optional<datetime>
docs: Optional filter to only include traces with a trace.timestamp before a certain datetime (ISO 8601)
orderBy:
type: optional<string>
docs: "Format of the string [field].[asc/desc]. Fields: id, timestamp, name, userId, release, version, public, bookmarked, sessionId. Example: timestamp.asc"
@@ -41,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:
+4 -7
View File
@@ -1,12 +1,11 @@
{
"name": "langfuse",
"version": "2.60.2",
"version": "2.67.0",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
"engines": {
"node": ">=20",
"pnpm": "<9"
"node": "20"
},
"scripts": {
"preinstall": "npx only-allow pnpm",
@@ -35,7 +34,7 @@
"husky": "^9.0.11",
"prettier": "^3.2.5",
"release-it": "^17.3.0",
"turbo": "^1.13.3"
"turbo": "^1.13.4"
},
"release-it": {
"git": {
@@ -80,7 +79,5 @@
}
}
},
"dependencies": {
"newrelic": "^11.22.0"
}
"packageManager": "pnpm@9.5.0"
}
+1 -1
View File
@@ -13,7 +13,7 @@
"@vercel/style-guide": "^6.0.0",
"eslint-config-next": "^14.2.3",
"eslint-config-prettier": "^9.1.0",
"eslint-config-turbo": "^1.13.3",
"eslint-config-turbo": "^1.13.4",
"eslint-plugin-only-warn": "^1.1.0",
"typescript": "^5.4.5"
}
+7 -4
View File
@@ -6,7 +6,7 @@
"main": "./dist/src/index.js",
"types": "./dist/src/index.d.ts",
"engines": {
"node": ">=20"
"node": "20"
},
"exports": {
".": {
@@ -47,6 +47,7 @@
"@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,18 +56,20 @@
"@react-email/render": "^0.0.15",
"@types/bcryptjs": "^2.4.6",
"bcryptjs": "^2.4.3",
"bullmq": "^5.4.2",
"ioredis": "^5.4.1",
"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",
"zod": "^3.22.4",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.22.5"
},
"devDependencies": {
"@repo/eslint-config": "*",
"@repo/typescript-config": "*",
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/lodash": "^4.17.5",
"@types/node": "^20.11.29",
"@types/nodemailer": "^6.4.15",
+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,6 @@
-- Migration script to update score_configs entries
-- Set categories to NULL where data_type is 'NUMERIC' and categories is an empty array
UPDATE score_configs
SET categories = NULL
WHERE data_type = 'NUMERIC' AND categories IS NOT NULL;
@@ -0,0 +1,20 @@
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-mini
('clyrjp56f0000t0mzapoocd7u', NULL, 'gpt-4o-mini', '(?i)^(gpt-4o-mini)$', NULL, 0.00000015, 0.0000006, NULL, 'TOKENS', 'openai', '{ "tokensPerMessage": 3, "tokensPerName": 1, "tokenizerModel": "gpt-4o" }'),
-- gpt-4o-mini-2024-07-18
('clyrjpbe20000t0mzcbwc42rg', NULL, 'gpt-4o-mini-2024-07-18', '(?i)^(gpt-4o-mini-2024-07-18)$', NULL, 0.00000015, 0.0000006, NULL, 'TOKENS', 'openai', '{ "tokensPerMessage": 3, "tokensPerName": 1, "tokenizerModel": "gpt-4o" }')
@@ -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
+27 -17
View File
@@ -3,7 +3,7 @@
generator client {
provider = "prisma-client-js"
previewFeatures = ["tracing", "views", "relationJoins"]
previewFeatures = ["tracing", "views", "relationJoins", "metrics"]
}
datasource db {
@@ -301,9 +301,9 @@ model Observation {
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
// GENERATION ONLY
model String?
internalModel String? @map("internal_model")
internalModelId String? @map("internal_model_id")
model String? // user-provided model attribute
internalModel String? @map("internal_model") // matched model.name that is matched at ingestion time, to be deprecated
internalModelId String? @map("internal_model_id") // matched model.id that is matched at ingestion time
modelParameters Json?
input Json?
@@ -363,7 +363,7 @@ view ObservationView {
statusMessage String? @map("status_message")
version String?
createdAt DateTime @map("created_at")
updateAt DateTime @map("updated_at")
updatedAt DateTime @map("updated_at")
// GENERATION ONLY
model String?
@@ -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)
@@ -632,7 +642,7 @@ model Model {
inputPrice Decimal? @map("input_price")
outputPrice Decimal? @map("output_price")
totalPrice Decimal? @map("total_price")
unit String // TOKENS, CHARACTERS, MILLISECONDS, SECONDS, or IMAGES
unit String // TOKENS, CHARACTERS, MILLISECONDS, SECONDS, REQUESTS, or IMAGES
tokenizerId String? @map("tokenizer_id")
tokenizerConfig Json? @map("tokenizer_config")
+11 -32
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);
});
@@ -710,7 +718,7 @@ function createObjects(
},
{
role: "user",
content: "How can i create a React component?",
content: "How can i create a *React* component?",
},
]
: {
@@ -728,37 +736,8 @@ function createObjects(
},
],
},
output: {
completion: `Creating a React component can be done in two ways: as a functional component or as a class component. Let's start with a basic example of both.
1. **Functional Component**:
A functional component is just a plain JavaScript function that accepts props as an argument, and returns a React element. Here's how you can create one:
'import React from 'react'; function Greeting(props) { return <h1>Hello, {props.name}</h1>; } export default Greeting;'
To use this component in another file, you can do:
'import Greeting from './Greeting'; function App() { return ( <div> <Greeting name="John" /> </div> ); } export default App;'
2. **Class Component**:
You can also define components as classes in React. These have some additional features compared to functional components:
'import React, { Component } from 'react'; class Greeting extends Component { render() { return <h1>Hello, {this.props.name}</h1>; } } export default Greeting;'
And here's how to use this component:
'import Greeting from './Greeting'; class App extends Component { render() { return ( <div> <Greeting name="John" /> </div> ); } } export default App;'
With the advent of hooks in React, functional components can do everything that class components can do and hence, the community has been favoring functional components over class components.
Remember to import React at the top of your file whenever you're creating a component, because JSX transpiles to 'React.createElement' calls under the hood.`,
},
output:
"Creating a React component can be done in two ways: as a functional component or as a class component. Let's start with a basic example of both.\n\n**Image**\n\n![Languse Example Image](https://static.langfuse.com/langfuse-dev/langfuse-example-image.jpeg)\n\n1. **Functional Component**:\n\nA functional component is just a plain JavaScript function that accepts props as an argument, and returns a React element. Here's how you can create one:\n\n```javascript\nimport React from 'react';\nfunction Greeting(props) {\n return <h1>Hello, {props.name}</h1>;\n}\nexport default Greeting;\n```\n\nTo use this component in another file, you can do:\n\n```javascript\nimport Greeting from './Greeting';\nfunction App() {\n return (\n <div>\n <Greeting name=\"John\" />\n </div>\n );\n}\nexport default App;\n```\n\n2. **Class Component**:\n\nYou can also define components as classes in React. These have some additional features compared to functional components:\n\n```javascript\nimport React, { Component } from 'react';\nclass Greeting extends Component {\n render() {\n return <h1>Hello, {this.props.name}</h1>;\n }\n}\nexport default Greeting;\n```\n\nAnd here's how to use this component:\n\n```javascript\nimport Greeting from './Greeting';\nclass App extends Component {\n render() {\n return (\n <div>\n <Greeting name=\"John\" />\n </div>\n );\n }\n}\nexport default App;\n```\n\nWith the advent of hooks in React, functional components can do everything that class components can do and hence, the community has been favoring functional components over class components.\n\nRemember to import React at the top of your file whenever you're creating a component, because JSX transpiles to `React.createElement` calls under the hood.",
model: model,
internalModel: model,
modelParameters: {
+1
View File
@@ -6,4 +6,5 @@ export enum ModelUsageUnit {
Seconds = "SECONDS",
Milliseconds = "MILLISECONDS",
Images = "IMAGES",
Requests = "REQUESTS",
}
+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({
@@ -80,6 +80,7 @@ export const availableEvalVariables = [
export const evalModelsByAdapter: Record<LLMAdapter, string[]> = {
[LLMAdapter.OpenAI]: [
"gpt-4o",
"gpt-4o-mini",
"gpt-4-turbo-preview",
"gpt-3.5-turbo",
] as const,
+67 -79
View File
@@ -28,7 +28,7 @@ const MixedUsage = z.object({
totalCost: z.number().nullish(),
});
export const stringDate = z.string().datetime({ offset: true }).nullish();
export const stringDateTime = z.string().datetime({ offset: true }).nullish();
export const usage = MixedUsage.nullish()
// transform mixed usage model input to new one
@@ -58,7 +58,7 @@ export const usage = MixedUsage.nullish()
export const TraceBody = z.object({
id: z.string().nullish(),
timestamp: stringDate,
timestamp: stringDateTime,
name: z.string().nullish(),
externalId: z.string().nullish(),
input: jsonSchema.nullish(),
@@ -75,7 +75,7 @@ export const TraceBody = z.object({
export const OptionalObservationBody = z.object({
traceId: z.string().nullish(),
name: z.string().nullish(),
startTime: stringDate,
startTime: stringDateTime,
metadata: jsonSchema.nullish(),
input: jsonSchema.nullish(),
output: jsonSchema.nullish(),
@@ -94,21 +94,27 @@ export const UpdateEventEvent = OptionalObservationBody.extend({
});
export const CreateSpanBody = CreateEventEvent.extend({
endTime: stringDate,
endTime: stringDateTime,
});
export const UpdateSpanBody = UpdateEventEvent.extend({
endTime: stringDate,
endTime: stringDateTime,
});
export const CreateGenerationBody = CreateSpanBody.extend({
completionStartTime: stringDate,
completionStartTime: stringDateTime,
model: z.string().nullish(),
modelParameters: z
.record(
z.string(),
z
.union([z.string(), z.number(), z.boolean(), z.array(z.string())])
.union([
z.string(),
z.number(),
z.boolean(),
z.array(z.string()),
z.record(z.string()),
])
.nullish()
)
.nullish(),
@@ -124,13 +130,19 @@ export const CreateGenerationBody = CreateSpanBody.extend({
});
export const UpdateGenerationBody = UpdateSpanBody.extend({
completionStartTime: stringDate,
completionStartTime: stringDateTime,
model: z.string().nullish(),
modelParameters: z
.record(
z.string(),
z
.union([z.string(), z.number(), z.boolean(), z.array(z.string())])
.union([
z.string(),
z.number(),
z.boolean(),
z.array(z.string()),
z.record(z.string()),
])
.nullish()
)
.nullish(),
@@ -156,71 +168,47 @@ 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({
id: z.string().nullish(),
traceId: z.string().nullish(),
name: z.string().nullish(),
startTime: stringDate,
endTime: stringDate,
startTime: stringDateTime,
endTime: stringDateTime,
metadata: jsonSchema.nullish(),
input: jsonSchema.nullish(),
output: jsonSchema.nullish(),
@@ -234,8 +222,8 @@ export const LegacySpanPatchSchema = z.object({
spanId: z.string(),
traceId: z.string().nullish(),
name: z.string().nullish(),
startTime: stringDate,
endTime: stringDate,
startTime: stringDateTime,
endTime: stringDateTime,
metadata: jsonSchema.nullish(),
input: jsonSchema.nullish(),
output: jsonSchema.nullish(),
@@ -248,9 +236,9 @@ export const LegacyGenerationsCreateSchema = z.object({
id: z.string().nullish(),
traceId: z.string().nullish(),
name: z.string().nullish(),
startTime: stringDate,
endTime: stringDate,
completionStartTime: stringDate,
startTime: stringDateTime,
endTime: stringDateTime,
completionStartTime: stringDateTime,
model: z.string().nullish(),
modelParameters: z
.record(
@@ -272,9 +260,9 @@ export const LegacyGenerationPatchSchema = z.object({
generationId: z.string(),
traceId: z.string().nullish(),
name: z.string().nullish(),
startTime: stringDate,
endTime: stringDate,
completionStartTime: stringDate,
startTime: stringDateTime,
endTime: stringDateTime,
completionStartTime: stringDateTime,
model: z.string().nullish(),
modelParameters: z
.record(
@@ -296,9 +284,9 @@ export const LegacyObservationBody = z.object({
traceId: z.string().nullish(),
type: z.enum(["GENERATION", "SPAN", "EVENT"]),
name: z.string().nullish(),
startTime: stringDate,
endTime: stringDate,
completionStartTime: stringDate,
startTime: stringDateTime,
endTime: stringDateTime,
completionStartTime: stringDateTime,
model: z.string().nullish(),
modelParameters: z
.record(
+1
View File
@@ -13,6 +13,7 @@ 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
+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
},
});
+4
View File
@@ -2,8 +2,12 @@ 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 "./redis/redis";
export * from "./redis/trace-upsert";
export * from "./clickhouse";
+4
View File
@@ -45,9 +45,12 @@ 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-05-13",
"gpt-4o-mini",
"gpt-4o-mini-2024-07-18",
"gpt-4-turbo-preview",
"gpt-4-1106-preview",
"gpt-4-0613",
@@ -64,6 +67,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",
}
+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;
}
+14832 -12698
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -17,7 +17,7 @@
"db:push": {},
"db:seed": {},
"models:migrate": {
"dependsOn": ["db:generate"]
"dependsOn": ["db:generate", "@langfuse/shared#build"]
},
"db:seed:examples": {},
"dev": {
+13 -9
View File
@@ -5,12 +5,11 @@ FROM --platform=${TARGETPLATFORM:-linux/amd64} node:20-alpine3.20 AS alpine
RUN apk update && apk upgrade --no-cache libcrypto3 libssl3 libc6-compat busybox ssl_client
FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine AS base
RUN npm install turbo@^1.13.3 --global
RUN npm install turbo@^1.13.4 --global
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@8.15.5 --activate
RUN corepack prepare pnpm@9.5.0 --activate
FROM --platform=${TARGETPLATFORM:-linux/amd64} base AS pruner
@@ -19,7 +18,6 @@ WORKDIR /app
COPY . .
RUN turbo prune --scope=web --docker
FROM --platform=${TARGETPLATFORM:-linux/amd64} base AS builder
WORKDIR /app
@@ -32,6 +30,7 @@ COPY --from=pruner /app/out/json/ .
RUN pnpm install --frozen-lockfile
ENV DOCKER_BUILD 1
ENV NEXT_MANUAL_SIG_HANDLE true
# pass public variables in build step
ARG NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
@@ -53,14 +52,13 @@ RUN rm -f ./web/src/middleware.ts
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
ENV NEXT_TELEMETRY_DISABLED 1
ENV NEXT_MANUAL_SIG_HANDLE true
RUN turbo run build --filter=web
# Production image, copy all the files and run next
FROM --platform=${TARGETPLATFORM:-linux/amd64} base AS runner
RUN apk add --no-cache dumb-init
WORKDIR /app
ENV NODE_ENV production
@@ -68,13 +66,16 @@ ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
# Needed to re-enable validation of environment variables during runtime
ENV DOCKER_BUILD 0
# Set NEXT_MANUAL_SIG_HANDLE for runtime
ENV NEXT_MANUAL_SIG_HANDLE true
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
COPY --from=builder --chown=nextjs:nodejs /app/web/next.config.mjs .
COPY --from=builder --chown=nextjs:nodejs /app/web/package.json .
@@ -95,5 +96,8 @@ USER nextjs
# Default port to 3000
ENV PORT 3000
# CMD ["node", "server.js"]
CMD ["dumb-init", "--", "./web/entrypoint.sh"]
ENTRYPOINT ["dumb-init", "--", "./web/entrypoint.sh"]
# startup command
CMD ["node", "./web/server.js"]
+2 -2
View File
@@ -35,5 +35,5 @@ if [ $status -ne 0 ]; then
exit $status
fi
# Start server
node web/server.js
# Run the command passed to the docker image on start
exec "$@"
+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;
+30 -16
View File
@@ -80,23 +80,37 @@ const nextConfig = {
// Required to check authentication status from langfuse.com
...(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined
? [
{
source: "/api/auth/session",
headers: [
{
key: "Access-Control-Allow-Origin",
value: "https://langfuse.com",
},
{ key: "Access-Control-Allow-Credentials", value: "true" },
{ key: "Access-Control-Allow-Methods", value: "GET,POST" },
{
key: "Access-Control-Allow-Headers",
value: "Content-Type, Authorization",
},
],
},
]
{
source: "/api/auth/session",
headers: [
{
key: "Access-Control-Allow-Origin",
value: "https://langfuse.com",
},
{ key: "Access-Control-Allow-Credentials", value: "true" },
{ key: "Access-Control-Allow-Methods", value: "GET,POST" },
{
key: "Access-Control-Allow-Headers",
value: "Content-Type, Authorization",
},
],
},
]
: []),
// all files in /public/generated are public and can be accessed from any origin, e.g. to render an API reference based on our openapi schema
{
source: "/generated/:path*",
headers: [
{
key: "Access-Control-Allow-Origin",
value: "*",
},
{
key: "Access-Control-Allow-Methods",
value: "GET",
},
],
},
];
},
+16 -18
View File
@@ -1,20 +1,20 @@
{
"name": "web",
"version": "2.60.2",
"version": "2.67.0",
"private": true,
"license": "MIT",
"engines": {
"node": ">=20"
"node": "20"
},
"scripts": {
"build": "INLINE_RUNTIME_CHUNK=false dotenv -e ../.env -- next build",
"dev": "dotenv -e ../.env -- next dev",
"lint": "dotenv -e ../.env -- next lint",
"lint": "dotenv -e ../.env -- next lint --max-warnings 0",
"lint:fix": "dotenv -e ../.env -- next lint --fix",
"prettier": "prettier --write ./src *.{ts,js}",
"clean": "rm -rf node_modules",
"start": "dotenv -e ../.env -- next start",
"test": "dotenv -e ../.env -- jest --runInBand",
"start": "dotenv -e ../.env -- sh -c 'NEXT_MANUAL_SIG_HANDLE=true next start'",
"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",
@@ -36,18 +36,6 @@
"@marsidev/react-turnstile": "^0.5.4",
"@mui/x-tree-view": "^7.6.2",
"@next-auth/prisma-adapter": "^1.0.7",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.48.0",
"@opentelemetry/exporter-jaeger": "^1.25.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.52.1",
"@opentelemetry/resource-detector-aws": "^1.5.2",
"@opentelemetry/resource-detector-container": "^0.3.11",
"@opentelemetry/resources": "^1.25.1",
"@opentelemetry/sdk-node": "^0.52.1",
"@opentelemetry/sdk-trace-node": "^1.25.1",
"@opentelemetry/semantic-conventions": "^1.25.1",
"@opentelemetry/winston-transport": "^0.5.0",
"@prisma/instrumentation": "^5.16.1",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4",
@@ -90,6 +78,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",
@@ -99,6 +88,9 @@
"decimal.js": "^10.4.3",
"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",
"langchain": "^0.2.6",
@@ -110,14 +102,19 @@
"next-themes": "^0.3.0",
"posthog-js": "^1.122.0",
"posthog-node": "^3.6.3",
"prexit": "^2.2.0",
"prisma": "^5.13.0",
"react": "18.2.0",
"react-day-picker": "^8.10.1",
"react-dom": "18.2.0",
"react-hook-form": "^7.51.5",
"react-icons": "^5.2.1",
"react-markdown": "^9.0.1",
"react-responsive": "^10.0.0",
"react-syntax-highlighter": "^15.5.0",
"react18-json-view": "^0.2.8-canary.6",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"sonner": "^1.4.41",
"superjson": "2.2.1",
"tailwind-merge": "^2.2.1",
@@ -125,7 +122,7 @@
"use-query-params": "^2.2.1",
"uuid": "^9.0.1",
"vaul": "^0.9.1",
"zod": "^3.22.4"
"zod": "^3.23.8"
},
"devDependencies": {
"@jedmao/location": "^3.0.0",
@@ -143,6 +140,7 @@
"@types/node": "20.10.5",
"@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^7.12.0",
+30 -63
View File
@@ -11,12 +11,8 @@ paths:
- Score
parameters: []
responses:
'200':
'204':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/Score'
'400':
description: ''
content:
@@ -90,8 +86,8 @@ components:
- traceId
- name
- value
NumericScore:
title: NumericScore
BaseScore:
title: BaseScore
type: object
properties:
id:
@@ -100,10 +96,6 @@ components:
type: string
name:
type: string
value:
type: number
format: double
description: The numeric value of the score
source:
$ref: '#/components/schemas/ScoreSource'
observationId:
@@ -111,6 +103,14 @@ components:
timestamp:
type: string
format: date-time
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
authorUserId:
type: string
comment:
type: string
configId:
@@ -123,19 +123,26 @@ components:
- id
- traceId
- name
- value
- source
- timestamp
- createdAt
- updatedAt
NumericScore:
title: NumericScore
type: object
properties:
value:
type: number
format: double
description: The numeric value of the score
required:
- value
allOf:
- $ref: '#/components/schemas/BaseScore'
BooleanScore:
title: BooleanScore
type: object
properties:
id:
type: string
traceId:
type: string
name:
type: string
value:
type: number
format: double
@@ -147,38 +154,15 @@ components:
description: >-
The string representation of the score value. Is inferred from the
numeric value and equals "True" or "False"
source:
$ref: '#/components/schemas/ScoreSource'
observationId:
type: string
timestamp:
type: string
format: date-time
comment:
type: string
configId:
type: string
description: >-
Reference a score config on a score. When set, config and score name
must be equal
required:
- id
- traceId
- name
- value
- stringValue
- source
- timestamp
allOf:
- $ref: '#/components/schemas/BaseScore'
CategoricalScore:
title: CategoricalScore
type: object
properties:
id:
type: string
traceId:
type: string
name:
type: string
value:
type: number
format: double
@@ -190,27 +174,10 @@ components:
description: >-
The string representation of the score value. If no config is
linked, can be any string. Otherwise, must map to a config category
source:
$ref: '#/components/schemas/ScoreSource'
observationId:
type: string
timestamp:
type: string
format: date-time
comment:
type: string
configId:
type: string
description: >-
Reference a score config on a score. When set, config and score name
must be equal and stringValue must map to a config category
required:
- id
- traceId
- name
- stringValue
- source
- timestamp
allOf:
- $ref: '#/components/schemas/BaseScore'
Score:
title: Score
oneOf:
@@ -264,9 +231,9 @@ components:
CreateScoreValue:
title: CreateScoreValue
oneOf:
- type: string
- type: number
format: double
- type: string
description: >-
The value of the score. Must be passed as string for categorical scores,
and numeric for boolean and numeric scores
+257 -71
View File
@@ -990,13 +990,30 @@ paths:
- name: fromStartTime
in: query
description: >-
Retrieve only observations with a start_time greater than this
Retrieve only observations with a start_time or or after this
datetime (ISO 8601).
required: false
schema:
type: string
format: date-time
nullable: true
- name: toStartTime
in: query
description: >-
Retrieve only observations with a start_time before this datetime
(ISO 8601).
required: false
schema:
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: ''
@@ -1411,7 +1428,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/Score'
$ref: '#/components/schemas/CreateScoreResponse'
'400':
description: ''
content:
@@ -1482,7 +1499,19 @@ paths:
nullable: true
- name: fromTimestamp
in: query
description: Retrieve only scores newer than this datetime (ISO 8601).
description: >-
Optional filter to only include scores created on or after a certain
datetime (ISO 8601)
required: false
schema:
type: string
format: date-time
nullable: true
- name: toTimestamp
in: query
description: >-
Optional filter to only include scores created before a certain
datetime (ISO 8601)
required: false
schema:
type: string
@@ -1651,6 +1680,82 @@ paths:
application/json:
schema: {}
security: *ref_0
/api/public/sessions:
get:
description: Get sessions
operationId: sessions_list
tags:
- Sessions
parameters:
- name: page
in: query
description: Page number, starts at 1
required: false
schema:
type: integer
nullable: true
- name: limit
in: query
description: >-
Limit of items per page. If you encounter api issues due to too
large page sizes, try to reduce the limit.
required: false
schema:
type: integer
nullable: true
- name: fromTimestamp
in: query
description: >-
Optional filter to only include sessions created on or after a
certain datetime (ISO 8601)
required: false
schema:
type: string
format: date-time
nullable: true
- name: toTimestamp
in: query
description: >-
Optional filter to only include sessions created before a certain
datetime (ISO 8601)
required: false
schema:
type: string
format: date-time
nullable: true
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedSessions'
'400':
description: ''
content:
application/json:
schema: {}
'401':
description: ''
content:
application/json:
schema: {}
'403':
description: ''
content:
application/json:
schema: {}
'404':
description: ''
content:
application/json:
schema: {}
'405':
description: ''
content:
application/json:
schema: {}
security: *ref_0
/api/public/sessions/{sessionId}:
get:
description: >-
@@ -1748,7 +1853,7 @@ paths:
security: *ref_0
/api/public/traces:
get:
description: Get list of traces.
description: Get list of traces
operationId: trace_list
tags:
- Trace
@@ -1789,7 +1894,19 @@ paths:
nullable: true
- name: fromTimestamp
in: query
description: Retrieve only traces newer than this datetime (ISO 8601).
description: >-
Optional filter to only include traces with a trace.timestamp on or
after a certain datetime (ISO 8601)
required: false
schema:
type: string
format: date-time
nullable: true
- name: toTimestamp
in: query
description: >-
Optional filter to only include traces with a trace.timestamp before
a certain datetime (ISO 8601)
required: false
schema:
type: string
@@ -1814,6 +1931,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: ''
@@ -1859,32 +1990,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
@@ -1932,6 +2075,10 @@ components:
htmlPath:
type: string
description: Path of trace in Langfuse UI
latency:
type: number
format: double
description: Latency of trace in seconds
totalCost:
type: number
format: double
@@ -1940,12 +2087,15 @@ components:
type: array
items:
$ref: '#/components/schemas/ObservationsView'
description: List of observations
scores:
type: array
items:
$ref: '#/components/schemas/Score'
description: List of scores
required:
- htmlPath
- latency
- totalCost
- observations
- scores
@@ -1984,56 +2134,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
@@ -2043,37 +2211,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:
@@ -2176,8 +2365,8 @@ components:
required:
- value
- label
NumericScore:
title: NumericScore
BaseScore:
title: BaseScore
type: object
properties:
id:
@@ -2186,10 +2375,6 @@ components:
type: string
name:
type: string
value:
type: number
format: double
description: The numeric value of the score
source:
$ref: '#/components/schemas/ScoreSource'
observationId:
@@ -2198,6 +2383,15 @@ components:
timestamp:
type: string
format: date-time
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
authorUserId:
type: string
nullable: true
comment:
type: string
nullable: true
@@ -2212,19 +2406,26 @@ components:
- id
- traceId
- name
- value
- source
- timestamp
- createdAt
- updatedAt
NumericScore:
title: NumericScore
type: object
properties:
value:
type: number
format: double
description: The numeric value of the score
required:
- value
allOf:
- $ref: '#/components/schemas/BaseScore'
BooleanScore:
title: BooleanScore
type: object
properties:
id:
type: string
traceId:
type: string
name:
type: string
value:
type: number
format: double
@@ -2236,41 +2437,15 @@ components:
description: >-
The string representation of the score value. Is inferred from the
numeric value and equals "True" or "False"
source:
$ref: '#/components/schemas/ScoreSource'
observationId:
type: string
nullable: true
timestamp:
type: string
format: date-time
comment:
type: string
nullable: true
configId:
type: string
nullable: true
description: >-
Reference a score config on a score. When set, config and score name
must be equal
required:
- id
- traceId
- name
- value
- stringValue
- source
- timestamp
allOf:
- $ref: '#/components/schemas/BaseScore'
CategoricalScore:
title: CategoricalScore
type: object
properties:
id:
type: string
traceId:
type: string
name:
type: string
value:
type: number
format: double
@@ -2283,30 +2458,10 @@ components:
description: >-
The string representation of the score value. If no config is
linked, can be any string. Otherwise, must map to a config category
source:
$ref: '#/components/schemas/ScoreSource'
observationId:
type: string
nullable: true
timestamp:
type: string
format: date-time
comment:
type: string
nullable: true
configId:
type: string
nullable: true
description: >-
Reference a score config on a score. When set, config and score name
must be equal and stringValue must map to a config category
required:
- id
- traceId
- name
- stringValue
- source
- timestamp
allOf:
- $ref: '#/components/schemas/BaseScore'
Score:
title: Score
oneOf:
@@ -2346,9 +2501,9 @@ components:
CreateScoreValue:
title: CreateScoreValue
oneOf:
- type: string
- type: number
format: double
- type: string
description: >-
The value of the score. Must be passed as string for categorical scores,
and numeric for boolean and numeric scores
@@ -2453,23 +2608,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
@@ -2563,6 +2726,7 @@ components:
- MILLISECONDS
- SECONDS
- IMAGES
- REQUESTS
description: Unit of usage in Langfuse
ObservationLevel:
title: ObservationLevel
@@ -2627,8 +2791,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
@@ -3787,6 +3951,15 @@ components:
- traceId
- name
- value
CreateScoreResponse:
title: CreateScoreResponse
type: object
properties:
id:
type: string
description: The id of the created object in Langfuse
required:
- id
Scores:
title: Scores
type: object
@@ -3800,6 +3973,19 @@ components:
required:
- data
- meta
PaginatedSessions:
title: PaginatedSessions
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Session'
meta:
$ref: '#/components/schemas/utilsMetaResponse'
required:
- data
- meta
Traces:
title: Traces
type: object
+83 -7
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=",
"raw": "{{baseUrl}}/api/public/observations?page=&limit=&name=&userId=&type=&traceId=&parentObservationId=&fromStartTime=&toStartTime=&version=",
"host": [
"{{baseUrl}}"
],
@@ -766,7 +766,17 @@
{
"key": "fromStartTime",
"value": "",
"description": "Retrieve only observations with a start_time greater than this datetime (ISO 8601)."
"description": "Retrieve only observations with a start_time or or after this datetime (ISO 8601)."
},
{
"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": []
@@ -1101,7 +1111,7 @@
"request": {
"description": "Get a list of scores",
"url": {
"raw": "{{baseUrl}}/api/public/scores?page=&limit=&userId=&name=&fromTimestamp=&source=&operator=&value=&scoreIds=&configId=&dataType=",
"raw": "{{baseUrl}}/api/public/scores?page=&limit=&userId=&name=&fromTimestamp=&toTimestamp=&source=&operator=&value=&scoreIds=&configId=&dataType=",
"host": [
"{{baseUrl}}"
],
@@ -1134,7 +1144,12 @@
{
"key": "fromTimestamp",
"value": "",
"description": "Retrieve only scores newer than this datetime (ISO 8601)."
"description": "Optional filter to only include scores created on or after a certain datetime (ISO 8601)"
},
{
"key": "toTimestamp",
"value": "",
"description": "Optional filter to only include scores created before a certain datetime (ISO 8601)"
},
{
"key": "source",
@@ -1247,6 +1262,52 @@
"description": null,
"name": "Sessions",
"item": [
{
"_type": "endpoint",
"name": "List",
"request": {
"description": "Get sessions",
"url": {
"raw": "{{baseUrl}}/api/public/sessions?page=&limit=&fromTimestamp=&toTimestamp=",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"sessions"
],
"query": [
{
"key": "page",
"value": "",
"description": "Page number, starts at 1"
},
{
"key": "limit",
"value": "",
"description": "Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit."
},
{
"key": "fromTimestamp",
"value": "",
"description": "Optional filter to only include sessions created on or after a certain datetime (ISO 8601)"
},
{
"key": "toTimestamp",
"value": "",
"description": "Optional filter to only include sessions created before a certain datetime (ISO 8601)"
}
],
"variable": []
},
"header": [],
"method": "GET",
"auth": null,
"body": null
},
"response": []
},
{
"_type": "endpoint",
"name": "Get",
@@ -1322,9 +1383,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=&orderBy=&tags=",
"raw": "{{baseUrl}}/api/public/traces?page=&limit=&userId=&name=&sessionId=&fromTimestamp=&toTimestamp=&orderBy=&tags=&version=&release=",
"host": [
"{{baseUrl}}"
],
@@ -1362,7 +1423,12 @@
{
"key": "fromTimestamp",
"value": "",
"description": "Retrieve only traces newer than this datetime (ISO 8601)."
"description": "Optional filter to only include traces with a trace.timestamp on or after a certain datetime (ISO 8601)"
},
{
"key": "toTimestamp",
"value": "",
"description": "Optional filter to only include traces with a trace.timestamp before a certain datetime (ISO 8601)"
},
{
"key": "orderBy",
@@ -1373,6 +1439,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": []
+5
View File
@@ -0,0 +1,5 @@
import teardown from "@/src/__tests__/teardown";
afterAll(async () => {
await teardown();
});
+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 () => {
+108 -14
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();
@@ -95,7 +96,8 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
expect(dbDataset.length).toBeGreaterThan(0);
// get dataset (v1) excluding items and runs
const getDatasetV1 = await makeAPICall(
const getDatasetV1 = await makeZodVerifiedAPICall(
GetDatasetV1Response,
"GET",
`/api/public/datasets/${encodeURIComponent("dataset + name")}`,
);
@@ -294,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(
@@ -332,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(),
@@ -783,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(
@@ -834,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,
}),
]),
);
});
});
+122 -77
View File
@@ -1,8 +1,13 @@
/** @jest-environment node */
import { prisma } from "@langfuse/shared/src/db";
import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
import {
makeZodVerifiedAPICall,
pruneDatabase,
} from "@/src/__tests__/test-utils";
import { v4 as uuidv4 } from "uuid";
import { PostTracesV1Response } from "@/src/features/public-api/types/traces";
import { PostEventsV1Response } from "@/src/features/public-api/types/events";
describe("/api/public/events API Endpoint", () => {
beforeEach(async () => await pruneDatabase());
@@ -12,15 +17,20 @@ describe("/api/public/events API Endpoint", () => {
const traceId = uuidv4();
await makeAPICall("POST", "/api/public/traces", {
id: traceId,
name: "trace-name",
userId: "user-1",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
});
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
id: traceId,
name: "trace-name",
userId: "user-1",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
},
);
const dbTrace = await prisma.trace.findMany({
where: {
@@ -32,16 +42,21 @@ describe("/api/public/events API Endpoint", () => {
expect(dbTrace[0]?.id).toBe(traceId);
const eventId = uuidv4();
const createEvent = await makeAPICall("POST", "/api/public/events", {
id: eventId,
traceId: traceId,
name: "event-name",
startTime: "2021-01-01T00:00:00.000Z",
input: { input: "value" },
output: { output: "value" },
metadata: { meta: "value" },
version: "2.0.0",
});
const createEvent = await makeZodVerifiedAPICall(
PostEventsV1Response,
"POST",
"/api/public/events",
{
id: eventId,
traceId: traceId,
name: "event-name",
startTime: "2021-01-01T00:00:00.000Z",
input: { input: "value" },
output: { output: "value" },
metadata: { meta: "value" },
version: "2.0.0",
},
);
expect(createEvent.status).toBe(200);
const dbEvent = await prisma.observation.findUnique({
@@ -66,16 +81,21 @@ describe("/api/public/events API Endpoint", () => {
const traceId = uuidv4();
const eventId = uuidv4();
const createEvent = await makeAPICall("POST", "/api/public/events", {
id: eventId,
traceId: traceId,
name: "event-name",
startTime: "2021-01-01T00:00:00.000Z",
input: { input: "value" },
output: { output: "value" },
metadata: { meta: "value" },
version: "2.0.0",
});
const createEvent = await makeZodVerifiedAPICall(
PostEventsV1Response,
"POST",
"/api/public/events",
{
id: eventId,
traceId: traceId,
name: "event-name",
startTime: "2021-01-01T00:00:00.000Z",
input: { input: "value" },
output: { output: "value" },
metadata: { meta: "value" },
version: "2.0.0",
},
);
expect(createEvent.status).toBe(200);
const dbEvent = await prisma.observation.findUnique({
@@ -93,15 +113,20 @@ describe("/api/public/events API Endpoint", () => {
expect(dbEvent?.metadata).toEqual({ meta: "value" });
expect(dbEvent?.version).toBe("2.0.0");
await makeAPICall("POST", "/api/public/traces", {
id: traceId,
name: "trace-name",
userId: "user-1",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
});
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
id: traceId,
name: "trace-name",
userId: "user-1",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
},
);
const dbTrace = await prisma.trace.findMany({
where: {
@@ -118,15 +143,20 @@ describe("/api/public/events API Endpoint", () => {
const traceId = uuidv4();
const response = await makeAPICall("POST", "/api/public/traces", {
externalId: traceId,
name: "trace-name",
userId: "user-1",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
});
const response = await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
externalId: traceId,
name: "trace-name",
userId: "user-1",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
},
);
expect(response.status).toBe(200);
@@ -141,17 +171,22 @@ describe("/api/public/events API Endpoint", () => {
expect(dbTrace[0]?.id).not.toBe(traceId);
const eventId = uuidv4();
const createEvent = await makeAPICall("POST", "/api/public/events", {
id: eventId,
traceIdType: "EXTERNAL",
traceId: dbTrace[0]?.id,
name: "event-name",
startTime: "2021-01-01T00:00:00.000Z",
input: { input: "value" },
output: { output: "value" },
metadata: { meta: "value" },
version: "2.0.0",
});
const createEvent = await makeZodVerifiedAPICall(
PostEventsV1Response,
"POST",
"/api/public/events",
{
id: eventId,
traceIdType: "EXTERNAL",
traceId: dbTrace[0]?.id,
name: "event-name",
startTime: "2021-01-01T00:00:00.000Z",
input: { input: "value" },
output: { output: "value" },
metadata: { meta: "value" },
version: "2.0.0",
},
);
expect(createEvent.status).toBe(200);
const dbEvent = await prisma.observation.findUnique({
@@ -174,16 +209,21 @@ describe("/api/public/events API Endpoint", () => {
const eventName = uuidv4();
const spanId = uuidv4();
const createEvent = await makeAPICall("POST", "/api/public/events", {
id: spanId,
name: eventName,
startTime: "2021-01-01T00:00:00.000Z",
endTime: "2021-01-01T00:00:00.000Z",
input: { input: "value" },
output: { output: "value" },
metadata: { meta: "value" },
version: "2.0.0",
});
const createEvent = await makeZodVerifiedAPICall(
PostEventsV1Response,
"POST",
"/api/public/events",
{
id: spanId,
name: eventName,
startTime: "2021-01-01T00:00:00.000Z",
endTime: "2021-01-01T00:00:00.000Z",
input: { input: "value" },
output: { output: "value" },
metadata: { meta: "value" },
version: "2.0.0",
},
);
const dbTrace = await prisma.trace.findMany({
where: {
@@ -215,14 +255,19 @@ describe("/api/public/events API Endpoint", () => {
const generationName = uuidv4();
const spanId = uuidv4();
const createSpan = await makeAPICall("POST", "/api/public/events", {
id: spanId,
name: generationName,
startTime: "2021-01-01T00:00:00.000Z",
input: { key: "value" },
metadata: { key: "value" },
version: "2.0.0",
});
const createSpan = await makeZodVerifiedAPICall(
PostEventsV1Response,
"POST",
"/api/public/events",
{
id: spanId,
name: generationName,
startTime: "2021-01-01T00:00:00.000Z",
input: { key: "value" },
metadata: { key: "value" },
version: "2.0.0",
},
);
const dbEvent = await prisma.observation.findFirstOrThrow({
where: {
+75 -39
View File
@@ -2,9 +2,19 @@
import { v4 as uuidv4 } from "uuid";
import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
import {
makeAPICall,
makeZodVerifiedAPICall,
pruneDatabase,
} from "@/src/__tests__/test-utils";
import { ModelUsageUnit } from "@langfuse/shared";
import { prisma } from "@langfuse/shared/src/db";
import { PostTracesV1Response } from "@/src/features/public-api/types/traces";
import {
PatchGenerationsV1Response,
PostGenerationsV1Body,
PostGenerationsV1Response,
} from "@/src/features/public-api/types/generations";
describe("/api/public/generations API Endpoint", () => {
beforeEach(async () => await pruneDatabase());
@@ -90,15 +100,20 @@ describe("/api/public/generations API Endpoint", () => {
const traceId = uuidv4();
await makeAPICall("POST", "/api/public/traces", {
id: traceId,
name: "trace-name",
userId: "user-1",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
});
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
id: traceId,
name: "trace-name",
userId: "user-1",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
},
);
const dbTrace = await prisma.trace.findMany({
where: {
@@ -110,7 +125,8 @@ describe("/api/public/generations API Endpoint", () => {
expect(dbTrace[0]?.id).toBe(traceId);
const generationId = uuidv4();
const createGeneration = await makeAPICall(
const createGeneration = await makeZodVerifiedAPICall(
PostGenerationsV1Response,
"POST",
"/api/public/generations",
{
@@ -164,7 +180,8 @@ describe("/api/public/generations API Endpoint", () => {
const traceId = uuidv4();
const generationId = uuidv4();
const createGeneration = await makeAPICall(
const createGeneration = await makeZodVerifiedAPICall(
PostGenerationsV1Body,
"POST",
"/api/public/generations",
{
@@ -201,15 +218,20 @@ describe("/api/public/generations API Endpoint", () => {
expect(dbGeneration?.metadata).toEqual({ key: "value" });
expect(dbGeneration?.version).toBe("2.0.0");
await makeAPICall("POST", "/api/public/traces", {
id: traceId,
name: "trace-name",
userId: "user-1",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
});
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
id: traceId,
name: "trace-name",
userId: "user-1",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
},
);
const dbTrace = await prisma.trace.findMany({
where: {
@@ -224,15 +246,20 @@ describe("/api/public/generations API Endpoint", () => {
it("should create generation after trace ignoring externalId", async () => {
const traceId = uuidv4();
const response = await makeAPICall("POST", "/api/public/traces", {
externalId: traceId,
name: "trace-name",
userId: "user-1",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
});
const response = await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
externalId: traceId,
name: "trace-name",
userId: "user-1",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
},
);
expect(response.status).toBe(200);
@@ -247,7 +274,8 @@ describe("/api/public/generations API Endpoint", () => {
expect(dbTrace[0]?.id).not.toBe(traceId);
const generationId = uuidv4();
const createGeneration = await makeAPICall(
const createGeneration = await makeZodVerifiedAPICall(
PostGenerationsV1Body,
"POST",
"/api/public/generations",
{
@@ -290,7 +318,8 @@ describe("/api/public/generations API Endpoint", () => {
const generationName = uuidv4();
const generationId = uuidv4();
const createGeneration = await makeAPICall(
const createGeneration = await makeZodVerifiedAPICall(
PostGenerationsV1Body,
"POST",
"/api/public/generations",
{
@@ -340,7 +369,8 @@ describe("/api/public/generations API Endpoint", () => {
const generationName = uuidv4();
const generationId = uuidv4();
const createGeneration = await makeAPICall(
const createGeneration = await makeZodVerifiedAPICall(
PostGenerationsV1Body,
"POST",
"/api/public/generations",
{
@@ -368,7 +398,8 @@ describe("/api/public/generations API Endpoint", () => {
const generationId2 = uuidv4();
const generationName2 = uuidv4();
const createGeneration2 = await makeAPICall(
const createGeneration2 = await makeZodVerifiedAPICall(
PostGenerationsV1Body,
"POST",
"/api/public/generations",
{
@@ -401,7 +432,8 @@ describe("/api/public/generations API Endpoint", () => {
const generationId = uuidv4();
const externalTraceId = uuidv4();
const createGeneration = await makeAPICall(
const createGeneration = await makeZodVerifiedAPICall(
PostGenerationsV1Body,
"POST",
"/api/public/generations",
{
@@ -437,7 +469,8 @@ describe("/api/public/generations API Endpoint", () => {
const generationName = uuidv4();
const generationId = uuidv4();
const createGeneration = await makeAPICall(
const createGeneration = await makeZodVerifiedAPICall(
PostGenerationsV1Body,
"POST",
"/api/public/generations",
{
@@ -488,7 +521,8 @@ describe("/api/public/generations API Endpoint", () => {
const generationName = uuidv4();
const generationId = uuidv4();
const createGeneration = await makeAPICall(
const createGeneration = await makeZodVerifiedAPICall(
PostGenerationsV1Body,
"POST",
"/api/public/generations",
{
@@ -506,7 +540,8 @@ describe("/api/public/generations API Endpoint", () => {
expect(createGeneration.status).toBe(200);
const updateGeneration = await makeAPICall(
const updateGeneration = await makeZodVerifiedAPICall(
PatchGenerationsV1Response,
"PATCH",
"/api/public/generations",
{
@@ -540,7 +575,8 @@ describe("/api/public/generations API Endpoint", () => {
const generationName = uuidv4();
const generationId = uuidv4();
const createGeneration = await makeAPICall(
const createGeneration = await makeZodVerifiedAPICall(
PostGenerationsV1Body,
"POST",
"/api/public/generations",
{
+11
View File
@@ -57,6 +57,17 @@ describe("/api/public/ingestion API Endpoint", () => {
expectedCompletionTokens: 2,
expectedTotalTokens: 3,
},
{
usage: {
input: 1,
output: 2,
unit: ModelUsageUnit.Requests,
},
expectedUnit: ModelUsageUnit.Requests,
expectedPromptTokens: 1,
expectedCompletionTokens: 2,
expectedTotalTokens: 3,
},
{
usage: {
input: 30,
+68
View File
@@ -0,0 +1,68 @@
import { containsAnyMarkdown } from "@/src/components/schemas/MarkdownSchema";
describe("containsAnyMarkdown Function", () => {
it("Detects simple bold using asterisks", () => {
expect(containsAnyMarkdown("This is **bold** text")).toBe(true);
});
it("Detects simple italics using asterisks", () => {
expect(containsAnyMarkdown("This is *italic* text")).toBe(true);
});
it("Detects bold using underscores", () => {
expect(containsAnyMarkdown("This is __bold__ text")).toBe(true);
});
it("Detects italics using underscores", () => {
expect(containsAnyMarkdown("This is _italic_ text")).toBe(true);
});
it("Detects inline code with backticks", () => {
expect(containsAnyMarkdown("This is `code` inline")).toBe(true);
});
it("Detects fenced code blocks", () => {
const codeBlock = "```\nlet x = 10;\n```";
expect(containsAnyMarkdown(codeBlock)).toBe(true);
});
it("Detects headers", () => {
expect(containsAnyMarkdown("# Header 1")).toBe(true);
expect(containsAnyMarkdown("## Header 2")).toBe(true);
});
it("Detects unordered lists", () => {
expect(containsAnyMarkdown("- List item 1\n- List item 2")).toBe(true);
});
it("Detects ordered lists", () => {
expect(containsAnyMarkdown("1. First item\n2. Second item")).toBe(true);
});
it("Detects blockquotes", () => {
expect(containsAnyMarkdown("> This is a blockquote")).toBe(true);
});
it("Detects links", () => {
expect(containsAnyMarkdown("[Google](http://www.google.com)")).toBe(true);
});
it("Detects images", () => {
expect(containsAnyMarkdown("![Alt text](http://url/to/img.png)")).toBe(
true,
);
});
it("Returns false for non-markdown text", () => {
expect(
containsAnyMarkdown("This is plain text without any markdown syntax"),
).toBe(false);
expect(containsAnyMarkdown("12345")).toBe(false);
expect(containsAnyMarkdown("Simple text.")).toBe(false);
});
it("Detects combination of markdown elements", () => {
const complexMarkdown = "**Bold** and _italic_ and `code`";
expect(containsAnyMarkdown(complexMarkdown)).toBe(true);
});
});
+136 -41
View File
@@ -1,4 +1,10 @@
import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
import {
makeZodVerifiedAPICall,
pruneDatabase,
} from "@/src/__tests__/test-utils";
import { PostGenerationsV1Response } from "@/src/features/public-api/types/generations";
import { GetMetricsDailyV1Response } from "@/src/features/public-api/types/metrics";
import { PostTracesV1Response } from "@/src/features/public-api/types/traces";
import { v4 as uuidv4 } from "uuid";
describe("/api/public/metrics/daily API Endpoint", () => {
@@ -6,53 +12,89 @@ describe("/api/public/metrics/daily API Endpoint", () => {
afterEach(async () => await pruneDatabase());
it("should handle daily metrics correctly", async () => {
await pruneDatabase();
// Create traces with observations on different days
const traceId1 = uuidv4();
const traceId2 = uuidv4();
await makeAPICall("POST", "/api/public/traces", {
id: traceId1,
timestamp: "2021-01-01T00:00:00.000Z",
name: "trace-day-1",
userId: "user-daily-metrics",
projectId: "project-daily-metrics",
});
await makeAPICall("POST", "/api/public/traces", {
id: traceId2,
timestamp: "2021-01-02T00:00:00.000Z",
name: "trace-day-2",
userId: "user-daily-metrics",
projectId: "project-daily-metrics",
});
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
id: traceId1,
timestamp: "2021-01-01T00:00:00.000Z",
name: "trace-day-1",
userId: "user-daily-metrics",
projectId: "project-daily-metrics",
},
);
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
id: traceId2,
timestamp: "2021-01-02T00:00:00.000Z",
name: "trace-day-2",
userId: "user-daily-metrics",
projectId: "project-daily-metrics",
},
);
// Simulate observations with usage metrics on different days
await makeAPICall("POST", "/api/public/generations", {
traceId: traceId1,
model: "modelA",
usage: { input: 100, output: 200, total: 300 },
startTime: "2021-01-01T00:00:00.000Z",
endTime: "2021-01-01T00:01:00.000Z",
});
await makeAPICall("POST", "/api/public/generations", {
traceId: traceId2,
model: "modelB",
usage: { input: 333 },
startTime: "2021-01-02T00:00:00.000Z",
endTime: "2021-01-02T00:02:00.000Z",
});
await makeAPICall("POST", "/api/public/generations", {
traceId: traceId2,
model: "modelC",
usage: { input: 666, output: 777, totalCost: 1024.22 },
startTime: "2021-01-02T00:00:00.000Z",
endTime: "2021-01-02T00:04:00.000Z",
});
await makeZodVerifiedAPICall(
PostGenerationsV1Response,
"POST",
"/api/public/generations",
{
traceId: traceId1,
model: "modelA",
usage: { input: 100, output: 200, total: 300 },
startTime: "2021-01-01T00:00:00.000Z",
endTime: "2021-01-01T00:01:00.000Z",
},
);
await makeZodVerifiedAPICall(
PostGenerationsV1Response,
"POST",
"/api/public/generations",
{
traceId: traceId2,
model: "modelB",
usage: { input: 333 },
startTime: "2021-01-02T00:00:00.000Z",
endTime: "2021-01-02T00:02:00.000Z",
},
);
await makeZodVerifiedAPICall(
PostGenerationsV1Response,
"POST",
"/api/public/generations",
{
traceId: traceId2,
model: "modelC",
usage: { input: 666, output: 777, totalCost: 1024.22 },
startTime: "2021-01-02T00:00:00.000Z",
endTime: "2021-01-02T00:04:00.000Z",
},
);
await makeZodVerifiedAPICall(
PostGenerationsV1Response,
"POST",
"/api/public/generations",
{
traceId: traceId2,
usage: { output: 300 },
startTime: "2021-01-02T00:00:00.000Z",
endTime: "2021-01-02T00:04:00.000Z",
},
);
// Retrieve the daily metrics
const dailyMetricsResponse = await makeAPICall<{
data: Array<Record<string, unknown>>;
}>("GET", `/api/public/metrics/daily`);
const dailyMetricsResponse = await makeZodVerifiedAPICall(
GetMetricsDailyV1Response,
"GET",
`/api/public/metrics/daily`,
);
const dailyMetricsData = dailyMetricsResponse.body.data;
// Check if the daily metrics are calculated correctly
@@ -81,6 +123,15 @@ describe("/api/public/metrics/daily API Endpoint", () => {
countTraces: 1,
totalCost: 1024.22,
},
{
model: null,
countObservations: 1,
countTraces: 1,
inputUsage: 0,
outputUsage: 300,
totalCost: 0,
totalUsage: 300,
},
]);
if (!dailyMetricsData[1])
@@ -100,4 +151,48 @@ describe("/api/public/metrics/daily API Endpoint", () => {
},
]);
});
it("should handle daily metrics correctly when there is no data", async () => {
// Retrieve the daily metrics
const dailyMetricsResponse = await makeZodVerifiedAPICall(
GetMetricsDailyV1Response,
"GET",
`/api/public/metrics/daily`,
);
const dailyMetricsData = dailyMetricsResponse.body.data;
// Check if the daily metrics are calculated correctly
expect(dailyMetricsData).toHaveLength(0); // No data
});
it("should handle daily metrics correctly when there is just a trace", async () => {
const traceId1 = uuidv4();
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
id: traceId1,
timestamp: "2021-01-01T00:00:00.000Z",
name: "trace-day-1",
userId: "user-daily-metrics",
projectId: "project-daily-metrics",
},
);
// Retrieve the daily metrics
const dailyMetricsResponse = await makeZodVerifiedAPICall(
GetMetricsDailyV1Response,
"GET",
`/api/public/metrics/daily`,
);
const dailyMetricsData = dailyMetricsResponse.body.data;
// Check if the daily metrics are calculated correctly
expect(dailyMetricsData).toHaveLength(1);
expect(dailyMetricsData[0].date).toBe("2021-01-01");
expect(dailyMetricsData[0].countTraces).toBe(1);
expect(dailyMetricsData[0].totalCost).toEqual(0);
expect(dailyMetricsData[0].usage).toEqual([]);
});
});
+248 -48
View File
@@ -2,10 +2,16 @@
import { v4 as uuidv4 } from "uuid";
import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
import {
makeZodVerifiedAPICall,
pruneDatabase,
} from "@/src/__tests__/test-utils";
import { ModelUsageUnit } from "@langfuse/shared";
import { prisma } from "@langfuse/shared/src/db";
import { type ObservationView } from "@langfuse/shared";
import {
GetObservationV1Response,
GetObservationsV1Response,
} from "@/src/features/public-api/types/observations";
describe("/api/public/observations API Endpoint", () => {
beforeEach(async () => await pruneDatabase());
@@ -79,7 +85,8 @@ describe("/api/public/observations API Endpoint", () => {
},
});
const fetchedObservations = await makeAPICall(
const fetchedObservations = await makeZodVerifiedAPICall(
GetObservationsV1Response,
"GET",
"/api/public/observations",
undefined,
@@ -87,13 +94,6 @@ describe("/api/public/observations API Endpoint", () => {
expect(fetchedObservations.status).toBe(200);
if (!isObservationList(fetchedObservations.body)) {
throw new Error(
"Expected body to be an array of observations" +
JSON.stringify(fetchedObservations.body),
);
}
expect(fetchedObservations.body.data.length).toBe(1);
expect(fetchedObservations.body.data[0]?.traceId).toBe(traceId);
expect(fetchedObservations.body.data[0]?.input).toEqual({ key: "input" });
@@ -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();
@@ -183,7 +187,8 @@ describe("/api/public/observations API Endpoint", () => {
},
});
const fetchedObservations = await makeAPICall(
const fetchedObservations = await makeZodVerifiedAPICall(
GetObservationsV1Response,
"GET",
"/api/public/observations?type=GENERATION",
undefined,
@@ -193,10 +198,6 @@ describe("/api/public/observations API Endpoint", () => {
expect(fetchedObservations.status).toBe(200);
if (!isObservationList(fetchedObservations.body)) {
throw new Error("Expected body to be an array of observations");
}
expect(fetchedObservations.body.data.length).toBe(1);
expect(fetchedObservations.body.data[0]?.traceId).toBe(traceId);
expect(fetchedObservations.body.data[0]?.input).toEqual({ key: "input" });
@@ -205,37 +206,236 @@ describe("/api/public/observations API Endpoint", () => {
});
});
const isObservationList = (val: unknown): val is ObservationResponse => {
return (
typeof val === "object" &&
val !== null &&
"data" in val &&
Array.isArray(val.data) &&
val.data.every(
(element) =>
typeof element === "object" &&
element !== null &&
"id" in element &&
"traceId" in element &&
"name" in element &&
"startTime" in element &&
"endTime" in element &&
"model" in element &&
"input" in element &&
"output" in element &&
"metadata" in element &&
"version" in element &&
"modelId" in element &&
"inputPrice" in element &&
"outputPrice" in element &&
"totalPrice" in element &&
"calculatedInputCost" in element &&
"calculatedOutputCost" in element &&
"calculatedTotalCost" in element,
)
);
};
it("GET /observations with timestamp filters and pagination", async () => {
await prisma.trace.create({
data: {
id: "trace-id",
name: "trace-name",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
},
});
await prisma.observation.createMany({
data: [
{
id: "observation-2021-01-01",
traceId: "trace-id",
name: "generation-name",
startTime: new Date("2021-01-01T00:00:00.000Z"),
endTime: new Date("2021-01-01T00:00:00.000Z"),
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
type: "GENERATION",
},
{
id: "observation-2021-02-01",
traceId: "trace-id",
name: "generation-name",
startTime: new Date("2021-02-01T00:00:00.000Z"),
endTime: new Date("2021-02-01T00:00:00.000Z"),
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
type: "SPAN",
},
{
id: "observation-2021-03-01",
traceId: "trace-id",
name: "generation-name",
startTime: new Date("2021-03-01T00:00:00.000Z"),
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
type: "EVENT",
},
{
id: "observation-2021-04-01",
traceId: "trace-id",
name: "generation-name",
startTime: new Date("2021-04-01T00:00:00.000Z"),
endTime: new Date("2021-04-01T00:00:00.000Z"),
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
type: "GENERATION",
},
],
});
type ObservationResponse = {
data: ObservationView[];
};
const fromTimestamp = "2021-02-01T00:00:00.000Z";
const toTimestamp = "2021-04-01T00:00:00.000Z";
// Test with both fromTimestamp and toTimestamp
let fetchedObservations = await makeZodVerifiedAPICall(
GetObservationsV1Response,
"GET",
`/api/public/observations?fromStartTime=${fromTimestamp}&toStartTime=${toTimestamp}`,
undefined,
);
expect(fetchedObservations.body.data.length).toBe(2);
expect(fetchedObservations.body.data[0]?.id).toBe("observation-2021-03-01");
expect(fetchedObservations.body.data[1]?.id).toBe("observation-2021-02-01");
expect(fetchedObservations.body.meta.totalItems).toBe(2);
// Test with only fromTimestamp
fetchedObservations = await makeZodVerifiedAPICall(
GetObservationsV1Response,
"GET",
`/api/public/observations?fromStartTime=${fromTimestamp}`,
undefined,
);
expect(fetchedObservations.body.data.length).toBe(3);
expect(fetchedObservations.body.data[0]?.id).toBe("observation-2021-04-01");
expect(fetchedObservations.body.data[1]?.id).toBe("observation-2021-03-01");
expect(fetchedObservations.body.data[2]?.id).toBe("observation-2021-02-01");
expect(fetchedObservations.body.meta.totalItems).toBe(3);
// Test with only toTimestamp
fetchedObservations = await makeZodVerifiedAPICall(
GetObservationsV1Response,
"GET",
`/api/public/observations?toStartTime=${toTimestamp}`,
undefined,
);
expect(fetchedObservations.body.data.length).toBe(3);
expect(fetchedObservations.body.data[0]?.id).toBe("observation-2021-03-01");
expect(fetchedObservations.body.data[1]?.id).toBe("observation-2021-02-01");
expect(fetchedObservations.body.data[2]?.id).toBe("observation-2021-01-01");
expect(fetchedObservations.body.meta.totalItems).toBe(3);
// test pagination only
fetchedObservations = await makeZodVerifiedAPICall(
GetObservationsV1Response,
"GET",
`/api/public/observations?limit=1&page=2`,
undefined,
);
expect(fetchedObservations.body.data.length).toBe(1);
expect(fetchedObservations.body.data[0]?.id).toBe("observation-2021-03-01");
expect(fetchedObservations.body.meta).toMatchObject({
totalItems: 4,
totalPages: 4,
page: 2,
limit: 1,
});
});
it("Get a single EVENT from /observations/:id", async () => {
const traceId = uuidv4();
await prisma.trace.create({
data: {
id: traceId,
name: "trace-name",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
},
});
// EVENT
const eventId = uuidv4();
await prisma.observation.create({
data: {
id: eventId,
traceId: traceId,
name: "generation-name",
startTime: new Date("2021-01-01T00:00:00.000Z"),
type: "EVENT",
project: {
connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" },
},
},
});
const getEventRes = await makeZodVerifiedAPICall(
GetObservationV1Response,
"GET",
"/api/public/observations/" + eventId,
);
expect(getEventRes.body).toMatchObject({
id: eventId,
traceId: traceId,
type: "EVENT",
});
});
it("Get a single GENERATION from /observations/:id", async () => {
const traceId = uuidv4();
await prisma.trace.create({
data: {
id: traceId,
name: "trace-name",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
},
});
const generationId = uuidv4();
await prisma.observation.create({
data: {
id: generationId,
traceId: traceId,
name: "generation-name",
startTime: new Date("2021-01-01T00:00:00.000Z"),
endTime: new Date("2021-01-01T00:00:00.000Z"),
model: "gpt-3.5-turbo",
internalModel: "gpt-3.5-turbo",
modelParameters: { key: "value" },
input: { key: "input" },
output: { key: "output" },
promptTokens: 10,
completionTokens: 20,
totalTokens: 30,
version: "2.0.0",
unit: ModelUsageUnit.Tokens,
type: "GENERATION",
project: {
connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" },
},
},
});
const getGenerationRes = await makeZodVerifiedAPICall(
GetObservationV1Response,
"GET",
"/api/public/observations/" + generationId,
);
expect(getGenerationRes.body).toMatchObject({
id: generationId,
traceId: traceId,
input: { key: "input" },
output: { key: "output" },
type: "GENERATION",
});
});
it("Get a single SPAN from /observations/:id", async () => {
const traceId = uuidv4();
await prisma.trace.create({
data: {
id: traceId,
name: "trace-name",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
},
});
const spanId = uuidv4();
await prisma.observation.create({
data: {
id: spanId,
traceId: traceId,
name: "generation-name",
startTime: new Date("2021-01-01T00:00:00.000Z"),
endTime: new Date("2021-01-01T00:00:00.000Z"),
modelParameters: { key: "value" },
input: { key: "input" },
output: { key: "output" },
version: "2.0.0",
type: "SPAN",
project: {
connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" },
},
},
});
const getSpanRes = await makeZodVerifiedAPICall(
GetObservationV1Response,
"GET",
"/api/public/observations/" + spanId,
);
expect(getSpanRes.body).toMatchObject({
id: spanId,
traceId: traceId,
input: { key: "input" },
output: { key: "output" },
type: "SPAN",
});
});
+321
View File
@@ -0,0 +1,321 @@
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>;
// Mocks
jest.mock("@prisma/client");
jest.mock("ioredis");
jest.mock("@langfuse/shared", () => ({
env: {
LANGFUSE_CACHE_PROMPT_ENABLED: "true",
LANGFUSE_CACHE_PROMPT_TTL_SECONDS: 300,
},
}));
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");
});
});
});
+1 -1
View File
@@ -219,7 +219,7 @@ describe("/api/public/prompts API Endpoint", () => {
});
// Third prompt is deactivated
const prompt3 = await makeAPICall("POST", "/api/public/prompts", {
await makeAPICall("POST", "/api/public/prompts", {
name: "prompt-name",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
prompt: "prompt3",
+166 -58
View File
@@ -13,6 +13,7 @@ import {
GetScoresResponse,
} from "@/src/features/public-api/types/scores";
import { z } from "zod";
import { PostTracesV1Response } from "@/src/features/public-api/types/traces";
const traceId = "de98afa2-89dc-47e9-9924-33f1490fdaf4";
@@ -21,15 +22,20 @@ describe("/api/public/scores API Endpoint", () => {
beforeEach(async () => {
if (should_prune_db) await pruneDatabase();
await makeAPICall("POST", "/api/public/traces", {
id: traceId,
name: "trace-name",
userId: "user-1",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
});
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
id: traceId,
name: "trace-name",
userId: "user-1",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
},
);
});
afterEach(async () => {
if (should_prune_db) await pruneDatabase();
@@ -105,22 +111,60 @@ describe("/api/public/scores API Endpoint", () => {
expect(fetchedScore.body?.observationId).toBeNull();
});
it("should GET score with minimal score data and minimal trace data", async () => {
const minimalTraceId = uuidv4();
await makeAPICall("POST", "/api/public/traces", {
id: minimalTraceId,
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
});
const dbTrace = await prisma.trace.findMany({
where: {
id: minimalTraceId,
},
});
expect(dbTrace.length).toBeGreaterThan(0);
expect(dbTrace[0]?.id).toBe(minimalTraceId);
const minimalScoreId = uuidv4();
await makeAPICall("POST", "/api/public/scores", {
id: minimalScoreId,
name: "score-name",
value: 100,
traceId: minimalTraceId,
});
const fetchedScore = await makeZodVerifiedAPICall(
GetScoreResponse,
"GET",
`/api/public/scores/${minimalScoreId}`,
);
expect(fetchedScore.status).toBe(200);
});
it("should create score for a generation", async () => {
await pruneDatabase();
const generationId = uuidv4();
await makeAPICall("POST", "/api/public/generations", {
id: generationId,
name: "generation-name",
startTime: "2021-01-01T00:00:00.000Z",
endTime: "2021-01-01T00:00:00.000Z",
model: "model-name",
modelParameters: { key: "value" },
prompt: { key: "value" },
metadata: { key: "value" },
version: "2.0.0",
});
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/generations",
{
id: generationId,
name: "generation-name",
startTime: "2021-01-01T00:00:00.000Z",
endTime: "2021-01-01T00:00:00.000Z",
model: "model-name",
modelParameters: { key: "value" },
prompt: { key: "value" },
metadata: { key: "value" },
version: "2.0.0",
},
);
const dbGeneration = await prisma.observation.findMany({
where: {
@@ -399,10 +443,8 @@ describe("/api/public/scores API Endpoint", () => {
expect(createScore.status).toBe(400);
expect(createScore.body).toMatchObject({
message: "Invalid request data",
errors: [
message:
"Data type mismatch based on config: expected NUMERIC, got CATEGORICAL",
],
});
});
@@ -451,10 +493,8 @@ describe("/api/public/scores API Endpoint", () => {
expect(createScore.status).toBe(400);
expect(createScore.body).toMatchObject({
message: "Invalid request data",
errors: [
message:
"Data type mismatch based on config: expected CATEGORICAL, got NUMERIC",
],
});
});
@@ -557,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"],
},
],
@@ -664,10 +705,8 @@ describe("/api/public/scores API Endpoint", () => {
expect(createScore.status).toBe(400);
expect(createScore.body).toMatchObject({
message: "Invalid request data",
errors: [
message:
"Ingested score body not valid against provided config: - Value exceeds maximum value of 0 defined in config",
],
});
});
@@ -682,18 +721,23 @@ describe("/api/public/scores API Endpoint", () => {
expect(dbTrace[0]?.id).toBe(traceId);
const generationId = uuidv4();
await makeAPICall("POST", "/api/public/generations", {
id: generationId,
name: "generation-name",
traceId,
startTime: "2021-01-01T00:00:00.000Z",
endTime: "2021-01-01T00:00:00.000Z",
model: "model-name",
modelParameters: { key: "value" },
prompt: { key: "value" },
metadata: { key: "value" },
version: "2.0.0",
});
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/generations",
{
id: generationId,
name: "generation-name",
traceId,
startTime: "2021-01-01T00:00:00.000Z",
endTime: "2021-01-01T00:00:00.000Z",
model: "model-name",
modelParameters: { key: "value" },
prompt: { key: "value" },
metadata: { key: "value" },
version: "2.0.0",
},
);
const dbGeneration = await prisma.observation.findMany({
where: {
@@ -743,9 +787,14 @@ describe("/api/public/scores API Endpoint", () => {
const traceId = uuidv4();
await makeAPICall("POST", "/api/public/traces", {
id: traceId,
});
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
id: traceId,
},
);
const scoreId = uuidv4();
const createScore = await makeAPICall("POST", "/api/public/scores", {
@@ -783,13 +832,23 @@ describe("/api/public/scores API Endpoint", () => {
const traceId = uuidv4();
await makeAPICall("POST", "/api/public/traces", {
id: traceId,
});
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
id: traceId,
},
);
const generationId = uuidv4();
await makeAPICall("POST", "/api/public/generations", {
id: generationId,
});
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/generations",
{
id: generationId,
},
);
const scoreId = uuidv4();
await makeAPICall("POST", "/api/public/scores", {
@@ -835,13 +894,23 @@ describe("/api/public/scores API Endpoint", () => {
should_prune_db = false;
await pruneDatabase();
await makeAPICall("POST", "/api/public/traces", {
id: traceId,
userId: userId,
});
await makeAPICall("POST", "/api/public/generations", {
id: generationId,
});
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
id: traceId,
userId: userId,
},
);
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/generations",
{
id: generationId,
},
);
await makeAPICall("POST", "/api/public/score-configs", {
name: scoreName,
@@ -1124,6 +1193,45 @@ describe("/api/public/scores API Endpoint", () => {
},
]);
});
it("should GET ALL scores with minimal score data and minimal trace data", async () => {
const minimalTraceId = uuidv4();
await makeAPICall("POST", "/api/public/traces", {
id: minimalTraceId,
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
});
const dbTrace = await prisma.trace.findMany({
where: {
id: minimalTraceId,
},
});
expect(dbTrace.length).toBeGreaterThan(0);
expect(dbTrace[0]?.id).toBe(minimalTraceId);
const createScore = await makeAPICall("POST", "/api/public/scores", {
name: "score-name",
value: 100,
traceId: minimalTraceId,
});
expect(createScore.status).toBe(200);
const fetchedScores = await makeZodVerifiedAPICall(
GetScoresResponse,
"GET",
`/api/public/scores`,
);
expect(fetchedScores.status).toBe(200);
expect(fetchedScores.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 4,
totalPages: 1,
});
expect(fetchedScores.body.data.length).toBe(4);
});
it("test invalid operator", async () => {
try {
await makeZodVerifiedAPICall(
+154 -16
View File
@@ -1,20 +1,33 @@
/** @jest-environment node */
import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
import {
makeZodVerifiedAPICall,
pruneDatabase,
} from "@/src/__tests__/test-utils";
import {
GetSessionV1Response,
GetSessionsV1Response,
} from "@/src/features/public-api/types/sessions";
import { PostTracesV1Response } from "@/src/features/public-api/types/traces";
import { prisma } from "@langfuse/shared/src/db";
describe("/api/public/traces API Endpoint", () => {
describe("Create and get sessions", () => {
beforeEach(async () => await pruneDatabase());
afterEach(async () => await pruneDatabase());
it("should create a session via a trace", async () => {
await pruneDatabase();
await makeAPICall("POST", "/api/public/traces", {
name: "trace-name",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
sessionId: "session-id",
});
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
name: "trace-name",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
sessionId: "session-id",
},
);
const dbSession = await prisma.traceSession.findFirst({
where: {
@@ -32,16 +45,22 @@ describe("/api/public/traces API Endpoint", () => {
it("should get session including traces", async () => {
await pruneDatabase();
await makeAPICall("POST", "/api/public/traces", {
name: "trace-name",
id: "trace-id",
input: { hello: "world" },
output: "hi",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
sessionId: "session-id",
});
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
name: "trace-name",
id: "trace-id",
input: { hello: "world" },
output: "hi",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
sessionId: "session-id",
},
);
const response = await makeAPICall(
const response = await makeZodVerifiedAPICall(
GetSessionV1Response,
"GET",
"/api/public/sessions/session-id",
);
@@ -60,3 +79,122 @@ describe("/api/public/traces API Endpoint", () => {
});
});
});
describe("GET /api/public/sessions API Endpoint", () => {
beforeEach(async () => {
await pruneDatabase();
await prisma.traceSession.createMany({
data: [
{
id: "session-2021-01-01",
createdAt: new Date("2021-01-01T00:00:00Z"),
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
},
{
id: "session-2021-02-01",
createdAt: new Date("2021-02-01T00:00:00Z"),
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
},
{
id: "session-2021-03-01",
createdAt: new Date("2021-03-01T00:00:00Z"),
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
},
{
id: "session-2021-04-01",
createdAt: new Date("2021-04-01T00:00:00Z"),
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
},
{
id: "session-2021-05-01",
createdAt: new Date("2021-05-01T00:00:00Z"),
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
},
],
});
});
afterEach(async () => await pruneDatabase());
it("should return all sessions", async () => {
const sessions = await makeZodVerifiedAPICall(
GetSessionsV1Response,
"GET",
"/api/public/sessions",
);
expect(sessions.body.data).toHaveLength(5);
});
it("should return paginated sessions with limit and page number", async () => {
const limit = 2;
const page = 2;
const sessions = await makeZodVerifiedAPICall(
GetSessionsV1Response,
"GET",
`/api/public/sessions?limit=${limit}&page=${page}`,
);
expect(sessions.body.data).toHaveLength(2);
expect(sessions.body.data.map((session) => session.id)).toEqual([
"session-2021-03-01",
"session-2021-02-01",
]);
expect(sessions.body.meta.totalItems).toBe(5);
expect(sessions.body.meta.totalPages).toBe(3);
expect(sessions.body.meta.page).toBe(page);
expect(sessions.body.meta.limit).toBe(limit);
});
it("should return sessions within a specific date range", async () => {
const fromTimestamp = "2021-02-01T00:00:00Z";
const toTimestamp = "2021-04-01T00:00:00Z";
const sessions = await makeZodVerifiedAPICall(
GetSessionsV1Response,
"GET",
`/api/public/sessions?fromTimestamp=${fromTimestamp}&toTimestamp=${toTimestamp}`,
);
expect(sessions.body.data).toHaveLength(2);
expect(sessions.body.data.map((session) => session.id)).toEqual([
"session-2021-03-01",
"session-2021-02-01",
]);
expect(sessions.body.meta.totalItems).toBe(2);
});
it("should return sessions from a specific date onwards (including the date)", async () => {
const fromTimestamp = "2021-03-01T00:00:00Z";
const sessions = await makeZodVerifiedAPICall(
GetSessionsV1Response,
"GET",
`/api/public/sessions?fromTimestamp=${fromTimestamp}`,
);
expect(sessions.body.data).toHaveLength(3);
expect(sessions.body.data.map((session) => session.id)).toEqual([
"session-2021-05-01",
"session-2021-04-01",
"session-2021-03-01",
]);
expect(sessions.body.meta.totalItems).toBe(3);
});
it("should return sessions up to a specific date (excluding it)", async () => {
const toTimestamp = "2021-03-01T00:00:00Z";
const sessions = await makeZodVerifiedAPICall(
GetSessionsV1Response,
"GET",
`/api/public/sessions?toTimestamp=${toTimestamp}`,
);
expect(sessions.body.data).toHaveLength(2);
expect(sessions.body.data.map((session) => session.id)).toEqual([
"session-2021-02-01",
"session-2021-01-01",
]);
expect(sessions.body.meta.totalItems).toBe(2);
});
});
+159 -99
View File
@@ -1,27 +1,39 @@
/** @jest-environment node */
import { prisma } from "@langfuse/shared/src/db";
import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
import {
makeAPICall,
makeZodVerifiedAPICall,
pruneDatabase,
} from "@/src/__tests__/test-utils";
import { v4 as uuidv4 } from "uuid";
import { PostTracesV1Response } from "@/src/features/public-api/types/traces";
import {
PatchSpansV1Response,
PostSpansV1Response,
} from "@/src/features/public-api/types/spans";
describe("/api/public/spans API Endpoint", () => {
beforeEach(async () => await pruneDatabase());
afterEach(async () => await pruneDatabase());
it("should create span after trace", async () => {
await pruneDatabase();
const traceId = uuidv4();
await makeAPICall("POST", "/api/public/traces", {
id: traceId,
name: "trace-name",
userId: "user-1",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
});
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
id: traceId,
name: "trace-name",
userId: "user-1",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
},
);
const dbTrace = await prisma.trace.findMany({
where: {
@@ -33,16 +45,21 @@ describe("/api/public/spans API Endpoint", () => {
expect(dbTrace[0]?.id).toBe(traceId);
const spanId = uuidv4();
const createSpan = await makeAPICall("POST", "/api/public/spans", {
id: spanId,
traceId: traceId,
name: "span-name",
startTime: "2021-01-01T00:00:00.000Z",
endTime: "2021-01-01T00:00:00.000Z",
input: { input: "value" },
metadata: { meta: "value" },
version: "2.0.0",
});
const createSpan = await makeZodVerifiedAPICall(
PostSpansV1Response,
"POST",
"/api/public/spans",
{
id: spanId,
traceId: traceId,
name: "span-name",
startTime: "2021-01-01T00:00:00.000Z",
endTime: "2021-01-01T00:00:00.000Z",
input: { input: "value" },
metadata: { meta: "value" },
version: "2.0.0",
},
);
expect(createSpan.status).toBe(200);
const dbSpan = await prisma.observation.findUnique({
@@ -62,21 +79,24 @@ describe("/api/public/spans API Endpoint", () => {
});
it("should create span before trace", async () => {
await pruneDatabase();
const traceId = uuidv4();
const spanId = uuidv4();
const createSpan = await makeAPICall("POST", "/api/public/spans", {
id: spanId,
traceId: traceId,
name: "span-name",
startTime: "2021-01-01T00:00:00.000Z",
endTime: "2021-01-01T00:00:00.000Z",
input: { input: "value" },
metadata: { meta: "value" },
version: "2.0.0",
});
const createSpan = await makeZodVerifiedAPICall(
PostSpansV1Response,
"POST",
"/api/public/spans",
{
id: spanId,
traceId: traceId,
name: "span-name",
startTime: "2021-01-01T00:00:00.000Z",
endTime: "2021-01-01T00:00:00.000Z",
input: { input: "value" },
metadata: { meta: "value" },
version: "2.0.0",
},
);
expect(createSpan.status).toBe(200);
const dbSpan = await prisma.observation.findUnique({
@@ -94,15 +114,20 @@ describe("/api/public/spans API Endpoint", () => {
expect(dbSpan?.metadata).toEqual({ meta: "value" });
expect(dbSpan?.version).toBe("2.0.0");
await makeAPICall("POST", "/api/public/traces", {
id: traceId,
name: "trace-name",
userId: "user-1",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
});
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
id: traceId,
name: "trace-name",
userId: "user-1",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
},
);
const dbTrace = await prisma.trace.findMany({
where: {
@@ -118,21 +143,31 @@ describe("/api/public/spans API Endpoint", () => {
const traceId = uuidv4();
const spanId = uuidv4();
const response = await makeAPICall("POST", "/api/public/traces", {
externalId: uuidv4(),
id: traceId,
name: "trace-name",
userId: "user-1",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
});
const response = await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
externalId: uuidv4(),
id: traceId,
name: "trace-name",
userId: "user-1",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
},
);
expect(response.status).toBe(200);
const createSpan = await makeAPICall("POST", "/api/public/spans", {
id: spanId,
name: "spanName",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
traceId: traceId,
externalTraceIdType: "EXTERNAL",
});
const createSpan = await makeZodVerifiedAPICall(
PostSpansV1Response,
"POST",
"/api/public/spans",
{
id: spanId,
name: "spanName",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
traceId: traceId,
externalTraceIdType: "EXTERNAL",
},
);
expect(createSpan.status).toBe(200);
const dbSpan = await prisma.observation.findFirstOrThrow({
@@ -148,15 +183,20 @@ describe("/api/public/spans API Endpoint", () => {
const spanName = uuidv4();
const spanId = uuidv4();
const createSpan = await makeAPICall("POST", "/api/public/spans", {
id: spanId,
name: spanName,
startTime: "2021-01-01T00:00:00.000Z",
endTime: "2021-01-01T00:00:00.000Z",
input: { input: "value" },
metadata: { meta: "value" },
version: "2.0.0",
});
const createSpan = await makeZodVerifiedAPICall(
PostSpansV1Response,
"POST",
"/api/public/spans",
{
id: spanId,
name: spanName,
startTime: "2021-01-01T00:00:00.000Z",
endTime: "2021-01-01T00:00:00.000Z",
input: { input: "value" },
metadata: { meta: "value" },
version: "2.0.0",
},
);
const dbTrace = await prisma.trace.findMany({
where: {
@@ -189,17 +229,22 @@ describe("/api/public/spans API Endpoint", () => {
const spanId = uuidv4();
const traceId = uuidv4();
const createSpan = await makeAPICall("POST", "/api/public/spans", {
id: spanId,
traceIdType: "EXTERNAL",
traceId: traceId,
name: spanName,
startTime: "2021-01-01T00:00:00.000Z",
endTime: "2021-01-01T00:00:00.000Z",
input: { input: "value" },
metadata: { meta: "value" },
version: "2.0.0",
});
const createSpan = await makeZodVerifiedAPICall(
PostSpansV1Response,
"POST",
"/api/public/spans",
{
id: spanId,
traceIdType: "EXTERNAL",
traceId: traceId,
name: spanName,
startTime: "2021-01-01T00:00:00.000Z",
endTime: "2021-01-01T00:00:00.000Z",
input: { input: "value" },
metadata: { meta: "value" },
version: "2.0.0",
},
);
expect(createSpan.status).toBe(200);
@@ -221,15 +266,20 @@ describe("/api/public/spans API Endpoint", () => {
const generationName = uuidv4();
const spanId = uuidv4();
const createSpan = await makeAPICall("POST", "/api/public/spans", {
id: spanId,
name: generationName,
startTime: "2021-01-01T00:00:00.000Z",
endTime: "2021-01-01T00:00:00.000Z",
input: { key: "value" },
metadata: { key: "value" },
version: "2.0.0",
});
const createSpan = await makeZodVerifiedAPICall(
PostSpansV1Response,
"POST",
"/api/public/spans",
{
id: spanId,
name: generationName,
startTime: "2021-01-01T00:00:00.000Z",
endTime: "2021-01-01T00:00:00.000Z",
input: { key: "value" },
metadata: { key: "value" },
version: "2.0.0",
},
);
const dbSpan = await prisma.observation.findFirstOrThrow({
where: {
@@ -262,22 +312,32 @@ describe("/api/public/spans API Endpoint", () => {
const spanName = uuidv4();
const spanId = uuidv4();
const createSpan = await makeAPICall("POST", "/api/public/spans", {
id: spanId,
name: spanName,
startTime: "2021-01-01T00:00:00.000Z",
endTime: "2021-01-01T00:00:00.000Z",
input: { input: "value" },
metadata: { meta: "value" },
version: "2.0.0",
});
const createSpan = await makeZodVerifiedAPICall(
PostSpansV1Response,
"POST",
"/api/public/spans",
{
id: spanId,
name: spanName,
startTime: "2021-01-01T00:00:00.000Z",
endTime: "2021-01-01T00:00:00.000Z",
input: { input: "value" },
metadata: { meta: "value" },
version: "2.0.0",
},
);
expect(createSpan.status).toBe(200);
const updatedSpan = await makeAPICall("PATCH", "/api/public/spans", {
spanId: spanId,
output: { key: "this is a great gpt output" },
});
const updatedSpan = await makeZodVerifiedAPICall(
PatchSpansV1Response,
"PATCH",
"/api/public/spans",
{
spanId: spanId,
output: { key: "this is a great gpt output" },
},
);
expect(updatedSpan.status).toBe(200);
const dbSpan = await prisma.observation.findUnique({
+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");
}
+6 -10
View File
@@ -3,7 +3,7 @@ import { hash } from "bcryptjs";
import { env } from "@/src/env.mjs";
import { getDisplaySecretKey, hashSecretKey } from "@langfuse/shared";
import { prisma } from "@langfuse/shared/src/db";
import { type z, ZodObject } from "zod";
import { type z } from "zod";
export const pruneDatabase = async () => {
if (!env.DATABASE_URL.includes("localhost:5432")) {
@@ -14,6 +14,7 @@ export const pruneDatabase = async () => {
await prisma.scoreConfig.deleteMany();
await prisma.observation.deleteMany();
await prisma.trace.deleteMany();
await prisma.traceSession.deleteMany();
await prisma.datasetItem.deleteMany();
await prisma.dataset.deleteMany();
await prisma.datasetRuns.deleteMany();
@@ -87,16 +88,11 @@ export async function makeZodVerifiedAPICall<T extends z.ZodTypeAny>(
`API call did not return 200, returned status ${status}, body ${JSON.stringify(resBody)}`,
);
}
try {
if (responseZodSchema instanceof ZodObject) {
responseZodSchema.strict().parse(resBody);
} else {
responseZodSchema.parse(resBody);
}
} catch (e) {
console.error(e);
const typeCheckResult = responseZodSchema.safeParse(resBody);
if (!typeCheckResult.success) {
console.error(typeCheckResult.error);
throw new Error(
`API call (${method} ${url}) did not return valid response, returned status ${status}, body ${JSON.stringify(resBody)}, error ${e}`,
`API call (${method} ${url}) did not return valid response, returned status ${status}, body ${JSON.stringify(resBody)}, error ${typeCheckResult.error}`,
);
}
return { body: resBody, status };
+109 -20
View File
@@ -126,30 +126,119 @@ describe("Token Cost Calculation", () => {
it("should correctly calculate token costs when only some user provided costs are given", async () => {
const model = {
inputPrice: new Decimal(0.01),
outputPrice: new Decimal(0.02),
totalPrice: new Decimal(0.03),
inputPrice: new Decimal(1),
outputPrice: new Decimal(1),
};
const tokenCounts = {
input: 100,
output: 200,
total: undefined,
};
const data = [
// missing total
{
userProvidedCosts: {
input: 1,
output: 2,
total: undefined,
},
expectedCost: {
input: 1,
output: 2,
total: 3,
},
},
// only total
{
userProvidedCosts: {
input: undefined,
output: undefined,
total: 2,
},
expectedCost: {
input: undefined,
output: undefined,
total: 2,
},
},
// missing input
{
userProvidedCosts: {
input: undefined,
output: 2,
total: 2,
},
expectedCost: {
input: undefined,
output: 2,
total: 2,
},
},
// only input
{
userProvidedCosts: {
input: 1,
output: undefined,
total: undefined,
},
expectedCost: {
input: 1,
output: undefined,
total: 1,
},
},
const userProvidedCosts = {
outputCost: new Decimal(3.0),
};
// missing output
{
userProvidedCosts: {
input: 1,
output: undefined,
total: 1,
},
expectedCost: {
input: 1,
output: undefined,
total: 1,
},
},
const costs = ObservationProcessor.calculateTokenCosts(
model as any,
userProvidedCosts,
tokenCounts,
);
// only output
{
userProvidedCosts: {
input: undefined,
output: 2,
total: undefined,
},
expectedCost: {
input: undefined,
output: 2,
total: 2,
},
},
];
expect(costs.inputCost).toBe(undefined); // No user provided cost
expect(costs.outputCost?.toNumber()).toBe(3.0); // Overridden by user provided cost
expect(costs.totalCost).toBe(undefined); // No user provided cost
for (const { userProvidedCosts, expectedCost } of data) {
const tokenCounts = {
input: 0,
output: 0,
total: 0,
};
const userProvidedCostsDecimal = {
inputCost:
userProvidedCosts.input && new Decimal(userProvidedCosts.input),
outputCost:
userProvidedCosts.output && new Decimal(userProvidedCosts.output),
totalCost:
userProvidedCosts.total && new Decimal(userProvidedCosts.total),
};
const costs = ObservationProcessor.calculateTokenCosts(
model as any,
userProvidedCostsDecimal as any,
tokenCounts,
);
expect(costs.inputCost?.toNumber()).toBe(expectedCost.input);
expect(costs.outputCost?.toNumber()).toBe(expectedCost.output);
expect(costs.totalCost?.toNumber()).toBe(expectedCost.total);
}
});
it("should return empty costs if no model is provided", async () => {
@@ -911,7 +1000,7 @@ describe("Token Cost Calculation", () => {
expect(generation?.calculatedOutputCost?.toNumber()).toBe(
generationUsage2.usage.outputCost,
);
expect(generation?.calculatedTotalCost?.toNumber()).toBe(undefined);
expect(generation?.calculatedTotalCost?.toNumber()).toBe(1);
expect(generation?.promptTokens).toBe(generationUsage1.usage.input);
expect(generation?.completionTokens).toBe(generationUsage1.usage.output);
expect(generation?.totalTokens).toBe(generationUsage1.usage.total);
+223 -135
View File
@@ -1,58 +1,70 @@
/** @jest-environment node */
import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
import {
makeZodVerifiedAPICall,
pruneDatabase,
} from "@/src/__tests__/test-utils";
import { PostGenerationsV1Response } from "@/src/features/public-api/types/generations";
import { PostScoresResponse } from "@/src/features/public-api/types/scores";
import {
GetTraceV1Response,
GetTracesV1Response,
PostTracesV1Response,
} from "@/src/features/public-api/types/traces";
import { prisma } from "@langfuse/shared/src/db";
import { v4 as uuidv4 } from "uuid";
interface GetTracesAPIResponse {
data: Array<{
id: string;
[key: string]: unknown;
}>;
}
describe("/api/public/traces API Endpoint", () => {
beforeEach(async () => await pruneDatabase());
afterEach(async () => await pruneDatabase());
it("should create", async () => {
it("should create and get a trace via /traces", async () => {
await pruneDatabase();
await makeAPICall("POST", "/api/public/traces", {
name: "trace-name",
userId: "user-1",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
});
const dbTrace = await prisma.trace.findMany({
where: {
const traceCreate = await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
name: "trace-name",
userId: "user-1",
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
},
});
);
expect(dbTrace.length).toBeGreaterThan(0);
expect(dbTrace[0]?.name).toBe("trace-name");
expect(dbTrace[0]?.release).toBe("1.0.0");
expect(dbTrace[0]?.externalId).toBeNull();
expect(dbTrace[0]?.version).toBe("2.0.0");
expect(dbTrace[0]?.projectId).toBe("7a88fb47-b4e2-43b8-a06c-a5ce950dc53a");
const trace = await makeZodVerifiedAPICall(
GetTraceV1Response,
"GET",
"/api/public/traces/" + traceCreate.body.id,
);
expect(trace.body.name).toBe("trace-name");
expect(trace.body.release).toBe("1.0.0");
expect(trace.body.externalId).toBeNull();
expect(trace.body.version).toBe("2.0.0");
expect(trace.body.projectId).toBe("7a88fb47-b4e2-43b8-a06c-a5ce950dc53a");
});
it("should upsert second trace", async () => {
await pruneDatabase();
await makeAPICall("POST", "/api/public/traces", {
id: "trace-id",
name: "trace-name",
userId: "user-1",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
public: true,
});
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
id: "trace-id",
name: "trace-name",
userId: "user-1",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
public: true,
},
);
const dbTrace1 = await prisma.trace.findFirst({
where: {
@@ -70,14 +82,19 @@ describe("/api/public/traces API Endpoint", () => {
userId: "user-1",
});
await makeAPICall("POST", "/api/public/traces", {
id: "trace-id",
metadata: { key: "value" },
timestamp: "2021-01-01T00:00:00.000Z",
release: "1.0.0",
version: "5.0.0",
public: false,
});
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
id: "trace-id",
metadata: { key: "value" },
timestamp: "2021-01-01T00:00:00.000Z",
release: "1.0.0",
version: "5.0.0",
public: false,
},
);
const dbTrace2 = await prisma.trace.findFirst({
where: {
@@ -100,23 +117,39 @@ describe("/api/public/traces API Endpoint", () => {
it("should use tags correctly on POST and GET", async () => {
await pruneDatabase();
await makeAPICall("POST", "/api/public/traces", {
id: "trace-1",
tags: ["tag-1", "tag-2", "tag-3"],
});
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
id: "trace-1",
tags: ["tag-1", "tag-2", "tag-3"],
},
);
await makeAPICall("POST", "/api/public/traces", {
id: "trace-2",
tags: ["tag-1"],
});
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
id: "trace-2",
tags: ["tag-1"],
},
);
await makeAPICall("POST", "/api/public/traces", {
id: "trace-3",
tags: ["tag-2", "tag-3"],
});
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
id: "trace-3",
tags: ["tag-2", "tag-3"],
},
);
// multiple tags
const traces = await makeAPICall<GetTracesAPIResponse>(
const traces = await makeZodVerifiedAPICall(
GetTracesV1Response,
"GET",
"/api/public/traces?tags=tag-2&tags=tag-3",
);
@@ -125,7 +158,8 @@ describe("/api/public/traces API Endpoint", () => {
expect(traceIds).toEqual(["trace-3", "trace-1"]);
// single tag
const traces2 = await makeAPICall<GetTracesAPIResponse>(
const traces2 = await makeZodVerifiedAPICall(
GetTracesV1Response,
"GET",
"/api/public/traces?tags=tag-1",
);
@@ -134,7 +168,8 @@ describe("/api/public/traces API Endpoint", () => {
expect(traceIds2).toEqual(["trace-2", "trace-1"]);
// wrong tag
const traces3 = await makeAPICall<GetTracesAPIResponse>(
const traces3 = await makeZodVerifiedAPICall(
GetTracesV1Response,
"GET",
"/api/public/traces?tags=tag-10",
);
@@ -143,7 +178,8 @@ describe("/api/public/traces API Endpoint", () => {
expect(traceIds3).toEqual([]);
// no tag
const traces4 = await makeAPICall<GetTracesAPIResponse>(
const traces4 = await makeZodVerifiedAPICall(
GetTracesV1Response,
"GET",
"/api/public/traces?tags=",
);
@@ -157,60 +193,94 @@ describe("/api/public/traces API Endpoint", () => {
// Create a trace with some observations that have costs and latencies
const traceId = uuidv4();
await makeAPICall("POST", "/api/public/traces", {
id: traceId,
name: "trace-with-costs",
userId: "user-costs",
projectId: "project-costs",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
});
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
id: traceId,
name: "trace-with-costs",
userId: "user-costs",
projectId: "project-costs",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
},
);
console.log(traceId);
// Simulate observations with costs and latencies
await makeAPICall("POST", "/api/public/generations", {
traceId: traceId,
usage: { totalCost: 10.5 },
startTime: "2021-01-01T00:00:00.000Z",
endTime: "2021-01-01T00:10:00.000Z",
});
await makeAPICall("POST", "/api/public/generations", {
traceId: traceId,
usage: { totalCost: 5.25 },
startTime: "2021-01-01T00:10:00.000Z",
endTime: "2021-01-01T00:20:00.000Z",
});
const generationId = uuidv4();
await makeZodVerifiedAPICall(
PostGenerationsV1Response,
"POST",
"/api/public/generations",
{
traceId: traceId,
id: generationId,
name: "Generation1",
usage: { totalCost: 10.5 },
startTime: "2021-01-01T00:00:00.000Z",
endTime: "2021-01-01T00:10:00.000Z",
},
);
await makeZodVerifiedAPICall(
PostGenerationsV1Response,
"POST",
"/api/public/generations",
{
traceId: traceId,
usage: { totalCost: 5.25 },
startTime: "2021-01-01T00:10:00.000Z",
endTime: "2021-01-01T00:20:00.000Z",
},
);
// Simulate scores on the trace
const scoreId1 = uuidv4();
await makeAPICall("POST", "/api/public/scores", {
id: scoreId1,
name: "score-1",
value: 75.0,
traceId: traceId,
comment: "First score",
});
await makeZodVerifiedAPICall(
PostScoresResponse,
"POST",
"/api/public/scores",
{
id: scoreId1,
name: "score-1",
value: 75.0,
traceId: traceId,
comment: "First score",
},
);
const scoreId2 = uuidv4();
await makeAPICall("POST", "/api/public/scores", {
id: scoreId2,
name: "score-2",
value: 85.5,
traceId: traceId,
comment: "Second score",
});
await makeZodVerifiedAPICall(
PostScoresResponse,
"POST",
"/api/public/scores",
{
id: scoreId2,
name: "score-2",
value: 85.5,
traceId: traceId,
comment: "Second score",
},
);
const scoreId3 = uuidv4();
await makeAPICall("POST", "/api/public/scores", {
id: scoreId3,
name: "score-3",
value: 95.0,
traceId: traceId,
comment: "Third score",
});
await makeZodVerifiedAPICall(
PostScoresResponse,
"POST",
"/api/public/scores",
{
id: scoreId3,
name: "score-3",
value: 95.0,
traceId: traceId,
comment: "Third score",
},
);
// GET traces
// Retrieve the trace with totalCost and latency
const traces = await makeAPICall<GetTracesAPIResponse>(
const traces = await makeZodVerifiedAPICall(
GetTracesV1Response,
"GET",
`/api/public/traces`,
);
@@ -226,17 +296,23 @@ describe("/api/public/traces API Endpoint", () => {
// GET trace
// Retrieve the trace with total
const trace = await makeAPICall<{
id: string;
totalCost: number;
htmlPath: string;
}>("GET", `/api/public/traces/${traceId}`);
const trace = await makeZodVerifiedAPICall(
GetTraceV1Response,
"GET",
`/api/public/traces/${traceId}`,
);
console.log(trace.body);
expect(trace.body.totalCost).toBeCloseTo(15.75);
expect(trace.body.id).toBe(traceId);
expect(trace.body.id).toBe(traceId);
expect(trace.body.htmlPath).toContain(`/traces/${traceId}`);
expect(trace.body.htmlPath).toContain(`/project/`); // do not know the projectId
expect(trace.body.scores).toHaveLength(3);
expect(trace.body.scores[0].id).toBe(scoreId3);
expect(trace.body.scores[0].name).toBe("score-3");
expect(trace.body.observations).toHaveLength(2);
expect(trace.body.observations[0].id).toBe(generationId);
expect(trace.body.observations[0].name).toBe("Generation1");
});
it("should filter traces by session ID", async () => {
@@ -244,30 +320,41 @@ describe("/api/public/traces API Endpoint", () => {
const anotherSessionId = "another-session-id";
// Create traces with different session IDs
await makeAPICall("POST", "/api/public/traces", {
id: "trace-1",
name: "test-trace-1",
sessionId,
userId: "user-1",
projectId: "project-1",
metadata: { key: "value" },
release: "1.0.0",
version: "1.0.0",
});
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
id: "trace-1",
name: "test-trace-1",
sessionId,
userId: "user-1",
projectId: "project-1",
metadata: { key: "value" },
release: "1.0.0",
version: "1.0.0",
},
);
await makeAPICall("POST", "/api/public/traces", {
id: "trace-2",
name: "test-trace-2",
sessionId: anotherSessionId,
userId: "user-2",
projectId: "project-1",
metadata: { key: "value" },
release: "1.0.0",
version: "1.0.0",
});
await makeZodVerifiedAPICall(
PostTracesV1Response,
"POST",
"/api/public/traces",
{
id: "trace-2",
name: "test-trace-2",
sessionId: anotherSessionId,
userId: "user-2",
projectId: "project-1",
metadata: { key: "value" },
release: "1.0.0",
version: "1.0.0",
},
);
// Filter by session ID
const tracesBySessionId = await makeAPICall<GetTracesAPIResponse>(
const tracesBySessionId = await makeZodVerifiedAPICall(
GetTracesV1Response,
"GET",
`/api/public/traces?sessionId=${sessionId}`,
);
@@ -277,7 +364,8 @@ describe("/api/public/traces API Endpoint", () => {
expect(tracesBySessionId.body.data[0].id).toBe("trace-1");
// Filter by another session ID
const tracesByAnotherSessionId = await makeAPICall<GetTracesAPIResponse>(
const tracesByAnotherSessionId = await makeZodVerifiedAPICall(
GetTracesV1Response,
"GET",
`/api/public/traces?sessionId=${anotherSessionId}`,
);
@@ -287,11 +375,11 @@ describe("/api/public/traces API Endpoint", () => {
expect(tracesByAnotherSessionId.body.data[0].id).toBe("trace-2");
// Filter by non-existent session ID
const tracesByNonExistentSessionId =
await makeAPICall<GetTracesAPIResponse>(
"GET",
`/api/public/traces?sessionId=non-existent-session-id`,
);
const tracesByNonExistentSessionId = await makeZodVerifiedAPICall(
GetTracesV1Response,
"GET",
`/api/public/traces?sessionId=non-existent-session-id`,
);
expect(tracesByNonExistentSessionId.status).toBe(200);
expect(tracesByNonExistentSessionId.body.data).toHaveLength(0);
@@ -15,6 +15,7 @@ import {
import React from "react";
import { api } from "@/src/utils/api";
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
import { useIsEeEnabled } from "@/src/ee/utils/useIsEeEnabled";
export type BatchExportTableButtonProps = {
@@ -31,6 +32,10 @@ export const BatchExportTableButton: React.FC<BatchExportTableButtonProps> = (
const [isExporting, setIsExporting] = React.useState(false);
const createExport = api.batchExport.create.useMutation();
const isEeEnabled = useIsEeEnabled();
const hasAccess = useHasAccess({
projectId: props.projectId,
scope: "batchExport:create",
});
const handleExport = async (format: BatchExportFileFormat) => {
setIsExporting(true);
@@ -51,7 +56,7 @@ export const BatchExportTableButton: React.FC<BatchExportTableButtonProps> = (
});
};
if (!isEeEnabled) return null;
if (!isEeEnabled || !hasAccess) return null;
return (
<DropdownMenu>
+117 -60
View File
@@ -11,29 +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";
export const DEFAULT_DATE_RANGE_SELECTION = "Date range" as const;
export type AvailableDateRangeSelections =
| typeof DEFAULT_DATE_RANGE_SELECTION
| DateTimeAggregationOption;
import { TimePicker } from "@/src/components/ui/time-picker";
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,
@@ -41,12 +30,14 @@ export function DatePicker({
clearable = false,
className,
disabled,
includeTimePicker,
}: {
date?: Date | undefined;
onChange: (date: Date | undefined) => void;
clearable?: boolean;
className?: string;
disabled?: boolean;
includeTimePicker?: boolean;
}) {
return (
<div className="flex flex-row gap-2 align-middle">
@@ -62,7 +53,11 @@ export function DatePicker({
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{date ? format(date, "PPP") : <span>Pick a date</span>}
{date ? (
format(date, includeTimePicker ? "PPP pp" : "PPP")
) : (
<span>Pick a date</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
@@ -72,6 +67,9 @@ export function DatePicker({
onSelect={(d) => onChange(d)}
initialFocus
/>
{includeTimePicker && (
<TimePicker date={date} setDate={(d) => onChange(d)} />
)}
</PopoverContent>
</Popover>
{date && clearable && (
@@ -91,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;
};
@@ -112,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,
);
}
};
@@ -136,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)" });
@@ -183,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}
/>
);
};
+1 -1
View File
@@ -59,7 +59,7 @@ export const GroupedScoreBadges = ({
if (variant === "headings")
return (
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 text-nowrap">
{Object.entries(groupedScores)
.sort(([a], [b]) => (a < b ? -1 : 1))
.map(([name, scores]) => (
+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 ? (
@@ -0,0 +1,25 @@
import { z } from "zod";
export const ChatMlMessageSchema = z
.object({
role: z.string().optional(),
name: z.string().optional(),
content: z
.union([z.record(z.any()), z.string(), z.array(z.any())])
.nullish(),
additional_kwargs: z.record(z.any()).optional(),
})
.passthrough()
.refine((value) => value.content !== null || value.role !== undefined)
.transform(({ additional_kwargs, ...other }) => ({
...other,
...additional_kwargs,
}))
.transform(({ role, name, content, ...other }) => ({
role,
name,
content,
json: Object.keys(other).length === 0 ? undefined : other,
}));
export const ChatMlArraySchema = z.array(ChatMlMessageSchema).min(1);
@@ -0,0 +1,22 @@
import { z } from "zod";
const MARKDOWN_PATTERNS = [
"(\\*\\*?|__?)(.*?)\\1", // Matches bold (** or __) and italic (* or _) with proper escaping
"`{3}[\\s\\S]*?`{3}", // Matches fenced code blocks with triple backticks
"`[\\s\\S]*?`", // Matches inline code with single backticks
"(^|\\s)[-+*]\\s", // Matches unordered lists that start with -, +, or *
"^\\s*#{1,6}\\s", // Matches headers that start with # to ######
"^>\\s+", // Matches blockquotes starting with >
"^\\d+\\.\\s", // Matches ordered lists starting with 1. or 2. etc
"!\\[.*?\\]\\(.*?\\)", // Matches images ![Alt text](URL)
"\\[.*?\\]\\(.*?\\)", // Matches links [Link text](URL)
].join("|");
const MARKDOWN_REGEX = new RegExp(MARKDOWN_PATTERNS, "gm");
export function containsAnyMarkdown(...texts: string[]): boolean {
MARKDOWN_REGEX.lastIndex = 0;
return texts.some((text) => MARKDOWN_REGEX.test(text));
}
export const MarkdownSchema = z.string().refine(containsAnyMarkdown);
+8
View File
@@ -15,6 +15,7 @@ import Link from "next/link";
import { useEffect, useState } from "react";
import { AnnotateDrawer } from "@/src/features/manual-scoring/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,9 +134,12 @@ export const SessionPage: React.FC<{
projectId={projectId}
traceId={trace.id}
scores={trace.scores}
emptySelectedConfigIds={emptySelectedConfigIds}
setEmptySelectedConfigIds={setEmptySelectedConfigIds}
variant="badge"
type="session"
source="SessionDetail"
key={"annotation-drawer" + trace.id}
/>
</div>
</Card>
@@ -15,6 +15,7 @@ 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>[];
@@ -98,13 +99,20 @@ export function DataTableColumnVisibilityFilter<TData, TValue>({
column.enableHiding && (
<DropdownMenuCheckboxItem
key={index}
className="capitalize"
checked={columnVisibility[column.accessorKey]}
onCheckedChange={() =>
toggleColumn(column.accessorKey.toString())
}
onCheckedChange={() => toggleColumn(column.accessorKey)}
>
{column.header?.toString() ?? column.accessorKey.toString()}
<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>
),
)}
@@ -38,7 +38,7 @@ export function TraceTableMultiSelectAction({
const mutDeleteTraces = api.traces.deleteMany.useMutation({
onSuccess: () => {
onDeleteSuccess();
void utils.traces.invalidate();
void utils.traces.all.invalidate();
},
});
@@ -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
+134 -55
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>[];
@@ -106,8 +106,34 @@ 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,
columnVisibility,
]);
return (
<>
<div
@@ -121,6 +147,7 @@ export function DataTable<TData extends object, TValue>({
"w-full overflow-auto",
isBorderless ? "" : "rounded-md border",
)}
style={{ ...columnSizeVars }}
>
<Table>
<TableHeader>
@@ -134,9 +161,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 +211,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 +245,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 +284,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>
);
}
@@ -21,7 +21,7 @@ import {
withDefault,
} from "use-query-params";
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
import { formatIntervalSeconds, utcDateOffsetByDays } 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 {
@@ -43,7 +43,8 @@ import { type ScoreSimplified } from "@/src/server/api/routers/generations/getAl
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 { useLookBackDays } from "@/src/hooks/useLookBackDays";
import { useTableDateRange } from "@/src/hooks/useTableDateRange";
import { useDebounce } from "@/src/hooks/useDebounce";
export type GenerationsTableRow = {
id: string;
@@ -106,15 +107,11 @@ export default function GenerationsTable({
"s",
);
const { selectedOption, dateRange, setDateRangeAndOption } =
useTableDateRange();
const [inputFilterState, setInputFilterState] = useQueryFilterState(
[
{
column: "Start Time",
type: "datetime",
operator: ">",
value: utcDateOffsetByDays(-useLookBackDays(projectId)),
},
],
[],
"generations",
);
@@ -145,7 +142,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,
]);
@@ -236,6 +245,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");
@@ -253,12 +263,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" ? (
@@ -274,6 +286,7 @@ export default function GenerationsTable({
accessorKey: "traceName",
id: "traceName",
header: "Trace Name",
size: 150,
enableHiding: true,
enableSorting: true,
},
@@ -281,6 +294,7 @@ export default function GenerationsTable({
accessorKey: "startTime",
id: "startTime",
header: "Start Time",
size: 150,
enableHiding: true,
enableSorting: true,
cell: ({ row }) => {
@@ -292,6 +306,7 @@ export default function GenerationsTable({
accessorKey: "endTime",
id: "endTime",
header: "End Time",
size: 150,
enableHiding: true,
enableSorting: true,
},
@@ -299,6 +314,7 @@ export default function GenerationsTable({
accessorKey: "timeToFirstToken",
id: "timeToFirstToken",
header: "Time to First Token",
size: 150,
enableHiding: true,
enableSorting: true,
cell: ({ row }) => {
@@ -316,6 +332,12 @@ export default function GenerationsTable({
accessorKey: "scores",
id: "scores",
header: "Scores",
size: 200,
headerTooltip: {
description:
"Scores are used to evaluate the quality of the trace. They can be automated, based on user feedback, or manually annotated. See docs to learn more.",
href: "https://langfuse.com/docs/scores",
},
cell: ({ row }) => {
const values: ScoreSimplified[] | undefined = row.getValue("scores");
return (
@@ -328,6 +350,7 @@ export default function GenerationsTable({
accessorKey: "latency",
id: "latency",
header: "Latency",
size: 100,
cell: ({ row }) => {
const latency: number | undefined = row.getValue("latency");
return latency !== undefined ? (
@@ -341,6 +364,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: {
@@ -365,6 +389,7 @@ export default function GenerationsTable({
accessorKey: "inputCost",
id: "inputCost",
header: "Input Cost",
size: 120,
cell: ({ row }) => {
const value: Decimal | undefined = row.getValue("inputCost");
@@ -380,6 +405,7 @@ export default function GenerationsTable({
accessorKey: "outputCost",
id: "outputCost",
header: "Output Cost",
size: 120,
cell: ({ row }) => {
const value: Decimal | undefined = row.getValue("outputCost");
@@ -395,6 +421,7 @@ export default function GenerationsTable({
accessorKey: "totalCost",
header: "Total Cost",
id: "totalCost",
size: 120,
cell: ({ row }) => {
const value: Decimal | undefined = row.getValue("totalCost");
@@ -409,6 +436,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");
@@ -430,6 +463,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,
},
@@ -437,6 +476,7 @@ export default function GenerationsTable({
accessorKey: "model",
id: "model",
header: "Model",
size: 150,
enableHiding: true,
enableSorting: true,
},
@@ -444,6 +484,7 @@ export default function GenerationsTable({
accessorKey: "inputTokens",
id: "inputTokens",
header: "Input Tokens",
size: 100,
enableHiding: true,
defaultHidden: true,
enableSorting: true,
@@ -460,6 +501,7 @@ export default function GenerationsTable({
accessorKey: "outputTokens",
id: "outputTokens",
header: "Output Tokens",
size: 100,
enableHiding: true,
defaultHidden: true,
enableSorting: true,
@@ -476,6 +518,7 @@ export default function GenerationsTable({
accessorKey: "totalTokens",
id: "totalTokens",
header: "Total Tokens",
size: 100,
enableHiding: true,
defaultHidden: true,
enableSorting: true,
@@ -492,6 +535,7 @@ export default function GenerationsTable({
accessorKey: "usage",
header: "Usage",
id: "usage",
size: 150,
cell: ({ row }) => {
const value: {
promptTokens: number;
@@ -514,6 +558,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");
@@ -533,6 +578,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");
@@ -551,6 +597,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");
@@ -570,6 +621,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,
},
@@ -577,6 +633,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 }) => {
@@ -589,7 +650,6 @@ export default function GenerationsTable({
<TableLink
path={`/project/${projectId}/prompts/${encodeURIComponent(promptName)}?version=${promptVersion}`}
value={value}
truncateAt={40}
/>
)
);
@@ -639,7 +699,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,
@@ -649,6 +709,8 @@ export default function GenerationsTable({
setColumnVisibility={setColumnVisibilityState}
rowHeight={rowHeight}
setRowHeight={setRowHeight}
selectedOption={selectedOption}
setDateRangeAndOption={setDateRangeAndOption}
actionButtons={
<DropdownMenu>
<DropdownMenuTrigger asChild>
+61 -36
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;
@@ -43,7 +46,7 @@ const modelConfigDescriptions = {
outputPrice: "Price per 1000 units of output",
totalPrice:
"Price per 1000 units, for models that don't have input/output specific prices",
unit: "Unit of measurement for generative model, can be TOKENS, CHARACTERS, SECONDS, MILLISECONDS, or IMAGES.",
unit: "Unit of measurement for generative model, can be TOKENS, CHARACTERS, SECONDS, MILLISECONDS, REQUESTS or IMAGES.",
tokenizerId:
"Tokenizer used for this model to calculate token counts if none are ingested. Pick from list of supported tokenizers.",
config:
@@ -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}
/>
</>
);
}
@@ -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;
+48 -30
View File
@@ -9,13 +9,13 @@ import useColumnVisibility from "@/src/features/column-visibility/hooks/useColum
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
import { isNumericDataType } from "@/src/features/manual-scoring/lib/helpers";
import { useOrderByState } from "@/src/features/orderBy/hooks/useOrderByState";
import { useLookBackDays } from "@/src/hooks/useLookBackDays";
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 { utcDateOffsetByDays } 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: utcDateOffsetByDays(-useLookBackDays(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 -15
View File
@@ -14,14 +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, utcDateOffsetByDays } 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 { useLookBackDays } from "@/src/hooks/useLookBackDays";
import { BatchExportTableButton } from "@/src/components/BatchExportTableButton";
import { useTableDateRange } from "@/src/hooks/useTableDateRange";
import { useDebounce } from "@/src/hooks/useDebounce";
export type SessionTableRow = {
id: string;
@@ -50,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: utcDateOffsetByDays(-useLookBackDays(projectId)),
},
],
[],
"sessions",
);
@@ -74,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),
@@ -142,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");
@@ -162,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;
},
@@ -178,6 +186,7 @@ export default function SessionsTable({
accessorKey: "createdAt",
id: "createdAt",
header: "Created At",
size: 150,
enableHiding: true,
enableSorting: true,
},
@@ -185,6 +194,7 @@ export default function SessionsTable({
accessorKey: "sessionDuration",
id: "sessionDuration",
header: "Duration",
size: 130,
enableHiding: true,
cell: ({ row }) => {
const value = row.getValue("sessionDuration");
@@ -199,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");
@@ -209,7 +220,6 @@ export default function SessionsTable({
key={user}
path={`/project/${projectId}/users/${encodeURIComponent(user)}`}
value={user}
truncateAt={40}
/>
))}
</div>
@@ -219,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,
},
@@ -227,6 +241,7 @@ export default function SessionsTable({
accessorKey: "inputCost",
id: "inputCost",
header: "Input Cost",
size: 110,
enableHiding: true,
defaultHidden: true,
enableSorting: true,
@@ -241,6 +256,7 @@ export default function SessionsTable({
accessorKey: "outputCost",
id: "outputCost",
header: "Output Cost",
size: 110,
enableHiding: true,
enableSorting: true,
defaultHidden: true,
@@ -256,6 +272,7 @@ export default function SessionsTable({
accessorKey: "totalCost",
id: "totalCost",
header: "Total Cost",
size: 110,
enableHiding: true,
enableSorting: true,
cell: ({ row }) => {
@@ -270,6 +287,7 @@ export default function SessionsTable({
accessorKey: "inputTokens",
id: "inputTokens",
header: "Input Tokens",
size: 110,
enableHiding: true,
defaultHidden: true,
enableSorting: true,
@@ -285,6 +303,7 @@ export default function SessionsTable({
accessorKey: "outputTokens",
id: "outputTokens",
header: "Output Tokens",
size: 110,
enableHiding: true,
defaultHidden: true,
enableSorting: true,
@@ -300,6 +319,7 @@ export default function SessionsTable({
accessorKey: "totalTokens",
id: "totalTokens",
header: "Total Tokens",
size: 110,
enableHiding: true,
defaultHidden: true,
enableSorting: true,
@@ -314,6 +334,7 @@ export default function SessionsTable({
accessorKey: "usage",
id: "usage",
header: "Usage",
size: 220,
enableHiding: true,
enableSorting: true,
cell: ({ row }) => {
@@ -346,7 +367,7 @@ export default function SessionsTable({
<DataTableToolbar
filterColumnDefinition={transformFilterOptions()}
filterState={userFilterState}
setFilterState={setUserFilterState}
setFilterState={useDebounce(setUserFilterState)}
columns={columns}
columnVisibility={columnVisibility}
setColumnVisibility={setColumnVisibility}
@@ -357,6 +378,8 @@ export default function SessionsTable({
key="batchExport"
/>,
]}
selectedOption={selectedOption}
setDateRangeAndOption={setDateRangeAndOption}
columnsWithCustomSelect={["userIds"]}
/>
<DataTable
+112 -62
View File
@@ -11,7 +11,7 @@ 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, utcDateOffsetByDays } from "@/src/utils/dates";
import { formatIntervalSeconds } from "@/src/utils/dates";
import { type RouterInput, type RouterOutput } from "@/src/utils/types";
import { type RowSelectionState } from "@tanstack/react-table";
import { useEffect, useState } from "react";
@@ -37,8 +37,9 @@ import {
} from "@langfuse/shared";
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
import { useLookBackDays } from "@/src/hooks/useLookBackDays";
import { type ValidatedScore } from "@/src/features/public-api/types/scores";
import { type APIScore } from "@/src/features/public-api/types/scores";
import { useTableDateRange } from "@/src/hooks/useTableDateRange";
import { useDebounce } from "@/src/hooks/useDebounce";
export type TracesTableRow = {
bookmarked: boolean;
@@ -56,12 +57,12 @@ export type TracesTableRow = {
input?: unknown;
output?: unknown;
metadata?: unknown;
scores: ValidatedScore[];
scores: APIScore[];
tags: string[];
usage: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
promptTokens: bigint;
completionTokens: bigint;
totalTokens: bigint;
};
inputCost?: Decimal;
outputCost?: Decimal;
@@ -89,15 +90,10 @@ export default function TracesTable({
withDefault(StringParam, null),
);
const { selectedOption, dateRange, setDateRangeAndOption } =
useTableDateRange();
const [userFilterState, setUserFilterState] = useQueryFilterState(
[
{
column: "Timestamp",
type: "datetime",
operator: ">",
value: utcDateOffsetByDays(-useLookBackDays(projectId)),
},
],
[],
"traces",
);
const [orderByState, setOrderByState] = useOrderByState({
@@ -105,6 +101,16 @@ export default function TracesTable({
order: "DESC",
});
const dateRangeFilter: FilterState = dateRange
? [
{
column: "Timestamp",
type: "datetime",
operator: ">=",
value: dateRange.from,
},
]
: [];
const userIdFilter: FilterState = userId
? [
{
@@ -116,11 +122,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,
@@ -145,12 +152,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: {
@@ -203,6 +211,7 @@ export default function TracesTable({
{
id: "select",
accessorKey: "select",
size: 30,
header: ({ table }) => (
<Checkbox
checked={
@@ -235,8 +244,10 @@ export default function TracesTable({
accessorKey: "bookmarked",
header: undefined,
id: "bookmarked",
size: 30,
cell: ({ row }) => {
const bookmarked = row.getValue("bookmarked");
const bookmarked: TracesTableRow["bookmarked"] =
row.getValue("bookmarked");
const traceId = row.getValue("id");
return typeof traceId === "string" &&
typeof bookmarked === "boolean" ? (
@@ -255,8 +266,9 @@ export default function TracesTable({
accessorKey: "id",
header: "ID",
id: "id",
size: 90,
cell: ({ row }) => {
const value = row.getValue("id");
const value: TracesTableRow["id"] = row.getValue("id");
return value && typeof value === "string" ? (
<TableLink
path={`/project/${projectId}/traces/${value}`}
@@ -270,6 +282,7 @@ export default function TracesTable({
accessorKey: "timestamp",
header: "Timestamp",
id: "timestamp",
size: 150,
enableHiding: true,
enableSorting: true,
},
@@ -277,20 +290,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 = row.getValue("userId");
const value: TracesTableRow["userId"] = row.getValue("userId");
return value && typeof value === "string" ? (
<TableLink
path={`/project/${projectId}/users/${encodeURIComponent(value)}`}
value={value}
truncateAt={40}
/>
) : undefined;
},
@@ -301,14 +319,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 = row.getValue("sessionId");
const value: TracesTableRow["sessionId"] = row.getValue("sessionId");
return value && typeof value === "string" ? (
<TableLink
path={`/project/${projectId}/sessions/${encodeURIComponent(value)}`}
value={value}
truncateAt={40}
/>
) : undefined;
},
@@ -319,9 +341,10 @@ export default function TracesTable({
accessorKey: "latency",
id: "latency",
header: "Latency",
size: 70,
// add seconds to the end of the latency
cell: ({ row }) => {
const value: number | undefined = row.getValue("latency");
const value: TracesTableRow["latency"] = row.getValue("latency");
return value !== undefined ? formatIntervalSeconds(value) : undefined;
},
enableHiding: true,
@@ -331,12 +354,9 @@ export default function TracesTable({
accessorKey: "inputTokens",
id: "inputTokens",
header: "Input Tokens",
size: 110,
cell: ({ row }) => {
const value: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
} = row.getValue("usage");
const value: TracesTableRow["usage"] = row.getValue("usage");
return <span>{numberFormatter(value.promptTokens, 0)}</span>;
},
enableHiding: true,
@@ -347,12 +367,9 @@ export default function TracesTable({
accessorKey: "outputTokens",
id: "outputTokens",
header: "Output Tokens",
size: 110,
cell: ({ row }) => {
const value: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
} = row.getValue("usage");
const value: TracesTableRow["usage"] = row.getValue("usage");
return <span>{numberFormatter(value.completionTokens, 0)}</span>;
},
enableHiding: true,
@@ -363,12 +380,9 @@ export default function TracesTable({
accessorKey: "totalTokens",
id: "totalTokens",
header: "Total Tokens",
size: 110,
cell: ({ row }) => {
const value: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
} = row.getValue("usage");
const value: TracesTableRow["usage"] = row.getValue("usage");
return <span>{numberFormatter(value.totalTokens, 0)}</span>;
},
enableHiding: true,
@@ -376,16 +390,12 @@ 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: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
} = row.getValue("usage");
const value: TracesTableRow["usage"] = row.getValue("usage");
return (
<TokenUsageBadge
promptTokens={value.promptTokens}
@@ -402,8 +412,9 @@ export default function TracesTable({
accessorKey: "inputCost",
id: "inputCost",
header: "Input Cost",
size: 100,
cell: ({ row }) => {
const cost: Decimal | undefined = row.getValue("inputCost");
const cost: TracesTableRow["inputCost"] = row.getValue("inputCost");
return (
<div>
{cost ? (
@@ -422,8 +433,9 @@ export default function TracesTable({
accessorKey: "outputCost",
id: "outputCost",
header: "Output Cost",
size: 100,
cell: ({ row }) => {
const cost: Decimal | undefined = row.getValue("outputCost");
const cost: TracesTableRow["outputCost"] = row.getValue("outputCost");
return (
<div>
{cost ? (
@@ -442,8 +454,9 @@ export default function TracesTable({
accessorKey: "totalCost",
id: "totalCost",
header: "Total Cost",
size: 100,
cell: ({ row }) => {
const cost: Decimal | undefined = row.getValue("totalCost");
const cost: TracesTableRow["totalCost"] = row.getValue("totalCost");
return (
<div>
{cost ? (
@@ -461,9 +474,15 @@ export default function TracesTable({
accessorKey: "scores",
id: "scores",
header: "Scores",
size: 500,
headerTooltip: {
description:
"Scores are used to evaluate the quality of the trace. They can be automated, based on user feedback, or manually annotated. See docs to learn more.",
href: "https://langfuse.com/docs/scores",
},
enableColumnFilter: !omittedFilter.find((f) => f === "scores"),
cell: ({ row }) => {
const values: ValidatedScore[] = row.getValue("scores");
const values: TracesTableRow["scores"] = row.getValue("scores");
return <GroupedScoreBadges scores={values} variant="headings" />;
},
enableHiding: true,
@@ -472,8 +491,9 @@ export default function TracesTable({
accessorKey: "input",
header: "Input",
id: "input",
size: 400,
cell: ({ row }) => {
const traceId: string = row.getValue("id");
const traceId: TracesTableRow["id"] = row.getValue("id");
return (
<TracesDynamicCell
traceId={traceId}
@@ -489,8 +509,9 @@ export default function TracesTable({
accessorKey: "output",
header: "Output",
id: "output",
size: 400,
cell: ({ row }) => {
const traceId: string = row.getValue("id");
const traceId: TracesTableRow["id"] = row.getValue("id");
return (
<TracesDynamicCell
traceId={traceId}
@@ -505,8 +526,13 @@ 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: string = row.getValue("id");
const traceId: TracesTableRow["id"] = row.getValue("id");
return (
<TracesDynamicCell
traceId={traceId}
@@ -522,8 +548,9 @@ export default function TracesTable({
accessorKey: "level",
id: "level",
header: "Level",
size: 75,
cell: ({ row }) => {
const value: ObservationLevel = row.getValue("level");
const value: TracesTableRow["level"] = row.getValue("level");
return (
<span
className={cn(
@@ -543,7 +570,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,
},
@@ -551,6 +582,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,
@@ -559,6 +595,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,
@@ -567,9 +608,14 @@ 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: string[] = row.getValue("tags");
const traceId: string = row.getValue("id");
const tags: TracesTableRow["tags"] = row.getValue("tags");
const traceId: TracesTableRow["id"] = row.getValue("id");
const filterOptionTags = traceFilterOptions.data?.tags ?? [];
const allTags = filterOptionTags.map((t) => t.value);
return (
@@ -579,6 +625,7 @@ export default function TracesTable({
projectId={projectId}
traceId={traceId}
tracesFilter={tracesAllQueryFilter}
className={cn(rowHeight !== "s" && "flex-wrap")}
/>
);
},
@@ -587,14 +634,15 @@ export default function TracesTable({
{
accessorKey: "action",
header: "Action",
size: 70,
cell: ({ row }) => {
const traceId = row.getValue("id");
const traceId: TracesTableRow["id"] = row.getValue("id");
return traceId && typeof traceId === "string" ? (
<DeleteButton
itemId={traceId}
projectId={projectId}
scope="traces:delete"
invalidateFunc={() => void utils.traces.invalidate()}
invalidateFunc={() => void utils.traces.all.invalidate()}
type="trace"
isTableAction={true}
/>
@@ -617,7 +665,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),
@@ -638,6 +686,8 @@ export default function TracesTable({
setColumnVisibility={setColumnVisibility}
rowHeight={rowHeight}
setRowHeight={setRowHeight}
selectedOption={selectedOption}
setDateRangeAndOption={setDateRangeAndOption}
/>
<DataTable
columns={columns}
+3 -3
View File
@@ -26,9 +26,9 @@ export const TokenUsageBadge = (
observation: Observation;
}
| {
promptTokens: number;
completionTokens: number;
totalTokens: number;
promptTokens: number | bigint;
completionTokens: number | bigint;
totalTokens: number | bigint;
}
) & {
inline?: boolean;
+138 -86
View File
@@ -1,16 +1,60 @@
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
import { z } from "zod";
import { deepParseJson } from "@langfuse/shared";
import { type Prisma, deepParseJson } from "@langfuse/shared";
import { cn } from "@/src/utils/tailwind";
import { useState } from "react";
import { useMemo, useState } from "react";
import { Button } from "@/src/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
import { Fragment } from "react";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { MarkdownView } from "@/src/components/ui/MarkdownViewer";
import { MarkdownSchema } from "@/src/components/schemas/MarkdownSchema";
import {
ChatMlArraySchema,
ChatMlMessageSchema,
} from "@/src/components/schemas/ChatMlSchema";
import { useMarkdownContext } from "@/src/features/theming/useMarkdownContext";
// MarkdownOrJsonView will render markdown if `isMarkdownEnabled` (global context) is true and the content is valid markdown
// otherwise, if content is valid markdown will render JSON with switch to enable markdown globally
function MarkdownOrJsonView({
content,
title,
className,
customCodeHeaderClassName,
}: {
content?: unknown;
title?: string;
className?: string;
customCodeHeaderClassName?: string;
}) {
const validatedMarkdown = useMemo(
() => MarkdownSchema.safeParse(content),
[content],
);
const { isMarkdownEnabled } = useMarkdownContext();
return validatedMarkdown.success && isMarkdownEnabled ? (
<MarkdownView
markdown={validatedMarkdown.data}
title={title}
className={className}
customCodeHeaderClassName={customCodeHeaderClassName}
/>
) : (
<JSONView
json={content}
canEnableMarkdown={validatedMarkdown.success}
title={title}
className={className}
/>
);
}
export const IOPreview: React.FC<{
input?: unknown;
output?: unknown;
input?: Prisma.JsonValue;
output?: Prisma.JsonValue;
isLoading?: boolean;
hideIfNull?: boolean;
}> = ({ isLoading = false, hideIfNull = false, ...props }) => {
@@ -59,43 +103,67 @@ export const IOPreview: React.FC<{
Array.isArray(output) ? output : [output],
);
// Pretty view available
const isPrettyViewAvailable = inChatMlArray.success;
const inMarkdown = MarkdownSchema.safeParse(input);
const outMarkdown = MarkdownSchema.safeParse(output);
const isPrettyViewAvailable =
inChatMlArray.success || inMarkdown.success || outMarkdown.success;
// default I/O
return (
<>
{isPrettyViewAvailable ? (
<Tabs
value={currentView}
onValueChange={(v) => {
setCurrentView(v as "pretty" | "json"),
capture("trace_detail:io_mode_switch", { view: v });
}}
>
<TabsList>
<TabsTrigger value="pretty">Pretty </TabsTrigger>
<TabsTrigger value="json">JSON</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex flex-row justify-between">
<Tabs
value={currentView}
onValueChange={(v) => {
setCurrentView(v as "pretty" | "json"),
capture("trace_detail:io_mode_switch", { view: v });
}}
>
<TabsList>
<TabsTrigger value="pretty">Pretty </TabsTrigger>
<TabsTrigger value="json">JSON</TabsTrigger>
</TabsList>
</Tabs>
</div>
) : null}
{isPrettyViewAvailable && currentView === "pretty" ? (
<OpenAiMessageView
messages={[
...inChatMlArray.data,
...(outChatMlArray.success
? outChatMlArray.data.map((m) => ({
...m,
role: m.role ?? "assistant",
}))
: [
ChatMlMessageSchema.parse({
role: "assistant",
content: outputClean ? JSON.stringify(outputClean) : null,
}),
]),
]}
/>
<>
{inChatMlArray.success ? (
<OpenAiMessageView
messages={[
...inChatMlArray.data,
...(outChatMlArray.success
? outChatMlArray.data.map((m) => ({
...m,
role: m.role ?? "assistant",
}))
: [
ChatMlMessageSchema.parse({
role: "assistant",
content: outputClean,
}),
]),
]}
shouldRenderMarkdown
/>
) : (
<>
{!(hideIfNull && !input) ? (
<MarkdownOrJsonView title="Input" content={input} />
) : null}
{!(hideIfNull && !output) ? (
<MarkdownOrJsonView
title="Output"
content={output}
className="bg-accent-light-green dark:border-accent-dark-green"
customCodeHeaderClassName="bg-muted-green dark:bg-secondary"
/>
) : null}
</>
)}
</>
) : null}
{currentView === "json" || !isPrettyViewAvailable ? (
<>
@@ -121,57 +189,23 @@ export const IOPreview: React.FC<{
);
};
const ChatMlMessageSchema = z
.object({
role: z
.enum(["system", "user", "assistant", "function", "tool"])
.optional(),
name: z.string().optional(),
content: z
.union([z.record(z.any()), z.string(), z.array(z.any())])
.nullish(),
additional_kwargs: z.record(z.any()).optional(),
})
.passthrough()
.refine((value) => value.content !== null || value.role !== undefined)
.transform(({ additional_kwargs, ...other }) => ({
...other,
...additional_kwargs,
}))
.transform(({ role, name, content, ...other }) => ({
role,
name,
content,
json: Object.keys(other).length === 0 ? undefined : other,
}));
export const ChatMlArraySchema = z.array(ChatMlMessageSchema).min(1);
export const OpenAiMessageView: React.FC<{
title?: string;
messages: z.infer<typeof ChatMlArraySchema>;
}> = ({ title, messages }) => {
title?: string;
shouldRenderMarkdown?: boolean;
}> = ({ title, messages, shouldRenderMarkdown = false }) => {
const COLLAPSE_THRESHOLD = 3;
const [isCollapsed, setCollapsed] = useState(
messages.length > COLLAPSE_THRESHOLD ? true : null,
);
const transformedMessages = messages;
// const transformedMessages = messages.map(
// ({ role, name, content, ...rest }) => ({
// role,
// name,
// content,
// json: rest,
// }),
// );
return (
<div className="rounded-md border">
{title && (
<div className="border-b px-3 py-1 text-xs font-medium">{title}</div>
)}
<div className="flex flex-col gap-2 p-3">
{transformedMessages
{messages
.filter(
(_, i) =>
// show all if not collapsed or null; show first and last n if collapsed
@@ -182,20 +216,38 @@ export const OpenAiMessageView: React.FC<{
.map((message, index) => (
<Fragment key={index}>
<div>
{!!message.content && (
<JSONView
title={message.name ?? message.role}
json={message.content}
className={cn(
"bg-muted",
message.role === "system" && "bg-primary-foreground",
message.role === "assistant" &&
"bg-accent-light-green dark:border-accent-dark-green",
message.role === "user" && "bg-background",
!!message.json && "rounded-b-none",
)}
/>
)}
{!!message.content &&
(shouldRenderMarkdown ? (
<MarkdownOrJsonView
title={message.name ?? message.role}
content={message.content}
className={cn(
"bg-muted",
message.role === "system" && "bg-primary-foreground",
message.role === "assistant" &&
"bg-accent-light-green dark:border-accent-dark-green",
message.role === "user" && "bg-background",
!!message.json && "rounded-b-none",
)}
customCodeHeaderClassName={cn(
message.role === "assistant" &&
"bg-muted-green dark:bg-secondary",
)}
/>
) : (
<JSONView
title={message.name ?? message.role}
json={message.content}
className={cn(
"bg-muted",
message.role === "system" && "bg-primary-foreground",
message.role === "assistant" &&
"bg-accent-light-green dark:border-accent-dark-green",
message.role === "user" && "bg-background",
!!message.json && "rounded-b-none",
)}
/>
))}
{!!message.json && (
<JSONView
title={
@@ -22,12 +22,13 @@ 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 { type ValidatedScore } from "@/src/features/public-api/types/scores";
import { type APIScore } from "@/src/features/public-api/types/scores";
import useLocalStorage from "@/src/components/useLocalStorage";
export const ObservationPreview = (props: {
observations: Array<ObservationReturnType>;
projectId: string;
scores: ValidatedScore[];
scores: APIScore[];
currentObservationId: string;
traceId: string;
}) => {
@@ -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,
@@ -60,7 +64,7 @@ export const ObservationPreview = (props: {
}
acc.get(score.source)?.push(score);
return acc;
}, new Map<ScoreSource, ValidatedScore[]>());
}, new Map<ScoreSource, APIScore[]>());
return (
<Card className="col-span-2 flex max-h-full flex-col overflow-hidden">
@@ -148,7 +152,11 @@ export const ObservationPreview = (props: {
.filter(Boolean)
.map(([key, value]) => (
<Badge variant="outline" key={key}>
{key}: {value?.toString()}
{key}:{" "}
{Object.prototype.toString.call(value) ===
"[object Object]"
? JSON.stringify(value)
: value?.toString()}
</Badge>
))
: null}
@@ -160,7 +168,10 @@ export const ObservationPreview = (props: {
traceId={preloadedObservation.traceId}
observationId={preloadedObservation.id}
scores={props.scores}
emptySelectedConfigIds={emptySelectedConfigIds}
setEmptySelectedConfigIds={setEmptySelectedConfigIds}
type="observation"
key={"annotation-drawer" + preloadedObservation.id}
/>
{observationWithInputAndOutput.data?.type === "GENERATION" && (
<JumpToPlaygroundButton
+4 -4
View File
@@ -1,7 +1,7 @@
import { type NestedObservation } from "@/src/utils/types";
import { cn } from "@/src/utils/tailwind";
import { type Trace, type $Enums } from "@langfuse/shared";
import { type ValidatedScore } from "@/src/features/public-api/types/scores";
import { type APIScore } from "@/src/features/public-api/types/scores";
import { GroupedScoreBadges } from "@/src/components/grouped-score-badge";
import { Fragment } from "react";
import { type ObservationReturnType } from "@/src/server/api/routers/traces";
@@ -23,7 +23,7 @@ export const ObservationTree = (props: {
collapseAll: () => void;
expandAll: () => void;
trace: Trace;
scores: ValidatedScore[];
scores: APIScore[];
currentObservationId: string | undefined;
setCurrentObservationId: (id: string | undefined) => void;
showMetrics: boolean;
@@ -62,7 +62,7 @@ const ObservationTreeTraceNode = (props: {
trace: Trace & { latency?: number };
expandAll: () => void;
collapseAll: () => void;
scores: ValidatedScore[];
scores: APIScore[];
currentObservationId: string | undefined;
setCurrentObservationId: (id: string | undefined) => void;
showMetrics?: boolean;
@@ -120,7 +120,7 @@ const ObservationTreeNode = (props: {
observations: NestedObservation[];
collapsedObservations: string[];
toggleCollapsedObservation: (id: string) => void;
scores: ValidatedScore[];
scores: APIScore[];
indentationLevel: number;
currentObservationId: string | undefined;
setCurrentObservationId: (id: string | undefined) => void;
+2 -2
View File
@@ -1,10 +1,10 @@
import { GroupedScoreBadges } from "@/src/components/grouped-score-badge";
import { type ValidatedScore } from "@/src/features/public-api/types/scores";
import { type APIScore } from "@/src/features/public-api/types/scores";
export const ScoresPreview = ({
itemScoresBySource,
}: {
itemScoresBySource: Map<string, ValidatedScore[]>;
itemScoresBySource: Map<string, APIScore[]>;
}) => {
if (!Boolean(itemScoresBySource.size)) return null;
+10 -3
View File
@@ -19,7 +19,8 @@ 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 { type ValidatedScore } from "@/src/features/public-api/types/scores";
import { type APIScore } from "@/src/features/public-api/types/scores";
import useLocalStorage from "@/src/components/useLocalStorage";
export const TracePreview = ({
trace,
@@ -28,12 +29,15 @@ export const TracePreview = ({
}: {
trace: Trace & { latency?: number };
observations: ObservationReturnType[];
scores: ValidatedScore[];
scores: APIScore[];
}) => {
const [selectedTab, setSelectedTab] = useQueryParam(
"view",
withDefault(StringParam, "preview"),
);
const [emptySelectedConfigIds, setEmptySelectedConfigIds] = useLocalStorage<
string[]
>("emptySelectedConfigIds", []);
const traceScores = scores.filter((s) => s.observationId === null);
const traceScoresBySource = traceScores.reduce((acc, score) => {
@@ -42,7 +46,7 @@ export const TracePreview = ({
}
acc.get(score.source)?.push(score);
return acc;
}, new Map<ScoreSource, ValidatedScore[]>());
}, new Map<ScoreSource, APIScore[]>());
return (
<Card className="col-span-2 flex max-h-full flex-col overflow-hidden">
@@ -100,6 +104,9 @@ export const TracePreview = ({
projectId={trace.projectId}
traceId={trace.id}
scores={scores}
emptySelectedConfigIds={emptySelectedConfigIds}
setEmptySelectedConfigIds={setEmptySelectedConfigIds}
key={"annotation-drawer" + trace.id}
/>
<NewDatasetItemFromTrace
traceId={trace.id}
@@ -1,7 +1,7 @@
import { Card } from "@/src/components/ui/card";
import { type ObservationReturnType } from "@/src/server/api/routers/traces";
import { type Trace } from "@langfuse/shared";
import { type ValidatedScore } from "@/src/features/public-api/types/scores";
import { type APIScore } from "@/src/features/public-api/types/scores";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { SimpleTreeView } from "@mui/x-tree-view/SimpleTreeView";
@@ -188,7 +188,7 @@ function TraceTreeItem({
traceStartTime: Date;
totalScaleSpan: number;
projectId: string;
scores: ValidatedScore[];
scores: APIScore[];
observations: Array<ObservationReturnType>;
cardWidth: number;
}) {
@@ -267,7 +267,7 @@ export function TraceTimelineView({
trace: Trace & { latency?: number };
observations: Array<ObservationReturnType>;
projectId: string;
scores: ValidatedScore[];
scores: APIScore[];
}) {
const { latency, name, id } = trace;
const [backgroundColor, setBackgroundColor] = useState("");
+4 -3
View File
@@ -34,12 +34,12 @@ import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePos
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 ValidatedScore } from "@/src/features/public-api/types/scores";
import { type APIScore } from "@/src/features/public-api/types/scores";
export function Trace(props: {
observations: Array<ObservationReturnType>;
trace: Trace;
scores: ValidatedScore[];
scores: APIScore[];
projectId: string;
}) {
const capture = usePostHogClientCapture();
@@ -102,6 +102,7 @@ export function Trace(props: {
const expandAll = useCallback(() => {
capture("trace_detail:observation_tree_expand", { type: "all" });
setCollapsedObservations([]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
@@ -261,7 +262,7 @@ export function TracePage({ traceId }: { traceId: string }) {
itemId={traceId}
projectId={trace.data.projectId}
scope="traces:delete"
invalidateFunc={() => void utils.traces.invalidate()}
invalidateFunc={() => void utils.traces.all.invalidate()}
type="trace"
redirectUrl={`/project/${router.query.projectId as string}/traces`}
/>
+12 -1
View File
@@ -16,8 +16,19 @@ export function nestObservations(
): NestedObservation[] {
if (list.length === 0) return [];
// Data prep: Remove parentObservationId attribute from observations if the id does not exist in the list of observations
const mutableList = list.map((o) => ({ ...o }));
mutableList.forEach((observation) => {
if (
observation.parentObservationId &&
!list.find((o) => o.id === observation.parentObservationId)
) {
observation.parentObservationId = null;
}
});
// Step 0: Sort the list by start time to ensure observations are in right order
const sortedObservations = list.sort(
const sortedObservations = mutableList.sort(
(a, b) => a.startTime.getTime() - b.startTime.getTime(),
);
+67 -5
View File
@@ -7,17 +7,37 @@ import "react18-json-view/src/dark.css";
import { deepParseJson } from "@langfuse/shared";
import { Skeleton } from "@/src/components/ui/skeleton";
import { useTheme } from "next-themes";
import { BsMarkdown } from "react-icons/bs";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { useMarkdownContext } from "@/src/features/theming/useMarkdownContext";
export function JSONView(props: {
canEnableMarkdown?: boolean;
json?: unknown;
title?: string;
className?: string;
isLoading?: boolean;
codeClassName?: string;
collapseStringsAfterLength?: number | null;
}) {
// some users ingest stringified json nested in json, parse it
const [isCopied, setIsCopied] = useState(false);
const parsedJson = deepParseJson(props.json);
const { resolvedTheme } = useTheme();
const { setIsMarkdownEnabled } = useMarkdownContext();
const capture = usePostHogClientCapture();
const collapseStringsAfterLength =
props.collapseStringsAfterLength === null
? 100_000_000 // if null, show all (100M chars)
: props.collapseStringsAfterLength ?? 500;
const handleCopy = () => {
setIsCopied(true);
void navigator.clipboard.writeText(stringifyJsonNode(parsedJson));
setTimeout(() => setIsCopied(false), 1000);
};
return (
<div className={cn("rounded-md border", props.className)}>
{props.title ? (
@@ -26,10 +46,43 @@ export function JSONView(props: {
props.title === "assistant" || props.title === "Output"
? "dark:border-accent-dark-green"
: "",
"border-b px-3 py-1 text-xs font-medium",
"flex flex-row items-center justify-between border-b px-3 py-1 text-xs font-medium",
)}
>
{props.title}
<div className="flex items-center gap-1">
{props.canEnableMarkdown && (
<Button
title="Enable Markdown"
variant="ghost"
type="button"
size="xs"
onClick={() => {
setIsMarkdownEnabled(true);
capture("trace_detail:io_pretty_format_toggle_group", {
renderMarkdown: true,
});
}}
className="opacity-50 hover:bg-border"
>
<BsMarkdown className="h-4 w-4 text-foreground" />
</Button>
)}
<Button
title="Copy to clipboard"
variant="ghost"
size="xs"
type="button"
onClick={handleCopy}
className="hover:bg-border"
>
{isCopied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</div>
) : undefined}
<div
@@ -46,7 +99,15 @@ export function JSONView(props: {
theme="github"
dark={resolvedTheme === "dark"}
collapseObjectsAfterLength={20}
collapseStringsAfterLength={500}
collapseStringsAfterLength={collapseStringsAfterLength}
collapseStringMode="word"
customizeCollapseStringUI={(fullSTring, truncated) =>
truncated ? (
<div className="opacity-50">{`\n...expand (${Math.max(fullSTring.length - collapseStringsAfterLength, 0)} more characters)`}</div>
) : (
""
)
}
displaySize={"collapsed"}
matchesURL={true}
customizeCopy={(node) => stringifyJsonNode(node)}
@@ -130,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,
)}
>
@@ -144,10 +205,11 @@ 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"
collapseStringsAfterLength={null} // in table, show full strings as row height is fixed
/>
)}
</>

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