Compare commits

...
151 Commits
Author SHA1 Message Date
Marlies Mayerhofer b21dd45597 chore: release v2.71.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 / e2e-server-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-08-16 19:15:40 +02:00
marliessophieandGitHub c1649ce9d1 chore(dashboard): release score analytics (#2961) 2024-08-16 18:53:25 +02:00
Marc KlingenandGitHub 555ac90535 fix(ui): table headers should not wrap (#2956) 2024-08-16 12:07:18 +02:00
Hassieb PakzadandGitHub a84d826e22 fix(ingestion): no processing of update events only with no existing record (#2955) 2024-08-16 11:32:06 +02:00
marliessophieandGitHub 7464059c6e feat(dashboard): aggregate and time series charts per score (#2935) 2024-08-15 16:10:00 +00:00
Max DeichmannandGitHub f159afc49e fix: remove global ingestion rate limiting (#2954) 2024-08-15 17:45:30 +02:00
Max DeichmannandGitHub e064f13dca fix: fix staging branch (#2953) 2024-08-15 15:22:59 +00:00
Max DeichmannandGitHub 8ec44d7fdb perf: optionally move ingestion pipeline into worker (#2936) 2024-08-15 15:13:03 +00:00
Hassieb PakzadandGitHub 9358f21b2b fix(ingestion): race condition on events 10s apart (#2950) 2024-08-15 16:57:46 +02:00
Marc Klingen 4e5a3c0e07 perf: reduce number of session retries 2024-08-15 15:40:32 +02:00
marliessophieandGitHub 2950a480f1 chore(markdown-ui): disable image previews on public traces; fix dom nesting error for markdown img (#2948)
* chore(markdown-ui): disable image previews on public traces

* fix(markdown-ui): wrap markdown img in fragment to prevent dom nesting error
2024-08-15 12:48:02 +00:00
Marc KlingenandGitHub b4246ec66e feat(ui): add loading state to sso sign in buttons (#2947) 2024-08-15 11:35:35 +00:00
Marc KlingenandGitHub dc15b72fc6 perf: drop dataset_items fkey on traces and observations (#2942) 2024-08-15 12:27:57 +02:00
marliessophieandGitHub 1ccfe68aae chore(audit-logs): add audit logs for score configs (#2944) 2024-08-15 09:47:25 +00:00
marliessophieandGitHub 1977725efc refactor(ui): defer fetching score configs until annotation drawer opened (#2943) 2024-08-15 09:07:54 +00:00
Marc KlingenandGitHub ac444e68e9 fix(models): text-embedding-3-large model name (#2941) 2024-08-15 00:47:30 +02:00
Marc Klingen ba58d6412c chore: make sign in message conditional to langfuse cloud env 2024-08-14 23:59:15 +02:00
Marc Klingen 5ceadd189c chore: add temp signin banner 2024-08-14 23:56:03 +02:00
Marc KlingenandGitHub 4c56e69702 docs: add comments to org membersRouter (#2939) 2024-08-14 17:34:35 +02:00
Hassieb PakzadandGitHub 857cf123a4 fix(ingestion): remove spread operator in cost calc (#2938) 2024-08-14 17:24:07 +02:00
Hassieb PakzadandGitHub 7af78553d1 feat(ingestion): enqueue events directly in web (#2934) 2024-08-14 12:12:00 +02:00
Marc Klingen aba41aeb1c chore: release v2.70.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
2024-08-14 00:08:30 +02:00
Marc KlingenandGitHub 1e56085d86 perf(ui): reload session after role changes only when they affected own user (#2929) 2024-08-13 21:49:11 +00:00
Marc Klingen f39576ccb4 docs: add note that POST /prompts creates a new prompt version 2024-08-13 22:55:29 +02:00
Marc KlingenandGitHub 408dd15d85 fix(ui): exclude dynamic pages when switching between projects (#2928) 2024-08-13 20:51:40 +00:00
Marc KlingenandGitHub 7a00e9415c chore: add projectId to trpc get trace and observations queries (#2926) 2024-08-13 20:37:42 +00:00
Marc KlingenandGitHub f9dda6088f fix(ui): projectoverview on small screens + no back button in breadcrumb (#2925)
fix(ui): projectoverview on small screens + no back button in breadcrumb
2024-08-13 19:57:56 +00:00
Marc Klingen d5d570ddc8 chore: release v2.70.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
2024-08-13 20:35:46 +02:00
Marc KlingenandGitHub f37dc514a5 feat(ee): add ui customization, e.g. docs, support, feedback links (#2924) 2024-08-13 20:34:27 +02:00
Marc KlingenandGitHub 2ec0ae8db2 feat(ui): add deletion of organizations via org settings (#2922) 2024-08-13 18:06:59 +02:00
Hassieb PakzadandGitHub 3a6f441524 fix(ingestion): tokenisation of chat message arrays (#2921) 2024-08-13 13:29:44 +00:00
Marc KlingenandGitHub afec6fb433 fix(ui): add org constraint to author user query on scores table (#2918) 2024-08-13 14:49:57 +02:00
Marc KlingenandGitHub 27e61c6f51 fix(ui): prompt version author name resolution via org instead of projects (#2915) 2024-08-13 14:44:23 +02:00
Marc Klingen 27868582f9 chore(cloud): enable US deployment 2024-08-13 14:39:09 +02:00
Hassieb PakzadandGitHub e6e740839f fix(ingestion): score comment + stringValue (#2917) 2024-08-13 14:31:08 +02:00
Hassieb PakzadandGitHub 8dc6ad601b refactor(scores): validateAndInflateScore to shared (#2903) 2024-08-13 14:27:12 +02:00
Marc KlingenandGitHub 1f38258e0c feat: configure default organization and role for new users (#2913) 2024-08-13 12:25:17 +00:00
marliessophieandGitHub c42ff44f8f style(score-configs): add confirm modal upon score config creation; add score data type icons on annotation drawer (#2910) 2024-08-13 11:00:34 +00:00
Marc KlingenandGitHub ec86cc7fe2 fix(ui): update frontend session after a role change (#2914)
update sessions after role changes (in case it is the users own role)
2024-08-13 10:47:45 +00:00
Marc KlingenandGitHub d213aa54a6 chore(ee): LANGFUSE_ALLOWED_ORGANIZATION_CREATORS is an ee feature (#2912) 2024-08-13 10:15:40 +00:00
Hassieb PakzadandGitHub 2a2f72cdc8 fix(playground): search existing chat prompts (#2911) 2024-08-13 11:49:29 +02:00
Marc Klingen b25b841a36 fix(cloud): include NEXT_PUBLIC_DEMO_ORG_ID as docker build arg 2024-08-13 10:21:07 +02:00
Marc Klingen 0190d54815 chore: trigger cd 2024-08-13 09:54:37 +02:00
Marc KlingenandGitHub d8d2a3c00c feat: add organizations (#2083) 2024-08-13 09:15:12 +02:00
Max DeichmannandGitHub 7f9a739ca5 chore: halt us deployments (#2908) 2024-08-13 09:02:13 +02:00
Max DeichmannandGitHub 65f797095e feat: add worker logs to betterstack (#2902) 2024-08-12 14:16:10 +00:00
Hassieb PakzadandGitHub 1ac5934e2a chore(ingestion): addtional metrics (#2901) 2024-08-12 15:56:14 +02:00
Max DeichmannandGitHub adf362c282 chore: remove logs (#2899)
fix
2024-08-12 12:09:27 +00:00
Max DeichmannandGitHub 77e2687baa fix: fix sentry manual instrumentation (#2898) 2024-08-12 10:38:25 +00:00
Max DeichmannandGitHub 52a3276495 feat: add own sentry instrumentation on ingestion (#2897)
feat: add own sentry instrumentation on ingrestion
2024-08-12 09:11:41 +00:00
Marc KlingenandGitHub cd6f9606e8 chore: remove old LANFUSE_WEB_MIGRATION_DISABLED (#2896)
chore: remove old `LANFUSE_WEB_MIGRATION_DISABLED`, was replaced by `LANGFUSE_AUTO_POSTGRES_MIGRATION_DISABLED`
2024-08-11 17:06:09 +00:00
Marc KlingenandGitHub e03b51657d fix(auth): increase resilience against network/caching issues (#2891) 2024-08-10 15:37:13 +00:00
Hassieb PakzadandGitHub 02266d420e chore(ingestionService): add trace correctness script (#2889) 2024-08-09 16:56:47 +02:00
Max DeichmannandGitHub 3b0e856410 infra: remove manual span (#2887) 2024-08-09 14:20:14 +00:00
Max DeichmannandGitHub d0a619af35 infra: instrument batch processing (#2884) 2024-08-09 13:31:13 +00:00
Max DeichmannandGitHub 7594d3a214 feat: return ingestion pipeline early (#2855) 2024-08-09 11:52:25 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ec76d94e4a chore(deps): bump @aws-sdk/client-s3 from 3.614.0 to 3.627.0 (#2872)
Bumps [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) from 3.614.0 to 3.627.0.
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.627.0/clients/client-s3)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-09 10:32:15 +00:00
Hassieb PakzadandGitHub f6615cc052 fix(ingestionService): json parsing + endTime < startTime (#2882) 2024-08-09 10:22:22 +00:00
Marc KlingenandGitHub d0d0df3829 chore(deps): bump posthog sdk (#2878) 2024-08-09 09:36:27 +00:00
marliessophieandGitHub 85d890529f chore(ui): hide url images by default in markdown (#2865) 2024-08-09 09:16:31 +00:00
Max DeichmannandGitHub 91876f0082 security: configure snyk for dependabot (#2876) 2024-08-09 08:32:49 +00:00
Hassieb PakzadandGitHub 4c65011cb6 fix(ingestionService): add model unit (#2854) 2024-08-08 14:43:36 +00:00
ClemoandGitHub 7d0b7d4301 docs: readme update 2024-08-08 15:11:50 +02:00
Max DeichmannandGitHub 5d1064f5de feat: send trace events to worker via redis (#2857) 2024-08-08 09:27:29 +00:00
Max DeichmannandGitHub b76d15df9d feat: move batch export queue from web to redis (#2859) 2024-08-08 09:13:39 +00:00
Marc KlingenandGitHub af0e57e561 fix(auth): extend no-cache headers on server side auth apis (#2856) 2024-08-07 21:25:24 +00:00
Marc Klingen ca9377ea4e chore: release v2.69.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (node20, pg12) (push) Waiting to run
CI/CD / tests-web (node20, pg15) (push) Waiting to run
CI/CD / tests-worker (node20, pg12) (push) Waiting to run
CI/CD / tests-worker (node20, pg15) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
Snyk Container / snyk (push) Waiting to run
2024-08-07 17:53:14 +02:00
Marc KlingenandGitHub 9f3ac3ab9f fix(ee): add ENCRYPTION_KEY to default environment variables + improve error messages (#2853) 2024-08-07 17:21:40 +02:00
Marc Klingen e38deed204 chore(ui): improve azure base url description 2024-08-07 17:15:05 +02:00
marliessophieandGitHub 19bf90dd71 perf: limit table scores columns by table timeframe (#2851) 2024-08-07 16:08:20 +02:00
marliessophieandGitHub 0523fbfc67 chore(ui): remove beta flag from trace timeline view (#2841) 2024-08-07 13:49:27 +02:00
Marc KlingenandGitHub 0fcdac0cf1 feat(models): add gpt-4o-2024-08-06 pricing, and make it available on playground and evals (#2845) 2024-08-07 13:48:00 +02:00
Hassieb PakzadandGitHub c2bc17d4d1 chore(flushQueue): add logs (#2843) 2024-08-07 12:16:59 +02:00
Hassieb PakzadandGitHub 7d11812fd1 fix(IngestionService): add fallback to event timestamp (#2842) 2024-08-07 09:46:20 +00:00
marliessophieandGitHub 877bad2234 feat(data-table): break out scores to individual columns in tables (#2743) 2024-08-07 10:47:00 +02:00
Max DeichmannandGitHub dd90a28d1c perf: instrument async clickhouse tasks (#2834) 2024-08-06 21:48:58 +00:00
Marc Klingen c8c9fc0eba chore: release v2.68.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (node20, pg12) (push) Waiting to run
CI/CD / tests-web (node20, pg15) (push) Waiting to run
CI/CD / tests-worker (node20, pg12) (push) Waiting to run
CI/CD / tests-worker (node20, pg15) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
Snyk Container / snyk (push) Waiting to run
2024-08-06 22:31:48 +02:00
Marc KlingenandGitHub aafd45f771 chore(ui): less padding on tag selector (#2839) 2024-08-06 20:09:16 +00:00
Max DeichmannandGitHub fc6f32ebe2 fix: fix prisma version in dockerfile (#2838) 2024-08-06 19:39:46 +00:00
Marc KlingenandGitHub 765406492a fix(ui): large tables in markdown i/o should be scrollable horizontally (#2835) 2024-08-06 18:36:28 +02:00
Marc KlingenandGitHub 3cd840766b refactor: use docker volumes for persistence of clickhouse data in dev setup (#2832) 2024-08-06 17:33:40 +02:00
Marc KlingenandGitHub ed4d7ceb40 feat(api): add lastConfig attribute and fromUpdatedAt and toUpdatedAt query params to GET /v2/prompts (#2824) 2024-08-06 11:02:56 +02:00
Marc KlingenandGitHub 3520b1d28d fix(ui): truncation of long email addresses in user menu (#2827) 2024-08-05 19:54:08 +02:00
Hassieb PakzadandGitHub 1f0e7b2c37 chore: fix flaky test (#2826) 2024-08-05 15:31:52 +00:00
Marc KlingenandGitHub 17c9fd0028 chore(ui): remove beta flag from playground (#2825) 2024-08-05 16:59:01 +02:00
Hassieb PakzadandGitHub ab997018ff fix: disable scores in CH PoC (#2822) 2024-08-05 15:18:49 +02:00
Marc KlingenandGitHub bc10176935 feat(api): add lastUpdatedAt to GET /v2/prompts (#2820) 2024-08-05 11:44:38 +00:00
Marc KlingenandGitHub 6058fc7388 feat(ui): extend max prompt label length to 36 characters (#2819)
chore(ui): extend max prompt label length to 36 characters
2024-08-05 09:41:59 +00:00
Max DeichmannandGitHub 923ea17fef feat: increase shutdown grace period web container (#2818)
add error handling
2024-08-05 08:37:04 +00:00
Marc KlingenandGitHub 6b3630f3c2 fix(auth): strict no-cache response headers (#2814) 2024-08-02 18:42:44 +02:00
Marc Klingen c4ad55b3ff fix(ui): date_trunc of dashboard ranges 2024-08-02 14:47:55 +02:00
Max DeichmannandGitHub 45c17ca286 chore: reduce logs in ingestion pipeline (#2805) 2024-08-01 11:33:34 +00:00
Marc KlingenandGitHub d1298e68a2 fix(ui): switch to trpc networkMode: "always" to resolve trpc connection issues (#2804) 2024-08-01 09:51:52 +00:00
Max DeichmannandGitHub ab85bad70b fix: remove newrelic config file (#2803)
inject logs
2024-08-01 09:33:21 +00:00
042901ddc8 fix(ui): prevents email address overflow in user menu box (#2800)
---------

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

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

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

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

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

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

* style(ui): markdown fix list marker alignment

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

* fix lint
2024-07-23 17:22:09 +00:00
ClemoandGitHub d33066e012 docs: update readme, remove superagent 2024-07-23 16:27:02 +02:00
Mal CurtisandGitHub dff8ab4faa docs: standardize api route names (#2698) 2024-07-23 14:49:59 +02:00
Max DeichmannandGitHub 57a42c8c4b infra: log prisma gauges (#2719) 2024-07-23 14:44:37 +02:00
marliessophieandGitHub a8258382d5 style(ui): add icon representation for trace links in Dataset Run Items table (#2715)
* style(ui): add icon representation for trace links in Dataset Run Items Table
2024-07-23 14:08:52 +02:00
Marlies Mayerhofer ba9d5217a4 chore: release v2.65.1
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (node20, pg12) (push) Waiting to run
CI/CD / tests-web (node20, pg15) (push) Waiting to run
CI/CD / tests-worker (node20, pg12) (push) Waiting to run
CI/CD / tests-worker (node20, pg15) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
Snyk Container / snyk (push) Waiting to run
2024-07-23 13:38:15 +02:00
marliessophieandGitHub 69bf783b11 fix(ui): gracefully handle image paths in markdown (#2717) 2024-07-23 09:23:04 +00:00
marliessophieandGitHub 31fb4b4b20 fix: render image paths in markdown gracefully (#2711) 2024-07-23 07:41:07 +00:00
Max DeichmannandGitHub 2d2c3c5818 infra: remove outdated worker us deployment (#2708)
types
2024-07-22 17:51:37 +00:00
432 changed files with 24738 additions and 8375 deletions
+7 -1
View File
@@ -6,6 +6,12 @@
DIRECT_URL="postgresql://postgres:postgres@localhost:5432/postgres"
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
# Clickhouse
CLICKHOUSE_MIGRATION_URL="clickhouse://localhost:9000"
CLICKHOUSE_URL="http://localhost:8123"
CLICKHOUSE_USER="clickhouse"
CLICKHOUSE_PASSWORD="clickhouse"
# Next Auth
# You can generate a new secret on the command line with:
# openssl rand -base64 32
@@ -44,4 +50,4 @@ REDIS_AUTH="myredissecret"
LANGFUSE_WORKER_PASSWORD=mybasicauthsecret
# openssl rand -hex 32 used only here
ENCRYPTION_KEY=6c16874e5c0f0cc74ddec00425fa99fbe9ffbe412b7d5d906a4b00005df91403
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
+4 -1
View File
@@ -18,4 +18,7 @@ REDIS_HOST="127.0.0.1"
REDIS_PORT=6379
REDIS_AUTH="myredissecret"
LANGFUSE_WORKER_PASSWORD=myworkerpassword
LANGFUSE_WORKER_PASSWORD=myworkerpassword
# openssl rand -hex 32 used only here
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
+63 -18
View File
@@ -10,40 +10,43 @@ DATABASE_URL="postgresql://postgres:postgres@db:5432/postgres"
# DIRECT_URL="postgresql://postgres:postgres@db:5432/postgres"
# SHADOW_DATABASE_URL=
# optional, set to true to disable automated database migrations on Docker start
# LANGFUSE_AUTO_POSTGRES_MIGRATION_DISABLED=
# LANGFUSE_AUTO_POSTGRES_MIGRATION_DISABLED=
# Next Auth
# NEXTAUTH_URL does not need to be set when deploying on Vercel
NEXTAUTH_URL="http://localhost:3000"
# You can generate a new secret on the command line with:
# For each of these, you can generate a new secret on the command line with:
# openssl rand -base64 32
# https://next-auth.js.org/configuration/options#secret
NEXTAUTH_SECRET="secret"
SALT="salt"
NEXTAUTH_SECRET="secret" # https://next-auth.js.org/configuration/options#secret
SALT="salt" # salt used to hash api keys
# API level encryption for sensitive data
# Must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32
ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000"
# Use CSP headers to enforce HTTPS, optional
LANGFUSE_CSP_ENFORCE_HTTPS="true"
# LANGFUSE_CSP_ENFORCE_HTTPS="true"
# Docker only, optional
# PORT=3000
# HOSTNAME=localhost
# Default project, optional
# Default role for users who sign up, optional, can be org or org+project
# LANGFUSE_DEFAULT_ORG_ID=
# LANGFUSE_DEFAULT_ORG_ROLE=
# LANGFUSE_DEFAULT_PROJECT_ID=
# LANGFUSE_DEFAULT_PROJECT_ROLE=
# Enable experimental features, optional
# LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=true
# Disable automated database migrations on Docker start, optional
# LANFUSE_WEB_MIGRATION_DISABLED=true
# Auth, optional configuration
# 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=
@@ -94,8 +97,20 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
# The page size can be adjusted if needed to optimize performance
# DB_EXPORT_PAGE_SIZE=1000
# Disable logging to `events` table in db
# ENABLE_EVENT_LOG=false
### START Enterprise Edition Configuration
# Allowlisted users that can create new organizations, by default all users can create organizations
# LANGFUSE_ALLOWED_ORGANIZATION_CREATORS=user1@langfuse.com,user2@langfuse.com
# UI Customization Options
# LANGFUSE_UI_API_HOST=https://api.example.com
# LANGFUSE_UI_DOCUMENTATION_HREF=https://docs.example.com
# LANGFUSE_UI_SUPPORT_HREF=https://support.example.com
# LANGFUSE_UI_FEEDBACK_HREF=https://feedback.example.com
### END Enterprise Edition Configuration
@@ -104,9 +119,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
@@ -133,6 +145,7 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
# SENTRY_AUTH_TOKEN=
# Used to determine the Sentry sample rate
# LANGFUSE_TRACING_SAMPLE_RATE=
# LANGFUSE_WORKER_BETTERSTACK_TOKEN=
# Cloudflare Turnstile
@@ -140,16 +153,48 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
# TURNSTILE_SECRET_KEY=
# Demo project that users can use to try the platform
# NEXT_PUBLIC_DEMO_ORG_ID=
# NEXT_PUBLIC_DEMO_PROJECT_ID=
# Crisp chat
# NEXT_PUBLIC_CRISP_WEBSITE_ID=
# Encryption
# ENCRYPTION_KEY=
# Admin API
# ADMIN_API_KEY=
# Redis
# REDIS_HOST=
# REDIS_PORT=
# REDIS_AUTH=
# REDIS_CONNECTION_STRING=
# Cache configuration
# LANGFUSE_CACHE_API_KEY_ENABLED=
# LANGFUSE_CACHE_API_KEY_TTL_SECONDS=
# LANGFUSE_CACHE_PROMPT_ENABLED=
# LANGFUSE_CACHE_PROMPT_TTL_SECONDS=
### END Langfuse Cloud Config
## START Langfuse V3 Ingestion
# Clickhouse
# CLICKHOUSE_MIGRATION_URL=
# CLICKHOUSE_URL=
# CLICKHOUSE_USER=
# CLICKHOUSE_PASSWORD=
# Ingestion
# LANGFUSE_INGESTION_BUFFER_TTL_SECONDS=
# LANGFUSE_INGESTION_FLUSH_DELAY_MS=
# LANGFUSE_INGESTION_FLUSH_ATTEMPTS=
# LANGFUSE_INGESTION_FLUSH_PROCESSING_CONCURRENCY=
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_BATCH_SIZE=
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=
# LANGFUSE_INGESTION_CLICKHOUSE_MAX_ATTEMPTS=
# LANGFUSE_LOG_LEVEL=
# LANGFUSE_LEGACY_INGESTION_WORKER_CONCURRENCY=
# LANGFUSE_ASYNC_INGESTION_PROCESSING="true"
## END Langfuse V3 Ingestion
+64 -2
View File
@@ -63,6 +63,7 @@ jobs:
fi
tests-web:
timeout-minutes: 20
runs-on: ubuntu-latest
name: tests-web (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }})
strategy:
@@ -93,7 +94,7 @@ jobs:
- name: Load default env
run: |
cp .env.dev.example .env
cp .env.dev.example web/.env
grep -v -e '^REDIS_HOST=' .env.dev.example > .env
- name: Run + migrate
run: |
@@ -118,6 +119,7 @@ jobs:
run: pnpm --filter=web run test
tests-worker:
timeout-minutes: 20
runs-on: ubuntu-latest
name: tests-worker (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }})
strategy:
@@ -145,10 +147,17 @@ jobs:
run: |
pnpm install
- name: Install golang-migrate for Clickhouse migrations
run: |
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.16.2/migrate.linux-amd64.tar.gz | tar xvz
sudo mv migrate /usr/bin/migrate
which migrate
- name: Load default env
run: |
cp .env.dev.example .env
cp .env.dev.example web/.env
cp .env.dev.example worker/.env
- name: Run + migrate
run: |
@@ -158,6 +167,7 @@ jobs:
- name: Seed DB
run: |
pnpm run --filter=shared ch:up
pnpm run db:migrate
pnpm run db:seed
@@ -209,10 +219,62 @@ jobs:
- name: Run e2e tests
run: pnpm --filter=web run test:e2e
e2e-server-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v3
with:
version: 9.5.0
- uses: actions/setup-node@v3
with:
node-version: 20
cache: "pnpm"
cache-dependency-path: "pnpm-lock.yaml"
- name: install dependencies
run: |
pnpm install
- name: Load default env
run: |
cp .env.dev.example .env
echo "LANGFUSE_ASYNC_INGESTION_PROCESSING=true" >> .env
echo "LANGFUSE_CACHE_API_KEY_ENABLED=true" >> .env
echo "LANGFUSE_CACHE_PROMPT_ENABLED=true" >> .env
- name: Run + migrate
run: |
docker compose -f docker-compose.dev.yml up -d
docker compose ps
sleep 5 # Wait for PostgreSQL to accept connections
- name: Seed DB
run: |
pnpm run db:migrate
pnpm run db:seed:examples
- name: Build
run: pnpm run build
- name: Run server
run: (pnpm run start&)
- name: Run e2e tests
run: pnpm --filter=web run test:e2e:server
all-ci-passed:
# This allows us to have a branch protection rule for tests and deploys with matrix
runs-on: ubuntu-latest
needs: [lint, tests-web, tests-worker, e2e-tests, test-docker-build]
needs:
[
lint,
tests-web,
tests-worker,
e2e-tests,
test-docker-build,
e2e-server-tests,
]
if: always()
steps:
- name: Successful deploy
+21 -21
View File
@@ -1,29 +1,29 @@
"on":
push:
branches:
- production
- production
name: Deploy to web
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: "4060"
PORTER_DEPLOYMENT_TARGET_ID: 3be075c0-b121-4123-aec1-5dc571626b0c
PORTER_HOST: https://dashboard.porter.run
PORTER_PR_NUMBER: ${{ github.event.number }}
PORTER_PROJECT: "12565"
PORTER_REPO_NAME: ${{ github.event.repository.name }}
PORTER_STACK_NAME: web
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4060 }}
- 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: "4060"
PORTER_DEPLOYMENT_TARGET_ID: 3be075c0-b121-4123-aec1-5dc571626b0c
PORTER_HOST: https://dashboard.porter.run
PORTER_PR_NUMBER: ${{ github.event.number }}
PORTER_PROJECT: "12565"
PORTER_REPO_NAME: ${{ github.event.repository.name }}
PORTER_STACK_NAME: web
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4060 }}
@@ -1,29 +1,29 @@
"on":
push:
branches:
- production
- production
name: Deploy to worker (EU temp)
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: "4060"
PORTER_DEPLOYMENT_TARGET_ID: 3be075c0-b121-4123-aec1-5dc571626b0c
PORTER_HOST: https://dashboard.porter.run
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_4060 }}
- 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: "4060"
PORTER_DEPLOYMENT_TARGET_ID: 3be075c0-b121-4123-aec1-5dc571626b0c
PORTER_HOST: https://dashboard.porter.run
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_4060 }}
@@ -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 }}
+10 -3
View File
@@ -1,5 +1,12 @@
name: Snyk Container
on: push
on:
push:
branches-ignore:
- "dependabot/**"
pull_request:
branches:
- "**"
jobs:
snyk:
runs-on: ubuntu-latest
@@ -18,7 +25,7 @@ jobs:
args: --file=web/Dockerfile
- name: Upload result to GitHub Code Scanning
uses: github/codeql-action/upload-sarif@v2
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: snyk.sarif
category: web
@@ -33,7 +40,7 @@ jobs:
args: --file=worker/Dockerfile
- name: Upload result to GitHub Code Scanning
uses: github/codeql-action/upload-sarif@v2
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: snyk.sarif
category: worker
+17 -12
View File
@@ -1,4 +1,8 @@
![Langfuse GitHub Banner](https://github.com/langfuse/langfuse/assets/121163007/6035f0f3-d691-4963-b5d0-10cf506e9d42)
![langfuse_logo_1024](https://github.com/user-attachments/assets/f17d8fc1-8206-4afa-a5ec-774826be88a8)
<div align="center"><h1> Langfuse: Open Source LLM Engineering Platform</h4></div>
<div align="center"><h2> Debug and improve your LLM app</h4></div>
<div align="center"><h3> LLM Observability, Prompt Management, LLM Evaluations, Datasets, LLM Metrics and Prompt Playground</h4></div>
<div align="center">
<div>
@@ -37,7 +41,7 @@
</div>
</br>
## Overview
## Langfuse Overview
_Unmute video for voice-over_
@@ -45,18 +49,18 @@ https://github.com/langfuse/langfuse/assets/2834609/a94062e9-c782-4ee9-af59-dee6
### Develop
- **Observability:** Instrument your app and start ingesting traces to Langfuse ([Quickstart](https://langfuse.com/docs/get-started), [Integrations](https://langfuse.com/docs/integrations) [Tracing](https://langfuse.com/docs/tracing))
- **LLM Observability:** Instrument your app and start ingesting traces to Langfuse ([Quickstart](https://langfuse.com/docs/get-started), [Integrations](https://langfuse.com/docs/integrations) [Tracing](https://langfuse.com/docs/tracing))
- **Langfuse UI:** Inspect and debug complex logs ([Demo](https://langfuse.com/docs/demo), [Tracing](https://langfuse.com/docs/tracing))
- **Prompt Management:** Manage, version and deploy prompts from within Langfuse ([Prompt Management](https://langfuse.com/docs/prompts/get-started))
- **Prompt Engineering:** Test and iterate on your prompts with the [LLM Playground](https://langfuse.com/docs/playground)
### Monitor
- **Analytics:** Track metrics (cost, latency, quality) and gain insights from dashboards & data exports ([Analytics](https://langfuse.com/docs/analytics))
- **Evals:** Collect and calculate scores for your LLM completions ([Scores & Evaluations](https://langfuse.com/docs/scores))
- Run model-based evaluations ([Model-based evaluations](https://langfuse.com/docs/scores/model-based-evals)) within Langfuse
- **LLM Analytics:** Track metrics (cost, latency, quality) and gain insights from dashboards & data exports ([Analytics](https://langfuse.com/docs/analytics))
- **LLM Evaluations:** Collect and calculate scores for your LLM completions ([Scores & Evaluations](https://langfuse.com/docs/scores))
- Run ([Model-based evaluations](https://langfuse.com/docs/scores/model-based-evals)) and LLM-as-a-Judge within Langfuse
- Collect user feedback ([User Feedback](https://langfuse.com/docs/scores/user-feedback))
- Manually score observations in Langfuse ([Manual Scores](https://langfuse.com/docs/scores/manually))
- Manually score LLM outputs in Langfuse ([Manual Scores](https://langfuse.com/docs/scores/manually))
### Test
@@ -72,6 +76,8 @@ Managed deployment by the Langfuse team, generous free-tier (hobby plan), no cre
**[» Langfuse Cloud](https://cloud.langfuse.com)**
## Self-Hosting Open Source LLM Observability with Langfuse
### Localhost (docker)
```bash
@@ -98,7 +104,7 @@ Templated deployments: [Railway, GCP Cloud Run, AWS Fargate, Kubernetes and othe
You need a Langfuse public and secret key to get started. Sign up [here](https://cloud.langfuse.com) and find them in your project settings.
### Ingesting Data · Instrumenting Your Application
### Ingesting Data · Instrumenting Your Application · LLM Observability with Langfuse
Note: We recommend using our fully async, typed [SDKs](https://langfuse.com/docs/sdk) that allow you to instrument any LLM application with any underlying model. They are available in [Python (Decorators)](https://langfuse.com/docs/sdk/python) & [JS/TS](https://langfuse.com/docs/sdk/typescript). The SDKs will always be the most fully featured and stable way to ingest data into Langfuse.
@@ -106,7 +112,7 @@ You may want to use another integration to get started quickly or implement a us
See the [→ Quickstart](https://langfuse.com/docs/get-started) to integrate Langfuse.
### Integrations
### LLM Observability Integrations
| Integration | Supports | Description |
| -------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
@@ -127,15 +133,14 @@ 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
### Ideas and roadmap
- [GitHub Discussions](https://github.com/orgs/langfuse/discussions)
- [Feature Requests](https://langfuse.com/idea)
- [Roadmap](https://langfuse.com/roadmap)
- [GitHub Discussions](https://github.com/orgs/langfuse/discussions)
- [Feature Requests](https://langfuse.com/idea
### Support and feedback
+1
View File
@@ -16,6 +16,7 @@ services:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
- NEXTAUTH_SECRET=mysecret
- SALT=mysalt
- ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 # generate via `openssl rand -hex 32`
- NEXTAUTH_URL=http://localhost:3000
- TELEMETRY_ENABLED=${TELEMETRY_ENABLED:-true}
- LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false}
+25 -3
View File
@@ -1,4 +1,22 @@
services:
clickhouse:
image: clickhouse/clickhouse-server
user: "101:101"
container_name: clickhouse
hostname: clickhouse
environment:
CLICKHOUSE_DB: default
CLICKHOUSE_USER: clickhouse
CLICKHOUSE_PASSWORD: clickhouse
volumes:
- langfuse_clickhouse_data:/var/lib/clickhouse
- langfuse_clickhouse_logs:/var/log/clickhouse-server
ports:
- "8123:8123"
- "9000:9000"
depends_on:
- postgres
redis:
image: redis:7.2.4
restart: always
@@ -7,7 +25,7 @@ services:
ports:
- 6379:6379
db:
postgres:
image: postgres:${POSTGRES_VERSION:-latest}
restart: always
command: ["postgres", "-c", "log_statement=all"]
@@ -18,8 +36,12 @@ services:
ports:
- 5432:5432
volumes:
- database_data:/var/lib/postgresql/data
- langfuse_postgres_data:/var/lib/postgresql/data
volumes:
database_data:
langfuse_postgres_data:
driver: local
langfuse_clickhouse_data:
driver: local
langfuse_clickhouse_logs:
driver: local
+1
View File
@@ -10,6 +10,7 @@ services:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
- NEXTAUTH_SECRET=mysecret
- SALT=mysalt
- ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 # generate via `openssl rand -hex 32`
- NEXTAUTH_URL=http://localhost:3000
- TELEMETRY_ENABLED=${TELEMETRY_ENABLED:-true}
- LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false}
+2 -1
View File
@@ -1,4 +1,5 @@
import { env } from "../env";
export const isEeAvailable: boolean =
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined;
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined ||
env.LANGFUSE_EE_LICENSE_KEY !== undefined;
+1 -1
View File
@@ -3,7 +3,7 @@ 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(),
LANGFUSE_EE_LICENSE_KEY: z.string().optional(),
});
export const env = { ...sharedEnv, ...EnvSchema.parse(process.env) };
-1
View File
@@ -1 +0,0 @@
export { isEeAvailable } from "./ee-license-check";
+141 -45
View File
@@ -5,16 +5,36 @@ types:
id:
type: string
docs: The unique identifier of a trace
timestamp: datetime
name: optional<string>
input: optional<unknown>
output: optional<unknown>
sessionId: optional<string>
release: optional<string>
version: optional<string>
userId: optional<string>
metadata: optional<unknown>
tags: optional<list<string>>
timestamp:
type: datetime
docs: The timestamp when the trace was created
name:
type: optional<string>
docs: The name of the trace
input:
type: optional<unknown>
docs: The input data of the trace. Can be any JSON.
output:
type: optional<unknown>
docs: The output data of the trace. Can be any JSON.
sessionId:
type: optional<string>
docs: The session identifier associated with the trace
release:
type: optional<string>
docs: The release version of the application when the trace was created
version:
type: optional<string>
docs: The version of the trace
userId:
type: optional<string>
docs: The user identifier associated with the trace
metadata:
type: optional<unknown>
docs: The metadata associated with the trace. Can be any JSON.
tags:
type: optional<list<string>>
docs: The tags associated with the trace. Can be an array of strings or null.
public:
type: optional<boolean>
docs: Public traces are accessible via url without login
@@ -65,37 +85,97 @@ types:
traces: list<Trace>
Observation:
properties:
id: string
traceId: optional<string>
type: string
name: optional<string>
startTime: datetime
endTime: optional<datetime>
completionStartTime: optional<datetime>
model: optional<string>
modelParameters: optional<map<string, MapValue>>
input: optional<unknown>
version: optional<string>
metadata: optional<unknown>
output: optional<unknown>
usage: optional<Usage>
level: ObservationLevel
statusMessage: optional<string>
parentObservationId: optional<string>
promptId: optional<string>
id:
type: string
docs: The unique identifier of the observation
traceId:
type: optional<string>
docs: The trace ID associated with the observation
type:
type: string
docs: The type of the observation
name:
type: optional<string>
docs: The name of the observation
startTime:
type: datetime
docs: The start time of the observation
endTime:
type: optional<datetime>
docs: The end time of the observation.
completionStartTime:
type: optional<datetime>
docs: The completion start time of the observation
model:
type: optional<string>
docs: The model used for the observation
modelParameters:
type: optional<map<string, MapValue>>
docs: The parameters of the model used for the observation
input:
type: optional<unknown>
docs: The input data of the observation
version:
type: optional<string>
docs: The version of the observation
metadata:
type: optional<unknown>
docs: Additional metadata of the observation
output:
type: optional<unknown>
docs: The output data of the observation
usage:
type: optional<Usage>
docs: The usage data of the observation
level:
type: ObservationLevel
docs: The level of the observation
statusMessage:
type: optional<string>
docs: The status message of the observation
parentObservationId:
type: optional<string>
docs: The parent observation ID
promptId:
type: optional<string>
docs: The prompt ID associated with the observation
ObservationsView:
extends: Observation
properties:
modelId: optional<string>
inputPrice: optional<double>
outputPrice: optional<double>
totalPrice: optional<double>
calculatedInputCost: optional<double>
calculatedOutputCost: optional<double>
calculatedTotalCost: optional<double>
latency: optional<double>
timeToFirstToken: optional<double>
promptName:
type: optional<string>
docs: The name of the prompt associated with the observation
promptVersion:
type: optional<integer>
docs: The version of the prompt associated with the observation
modelId:
type: optional<string>
docs: The unique identifier of the model
inputPrice:
type: optional<double>
docs: The price of the input in USD
outputPrice:
type: optional<double>
docs: The price of the output in USD.
totalPrice:
type: optional<double>
docs: The total price in USD.
calculatedInputCost:
type: optional<double>
docs: The calculated cost of the input in USD
calculatedOutputCost:
type: optional<double>
docs: The calculated cost of the output in USD
calculatedTotalCost:
type: optional<double>
docs: The calculated total cost in USD
latency:
type: optional<double>
docs: The latency in seconds.
timeToFirstToken:
type: optional<double>
docs: The time to the first token in seconds
Usage:
docs: Standard interface for usage and cost
@@ -237,14 +317,30 @@ types:
updatedAt: datetime
DatasetRun:
properties:
id: string
name: string
description: optional<string>
metadata: optional<unknown>
datasetId: string
datasetName: string
createdAt: datetime
updatedAt: datetime
id:
type: string
docs: Unique identifier of the dataset run
name:
type: string
docs: Name of the dataset run
description:
type: optional<string>
docs: Description of the run
metadata:
type: optional<unknown>
docs: Metadata of the dataset run
datasetId:
type: string
docs: Id of the associated dataset
datasetName:
type: string
docs: Name of the associated dataset
createdAt:
type: datetime
docs: The date and time when the dataset run was created
updatedAt:
type: datetime
docs: The date and time when the dataset run was last updated
DatasetRunWithItems:
extends: DatasetRun
properties:
@@ -49,7 +49,7 @@ types:
sourceObservationId: optional<string>
id:
type: optional<string>
docs: Dataset items are upserted on their id. Id needs to be globally unique and cannot be reused across datasets.
docs: Dataset items are upserted on their id. Id needs to be unique (project-level) and cannot be reused across datasets.
status:
type: optional<commons.DatasetStatus>
docs: Defaults to ACTIVE for newly created items
@@ -39,6 +39,9 @@ service:
toStartTime:
type: optional<datetime>
docs: Retrieve only observations with a start_time before this datetime (ISO 8601).
version:
type: optional<string>
docs: Optional filter to only include observations with a certain version.
response: ObservationsViews
types:
+11 -1
View File
@@ -41,11 +41,17 @@ service:
limit:
type: optional<integer>
docs: limit of items per page
fromUpdatedAt:
type: optional<datetime>
docs: Optional filter to only include prompt versions created/updated on or after a certain datetime (ISO 8601)
toUpdatedAt:
type: optional<datetime>
docs: Optional filter to only include prompt versions created/updated before a certain datetime (ISO 8601)
response: PromptMetaListResponse
create:
docs: Create a prompt
docs: Create a new version for the prompt with the given `name`
method: POST
path: /prompts
request: CreatePromptRequest
@@ -63,6 +69,10 @@ types:
versions: list<integer>
labels: list<string>
tags: list<string>
lastUpdatedAt: datetime
lastConfig:
type: unknown
docs: Config object of the most recent prompt version that matches the filters (if any are provided)
CreatePromptRequest:
union:
+1 -1
View File
@@ -7,7 +7,7 @@ service:
base-path: /api/public
endpoints:
list:
docs: Get sessions.
docs: Get sessions
method: GET
path: /sessions
request:
+7 -1
View File
@@ -16,7 +16,7 @@ service:
docs: The unique langfuse identifier of a trace
response: commons.TraceWithFullDetails
list:
docs: Get list of traces.
docs: Get list of traces
method: GET
path: /traces
request:
@@ -44,6 +44,12 @@ service:
type: optional<string>
allow-multiple: true
docs: Only traces that include all of these tags will be returned.
version:
type: optional<string>
docs: Optional filter to only include traces with a certain version.
release:
type: optional<string>
docs: Optional filter to only include traces with a certain release.
response: Traces
types:
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "2.65.0",
"version": "2.71.0",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
@@ -16,8 +16,9 @@
"db:seed": "turbo run db:seed",
"db:seed:examples": "turbo run db:seed:examples",
"nuke": "bash ./scripts/nuke.sh",
"dx": "pnpm i && pnpm run infra:dev:up && pnpm --filter=shared run db:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
"dx-f": "pnpm i && pnpm run infra:dev:up && pnpm --filter=shared run db:reset -f && pnpm --filter=shared run db:seed:examples && pnpm run dev",
"dx": "pnpm i && pnpm run infra:dev:up && pnpm --filter=shared run db:reset && pnpm --filter=shared run ch:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
"dx-f": "pnpm i && pnpm run infra:dev:up && pnpm --filter=shared run db:reset -f && pnpm --filter=shared run ch:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
"dx:skip-infra": "pnpm i && pnpm --filter=shared run db:reset && pnpm --filter=shared run ch:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
"build": "turbo run build",
"start": "turbo run start",
"dev": "turbo run dev",
@@ -0,0 +1 @@
DROP TABLE traces;
@@ -0,0 +1,26 @@
CREATE TABLE traces (
`id` String,
`timestamp` DateTime64(3),
`name` String,
`user_id` Nullable(String),
`metadata` Map(String, String) CODEC(ZSTD(1)),
`release` Nullable(String),
`version` Nullable(String),
`project_id` String,
`public` Bool,
`bookmarked` Bool,
`tags` Array(String),
`input` Nullable(String) CODEC(ZSTD(1)),
`output` Nullable(String) CODEC(ZSTD(1)),
`session_id` Nullable(String),
`created_at` DateTime64(3) DEFAULT now(),
`updated_at` DateTime64(3) DEFAULT now(),
INDEX idx_id id TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_res_metadata_key mapKeys(metadata) TYPE bloom_filter(0.01) GRANULARITY 1,
INDEX idx_res_metadata_value mapValues(metadata) TYPE bloom_filter(0.01) GRANULARITY 1
) ENGINE = ReplacingMergeTree Partition by toYYYYMM(timestamp)
ORDER BY (
project_id,
toUnixTimestamp(timestamp),
id
);
@@ -0,0 +1 @@
DROP TABLE observations;
@@ -0,0 +1,50 @@
CREATE TABLE observations (
`id` String,
`trace_id` String,
`project_id` String,
`type` LowCardinality(String),
`parent_observation_id` Nullable(String),
`start_time` DateTime64(3),
`end_time` Nullable(DateTime64(3)),
`name` String,
`metadata` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
`level` LowCardinality(String),
`status_message` Nullable(String),
`version` Nullable(String),
`input` Nullable(String) CODEC(ZSTD(3)),
`output` Nullable(String) CODEC(ZSTD(3)),
`provided_model_name` Nullable(String),
`internal_model_id` Nullable(String),
`model_parameters` Nullable(String),
`provided_input_usage_units` Nullable(Decimal64(12)),
`provided_output_usage_units` Nullable(Decimal64(12)),
`provided_total_usage_units` Nullable(Decimal64(12)),
`input_usage_units` Nullable(Decimal64(12)),
`output_usage_units` Nullable(Decimal64(12)),
`total_usage_units` Nullable(Decimal64(12)),
`unit` Nullable(String),
`provided_input_cost` Nullable(Decimal64(12)),
`provided_output_cost` Nullable(Decimal64(12)),
`provided_total_cost` Nullable(Decimal64(12)),
`input_cost` Nullable(Decimal64(12)),
`output_cost` Nullable(Decimal64(12)),
`total_cost` Nullable(Decimal64(12)),
`completion_start_time` Nullable(DateTime64(3)),
`prompt_id` Nullable(String),
`prompt_name` Nullable(String),
`prompt_version` Nullable(UInt16),
`created_at` DateTime64(3) DEFAULT now(),
`updated_at` DateTime64(3) DEFAULT now(),
INDEX idx_id id TYPE bloom_filter() GRANULARITY 1,
INDEX idx_trace_id trace_id TYPE bloom_filter() GRANULARITY 1,
INDEX idx_project_id project_id TYPE bloom_filter() GRANULARITY 1,
INDEX idx_res_metadata_key mapKeys(metadata) TYPE bloom_filter() GRANULARITY 1,
INDEX idx_res_metadata_value mapValues(metadata) TYPE bloom_filter() GRANULARITY 1
) ENGINE = ReplacingMergeTree Partition by toYYYYMM(start_time)
ORDER BY (
project_id,
`type`,
trace_id,
toUnixTimestamp(start_time),
id
);
@@ -0,0 +1 @@
DROP TABLE scores;
@@ -0,0 +1,25 @@
CREATE TABLE scores (
`id` String,
`timestamp` DateTime64(3),
`project_id` String,
`trace_id` String,
`observation_id` Nullable(String),
`name` String,
`value` Float64,
`source` String,
`comment` Nullable(String) CODEC(ZSTD(1)),
`author_user_id` Nullable(String),
`config_id` Nullable(String),
`data_type` String,
`string_value` Nullable(String),
`created_at` DateTime64(3) DEFAULT now(),
`updated_at` DateTime64(3) DEFAULT now(),
INDEX idx_id id TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_project_id trace_id TYPE bloom_filter(0.001) GRANULARITY 1
) ENGINE = ReplacingMergeTree Partition by toYYYYMM(timestamp)
ORDER BY (
project_id,
trace_id,
toUnixTimestamp(timestamp),
id
);
@@ -0,0 +1,22 @@
#!/bin/bash
# Load environment variables
source ../../.env
# Check if golang-migrate is installed
if ! command -v migrate &> /dev/null
then
echo "Error: golang-migrate is not installed or not in PATH."
echo "Please install golang-migrate via 'brew install golang-migrate' to run this script."
echo "Visit https://github.com/golang-migrate/migrate for more installation instructions."
exit 1
fi
# Construct the database URL
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-migrations-table-engine=MergeTree"
else
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-migrations-table-engine=MergeTree"
fi
# Execute the down command
migrate -source file://clickhouse/migrations -database "$DATABASE_URL" down
@@ -0,0 +1,22 @@
#!/bin/bash
# Load environment variables
source ../../.env
# Check if golang-migrate is installed
if ! command -v migrate &> /dev/null
then
echo "Error: golang-migrate is not installed or not in PATH."
echo "Please install golang-migrate via 'brew install golang-migrate' to run this script."
echo "Visit https://github.com/golang-migrate/migrate for more installation instructions."
exit 1
fi
# Construct the database URL
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-migrations-table-engine=MergeTree"
else
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-migrations-table-engine=MergeTree"
fi
# Execute the drop command
migrate -source file://clickhouse/migrations -database "$DATABASE_URL" drop
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
# Load environment variables
source ../../.env
# Check if golang-migrate is installed
if ! command -v migrate &> /dev/null
then
echo "Error: golang-migrate is not installed or not in PATH."
echo "Please install golang-migrate via 'brew install golang-migrate' to run this script."
echo "Visit https://github.com/golang-migrate/migrate for more installation instructions."
exit 1
fi
# Construct the database URL
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-migrations-table-engine=MergeTree"
else
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-migrations-table-engine=MergeTree"
fi
# Execute the up command
migrate -source file://clickhouse/migrations -database "$DATABASE_URL" up
+14 -2
View File
@@ -38,15 +38,22 @@
"db:seed": "dotenv -e ../../.env -- npx prisma db seed",
"db:generate": "dotenv -e ../../.env -- npx prisma generate",
"db:seed:examples": "dotenv -e ../../.env -- npx prisma db seed -- --environment examples",
"db:seed:load": "dotenv -e ../../.env -- npx prisma db seed -- --environment load"
"db:seed:load": "dotenv -e ../../.env -- npx prisma db seed -- --environment load",
"ch:status": "dotenv -e ../../.env -- goose -dir './clickhouse/migrations/' status",
"ch:up": "bash clickhouse/scripts/up.sh",
"ch:down": "bash clickhouse/scripts/down.sh",
"ch:drop": "bash clickhouse/scripts/drop.sh",
"ch:reset": "pnpm run ch:down && pnpm run ch:up"
},
"prisma": {
"seed": "ts-node -r tsconfig-paths/register -r dotenv/config --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.550.0",
"@anthropic-ai/tokenizer": "^0.0.4",
"@aws-sdk/client-s3": "^3.627.0",
"@aws-sdk/lib-storage": "^3.568.0",
"@aws-sdk/s3-request-presigner": "^3.554.0",
"@clickhouse/client": "^1.4.0",
"@langchain/anthropic": "^0.1.21",
"@langchain/core": "^0.1.61",
"@langchain/openai": "^0.0.33",
@@ -55,12 +62,17 @@
"@react-email/render": "^0.0.15",
"@types/bcryptjs": "^2.4.6",
"bcryptjs": "^2.4.3",
"bullmq": "^5.4.2",
"decimal.js": "^10.4.3",
"exponential-backoff": "^3.1.1",
"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",
"uuid": "^9.0.1",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.22.5"
},
+39 -11
View File
@@ -4,13 +4,14 @@ export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
: ColumnType<T, T | undefined, T>;
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
export const ProjectRole = {
export const Role = {
OWNER: "OWNER",
ADMIN: "ADMIN",
MEMBER: "MEMBER",
VIEWER: "VIEWER"
VIEWER: "VIEWER",
NONE: "NONE"
} as const;
export type ProjectRole = (typeof ProjectRole)[keyof typeof ProjectRole];
export type Role = (typeof Role)[keyof typeof Role];
export const ObservationType = {
SPAN: "SPAN",
EVENT: "EVENT",
@@ -90,8 +91,10 @@ export type AuditLog = {
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
user_id: string;
project_id: string;
user_project_role: ProjectRole;
org_id: string;
user_org_role: string;
project_id: string | null;
user_project_role: string | null;
resource_type: string;
resource_id: string;
action: string;
@@ -121,15 +124,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 +146,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 +156,7 @@ export type DatasetRunItems = {
};
export type DatasetRuns = {
id: string;
project_id: string;
name: string;
description: string | null;
metadata: unknown | null;
@@ -226,9 +232,11 @@ export type LlmApiKeys = {
export type MembershipInvitation = {
id: string;
email: string;
role: ProjectRole;
project_id: string;
sender_id: string | null;
org_id: string;
org_role: Role;
project_id: string | null;
project_role: Role | null;
invited_by_user_id: string | null;
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
};
@@ -306,6 +314,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;
@@ -316,6 +326,21 @@ export type ObservationView = {
latency: number | null;
time_to_first_token: number | null;
};
export type Organization = {
id: string;
name: string;
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
cloud_config: unknown | null;
};
export type OrganizationMembership = {
id: string;
org_id: string;
user_id: string;
role: Role;
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
};
export type PosthogIntegration = {
project_id: string;
encrypted_posthog_api_key: string;
@@ -326,15 +351,16 @@ export type PosthogIntegration = {
};
export type Project = {
id: string;
org_id: string;
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
name: string;
cloud_config: unknown | null;
};
export type ProjectMembership = {
org_membership_id: string;
project_id: string;
user_id: string;
role: ProjectRole;
role: Role;
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
};
@@ -479,6 +505,8 @@ export type DB = {
models: Model;
observations: Observation;
observations_view: ObservationView;
organization_memberships: OrganizationMembership;
organizations: Organization;
posthog_integrations: PosthogIntegration;
project_memberships: ProjectMembership;
projects: Project;
@@ -0,0 +1,68 @@
-- Add project_id to datasets, dataset_items, dataset_runs, dataset_run_items
ALTER TABLE "dataset_items"
ADD COLUMN "project_id" TEXT;
ALTER TABLE "dataset_run_items"
ADD COLUMN "project_id" TEXT;
ALTER TABLE "dataset_runs"
ADD COLUMN "project_id" TEXT;
-- Backfill project_id for dataset_items
UPDATE dataset_items
SET project_id = datasets.project_id
FROM datasets
WHERE dataset_items.dataset_id = datasets.id;
-- Backfill project_id for dataset_runs
UPDATE dataset_runs
SET project_id = datasets.project_id
FROM datasets
WHERE dataset_runs.dataset_id = datasets.id;
-- Backfill project_id for dataset_run_items
UPDATE dataset_run_items
SET project_id = dataset_runs.project_id
FROM dataset_runs
WHERE dataset_run_items.dataset_run_id = dataset_runs.id;
-- Drop the old foreign keys
ALTER TABLE "dataset_run_items"
DROP CONSTRAINT "dataset_run_items_dataset_item_id_fkey",
DROP CONSTRAINT "dataset_run_items_dataset_run_id_fkey";
ALTER TABLE "dataset_items"
DROP CONSTRAINT "dataset_items_dataset_id_fkey";
ALTER TABLE "dataset_runs"
DROP CONSTRAINT "dataset_runs_dataset_id_fkey";
-- Now alter the columns to NOT NULL and update primary keys
ALTER TABLE "datasets"
DROP CONSTRAINT "datasets_pkey",
ADD CONSTRAINT "datasets_pkey" PRIMARY KEY ("id", "project_id");
ALTER TABLE "dataset_items"
ALTER COLUMN "project_id" SET NOT NULL,
DROP CONSTRAINT "dataset_items_pkey",
ADD CONSTRAINT "dataset_items_pkey" PRIMARY KEY ("id", "project_id");
ALTER TABLE "dataset_runs"
ALTER COLUMN "project_id" SET NOT NULL,
DROP CONSTRAINT "dataset_runs_pkey",
ADD CONSTRAINT "dataset_runs_pkey" PRIMARY KEY ("id", "project_id");
ALTER TABLE "dataset_run_items"
ALTER COLUMN "project_id" SET NOT NULL,
DROP CONSTRAINT "dataset_run_items_pkey",
ADD CONSTRAINT "dataset_run_items_pkey" PRIMARY KEY ("id", "project_id");
-- Add new foreign keys
ALTER TABLE "dataset_items" ADD CONSTRAINT "dataset_items_dataset_id_project_id_fkey" FOREIGN KEY ("dataset_id", "project_id") REFERENCES "datasets"("id", "project_id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "dataset_runs" ADD CONSTRAINT "dataset_runs_dataset_id_project_id_fkey" FOREIGN KEY ("dataset_id", "project_id") REFERENCES "datasets"("id", "project_id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "dataset_run_items" ADD CONSTRAINT "dataset_run_items_dataset_run_id_project_id_fkey" FOREIGN KEY ("dataset_run_id", "project_id") REFERENCES "dataset_runs"("id", "project_id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "dataset_run_items" ADD CONSTRAINT "dataset_run_items_dataset_item_id_project_id_fkey" FOREIGN KEY ("dataset_item_id", "project_id") REFERENCES "dataset_items"("id", "project_id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE UNIQUE INDEX CONCURRENTLY "dataset_runs_dataset_id_project_id_name_key" ON "dataset_runs"("dataset_id", "project_id", "name");
@@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX CONCURRENTLY "dataset_runs_dataset_id_name_key";
@@ -0,0 +1,100 @@
DROP VIEW IF EXISTS "observations_view"; -- Drop view as column was added in 20240705154048_observation_view_add_created_at_updated_at and update view must have same columns
CREATE VIEW "observations_view" AS -- Specify the columns that should be returned in the view, as calculated columns are added but exist in the observations table already
SELECT
o.id,
o.name,
o.start_time,
o.end_time,
o.parent_observation_id,
o.type,
o.trace_id,
o.metadata,
o.model,
o."modelParameters",
o.input,
o.output,
o.level,
o.status_message,
o.completion_start_time,
o.completion_tokens,
o.prompt_tokens,
o.total_tokens,
o.version,
o.project_id,
o.created_at,
o.updated_at,
o.unit,
o.prompt_id,
p.name as prompt_name, -- added in this change
p.version as prompt_version, -- added in this change
o.input_cost,
o.output_cost,
o.total_cost,
o.internal_model,
m.id AS "model_id",
m.start_date AS "model_start_date",
m.input_price,
m.output_price,
m.total_price,
m.tokenizer_config AS "tokenizer_config",
CASE
WHEN o.calculated_input_cost IS NULL AND o.input_cost IS NULL AND o.output_cost IS NULL AND o.total_cost IS NULL THEN
o.prompt_tokens::decimal * m.input_price
ELSE
COALESCE(o.calculated_input_cost, o.input_cost)
END AS "calculated_input_cost",
CASE
WHEN o.calculated_output_cost IS NULL AND o.input_cost IS NULL AND o.output_cost IS NULL AND o.total_cost IS NULL THEN
o.completion_tokens::decimal * m.output_price
ELSE
COALESCE(o.calculated_output_cost, o.output_cost)
END AS "calculated_output_cost",
CASE
WHEN o.calculated_total_cost IS NULL AND o.input_cost IS NULL AND o.output_cost IS NULL AND o.total_cost IS NULL THEN
CASE
WHEN m.total_price IS NOT NULL AND o.total_tokens IS NOT NULL THEN
m.total_price * o.total_tokens
ELSE
o.prompt_tokens::decimal * m.input_price +
o.completion_tokens::decimal * m.output_price
END
ELSE
COALESCE(o.calculated_total_cost, o.total_cost)
END AS "calculated_total_cost",
CASE WHEN o.end_time IS NULL THEN NULL ELSE (EXTRACT(EPOCH FROM o."end_time") - EXTRACT(EPOCH FROM o."start_time"))::double precision END AS "latency",
CASE WHEN o.completion_start_time IS NOT NULL AND o.start_time IS NOT NULL THEN EXTRACT(EPOCH FROM (completion_start_time - start_time))::double precision ELSE NULL END as "time_to_first_token"
FROM
observations o
LEFT JOIN LATERAL (
SELECT
models.*
FROM
models
WHERE (models.project_id = o.project_id OR models.project_id IS NULL)
AND models.model_name = o.internal_model
AND (models.start_date < o.start_time OR models.start_date IS NULL)
AND o.unit::TEXT = models.unit
ORDER BY
models.project_id ASC, -- in postgres, NULLs are sorted last when ordering ASC
models.start_date DESC NULLS LAST -- now, NULLs are sorted last when ordering DESC as well
LIMIT 1
) m ON TRUE
LEFT JOIN LATERAL (
SELECT
prompts.*
FROM
prompts
WHERE prompts.id = o.prompt_id
AND prompts.project_id = o.project_id
LIMIT 1
) p ON TRUE
-- requirements:
-- 1. The view should return all columns from the observations table
-- 2. The view should match with only one model for each observation if:
-- a. The model has the same project_id as the observation, otherwise the model without project_id.
-- b. The model has the same model_name as the observation
-- c. The model has a start_date that is less than the observation start_time, otherwise the model without start_date
-- d. The model has the same unit as the observation
@@ -0,0 +1,18 @@
INSERT INTO models (
id,
project_id,
model_name,
match_pattern,
start_date,
input_price,
output_price,
total_price,
unit,
tokenizer_id,
tokenizer_config
)
VALUES
-- gpt-4o-2024-08-06
('clzjr85f70000ymmzg7hqffra', NULL, 'gpt-4o-2024-08-06', '(?i)^(gpt-4o-2024-08-06)$', NULL, 0.0000025, 0.000010, NULL, 'TOKENS', 'openai', '{ "tokensPerMessage": 3, "tokensPerName": 1, "tokenizerModel": "gpt-4o" }')
@@ -0,0 +1,164 @@
-- Table ORGANIZATIONS
-- Create empty table ORGANIZATIONS
CREATE TABLE "organizations" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"cloud_config" JSONB,
CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
);
-- Backfill table ORGANIZATIONS: create a new organization for each project, move cloudConfig, and set the org_id on the project to project_id prefixed with 'o'
INSERT INTO "organizations" ("id", "name", "cloud_config", "created_at")
SELECT
CONCAT('o', "id") as "id", -- This mapping is used in other migration steps as well, keep it consistent
"name",
"cloud_config",
"created_at"
FROM "projects";
-- Drop column cloud_config from projects as it's now on organization level
ALTER TABLE "projects" DROP COLUMN "cloud_config";
-- PROJECT.ORG_ID
-- Add org_id to projects
ALTER TABLE "projects" ADD COLUMN "org_id" TEXT;
-- Backfill: org_id on projects, set it to project_id prefixed with 'o'
UPDATE "projects"
SET "org_id" = CONCAT('o', "id");
-- Set not null after backfill
ALTER TABLE "projects" ALTER COLUMN "org_id" SET NOT NULL;
-- AddForeignKey
ALTER TABLE "projects" ADD CONSTRAINT "projects_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- CreateIndex
CREATE INDEX "projects_org_id_idx" ON "projects"("org_id");
-- ORGANIZATION MEMBERSHIPS
-- Create UserRole ENUM
CREATE TYPE "Role" AS ENUM ('OWNER', 'ADMIN', 'MEMBER', 'VIEWER', 'NONE');
-- Create empty table
CREATE TABLE "organization_memberships" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"role" "Role" NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "organization_memberships_pkey" PRIMARY KEY ("id")
);
-- Add indexes
CREATE INDEX "organization_memberships_user_id_idx" ON "organization_memberships"("user_id");
CREATE INDEX "organization_memberships_org_id_idx" ON "organization_memberships"("org_id");
CREATE UNIQUE INDEX "organization_memberships_org_id_user_id_key" ON "organization_memberships"("org_id", "user_id");
-- AddForeignKey
ALTER TABLE "organization_memberships" ADD CONSTRAINT "organization_memberships_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "organization_memberships" ADD CONSTRAINT "organization_memberships_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Migrate project memberships to organization memberships
INSERT INTO "organization_memberships" ("id", "org_id", "user_id", "role", "created_at", "updated_at")
SELECT
md5(random()::text || clock_timestamp()::text || project_id::text || user_id::text)::uuid AS "id",
CONCAT('o', "project_id") as "org_id",
"user_id",
"role"::text::"Role" as "role",
"created_at",
"updated_at"
FROM "project_memberships";
-- Delete all project memberships after migration to organization memberships
DELETE FROM "project_memberships";
-- Add org_membership_id to project_memberships, ok to be not null as it's a new column on a now empty table
ALTER TABLE "project_memberships" ADD COLUMN "org_membership_id" TEXT NOT NULL;
-- Switch to new UserRole enum
ALTER TABLE "project_memberships" DROP COLUMN "role",
ADD COLUMN "role" "Role" NOT NULL;
-- AddForeignKey
ALTER TABLE "project_memberships" ADD CONSTRAINT "project_memberships_org_membership_id_fkey" FOREIGN KEY ("org_membership_id") REFERENCES "organization_memberships"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- CreateIndex
CREATE INDEX "project_memberships_project_id_idx" ON "project_memberships"("project_id");
-- CreateIndex
CREATE INDEX "project_memberships_org_membership_id_idx" ON "project_memberships"("org_membership_id");
-- AUDIT LOGS
-- DropForeignKey, just index on these, should remain after project/org/user deletions
ALTER TABLE "audit_logs" DROP CONSTRAINT "audit_logs_user_id_fkey";
ALTER TABLE "audit_logs" DROP CONSTRAINT "audit_logs_project_id_fkey";
-- Drop not null on project level cols
ALTER TABLE "audit_logs" ALTER COLUMN "project_id" DROP NOT NULL;
ALTER TABLE "audit_logs" ALTER COLUMN "user_project_role" DROP NOT NULL;
-- Add org-level cols
ALTER TABLE "audit_logs" ADD COLUMN "org_id" TEXT;
ALTER TABLE "audit_logs" ADD COLUMN "user_org_role" TEXT;
-- Backfill org_id
UPDATE "audit_logs"
SET "org_id" = "projects"."org_id"
FROM "projects"
WHERE "audit_logs"."project_id" = "projects"."id";
-- Backfill user_org_role with value from user_project_role
UPDATE "audit_logs"
SET "user_org_role" = "user_project_role"::text::"Role";
-- Drop and recreate user_project_role column as text column going forward, empty for historical data as it's all on org level now
ALTER TABLE "audit_logs" DROP COLUMN "user_project_role";
ALTER TABLE "audit_logs" ADD COLUMN "user_project_role" TEXT; -- nullable
-- Add not null on org level cols
ALTER TABLE "audit_logs" ALTER COLUMN "org_id" SET NOT NULL;
ALTER TABLE "audit_logs" ALTER COLUMN "user_org_role" SET NOT NULL;
-- CreateIndex
CREATE INDEX "audit_logs_user_id_idx" ON "audit_logs"("user_id");
CREATE INDEX "audit_logs_org_id_idx" ON "audit_logs"("org_id");
-- MEMBERSHIP INVITATIONS
-- Rename sender_id to invited_by_user_id
ALTER TABLE "membership_invitations" DROP CONSTRAINT "membership_invitations_sender_id_fkey";
ALTER TABLE "membership_invitations" RENAME COLUMN "sender_id" TO "invited_by_user_id";
ALTER TABLE "membership_invitations" ADD CONSTRAINT "membership_invitations_invited_by_user_id_fkey" FOREIGN KEY ("invited_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- DropForeignKey
ALTER TABLE "membership_invitations" DROP CONSTRAINT "membership_invitations_project_id_fkey";
-- AlterTable
ALTER TABLE "membership_invitations" RENAME COLUMN "role" TO "project_role";
ALTER TABLE "membership_invitations" ADD COLUMN "org_id" TEXT;
ALTER TABLE "membership_invitations" ADD COLUMN "org_role" "Role";
ALTER TABLE "membership_invitations" ALTER COLUMN "project_id" DROP NOT NULL;
ALTER TABLE "membership_invitations" ALTER COLUMN "project_role" DROP NOT NULL;
-- Backfill org id
UPDATE "membership_invitations"
SET "org_id" = "projects"."org_id"
FROM "projects"
WHERE "membership_invitations"."project_id" = "projects"."id";
-- AddForeignKey
ALTER TABLE "membership_invitations" ADD CONSTRAINT "membership_invitations_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Backfill org role with value from project role
UPDATE "membership_invitations"
SET "org_role" = "project_role"::text::"Role";
-- Set project-level cols to null, as it's now org level for all existing invitations and role enum will change below
UPDATE "membership_invitations"
SET "project_role" = NULL, "project_id" = NULL;
-- Switch to new UserRole enum
ALTER TABLE "membership_invitations" DROP COLUMN "project_role",
ADD COLUMN "project_role" "Role";
-- AddForeignKey
ALTER TABLE "membership_invitations" ADD CONSTRAINT "membership_invitations_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Add not null on org level cols after backfill
ALTER TABLE "membership_invitations"
ALTER COLUMN "org_id" SET NOT NULL,
ALTER COLUMN "org_role" SET NOT NULL;
-- CreateIndex
CREATE INDEX "membership_invitations_org_id_idx" ON "membership_invitations"("org_id");
-- Drop ProjectRole enum as it is replaced by Role
DROP TYPE "ProjectRole";
@@ -0,0 +1,7 @@
-- Fix model name
-- https://github.com/langfuse/langfuse/issues/2688
UPDATE models
SET model_name = 'text-embedding-3-large'
WHERE id = 'clruwn76700020al7gp8e4g4l'
@@ -0,0 +1,5 @@
-- DropForeignKey
ALTER TABLE "dataset_items" DROP CONSTRAINT "dataset_items_source_observation_id_fkey";
-- DropForeignKey
ALTER TABLE "dataset_items" DROP CONSTRAINT "dataset_items_source_trace_id_fkey";
+111 -61
View File
@@ -63,21 +63,21 @@ model Session {
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime? @map("email_verified")
password String?
image String?
admin Boolean @default(false)
accounts Account[]
sessions Session[]
projectMemberships ProjectMembership[]
invitations MembershipInvitation[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
featureFlags String[] @default([]) @map("feature_flags")
AuditLog AuditLog[]
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime? @map("email_verified")
password String?
image String?
admin Boolean @default(false)
accounts Account[]
sessions Session[]
organizationMemberships OrganizationMembership[]
projectMemberships ProjectMembership[]
invitations MembershipInvitation[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
featureFlags String[] @default([]) @map("feature_flags")
@@map("users")
}
@@ -91,13 +91,27 @@ model VerificationToken {
@@map("verification_tokens")
}
model Organization {
id String @id @default(cuid())
name String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
cloudConfig Json? @map("cloud_config") // Langfuse Cloud, for zod schema see @/src/features/organizations/utils/cloudConfigSchema
organizationMemberships OrganizationMembership[]
projects Project[]
MembershipInvitation MembershipInvitation[]
@@map("organizations")
}
model Project {
id String @id @default(cuid())
orgId String @map("org_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
name String
cloudConfig Json? @map("cloud_config") // Langfuse Cloud, for zod schema see projectsRouter.ts
projectMembers ProjectMembership[]
organization Organization @relation(fields: [orgId], references: [id], onUpdate: Cascade, onDelete: Cascade)
traces Trace[]
observations Observation[]
apiKeys ApiKey[]
@@ -107,7 +121,6 @@ model Project {
sessions TraceSession[]
Prompt Prompt[]
Model Model[]
AuditLog AuditLog[]
EvalTemplate EvalTemplate[]
JobConfiguration JobConfiguration[]
JobExecution JobExecution[]
@@ -117,6 +130,7 @@ model Project {
scoreConfig ScoreConfig[]
BatchExport BatchExport[]
@@index([orgId])
@@map("projects")
}
@@ -161,41 +175,68 @@ model LlmApiKeys {
@@map("llm_api_keys")
}
model OrganizationMembership {
id String @id @default(cuid())
orgId String @map("org_id")
organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
userId String @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role Role @map("role")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
ProjectMemberships ProjectMembership[]
@@unique([orgId, userId])
@@index([userId])
@@index([orgId])
@@map("organization_memberships")
}
// Set a project-specific role for a user in an organization
model ProjectMembership {
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
userId String @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role ProjectRole
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
orgMembershipId String @map("org_membership_id")
organizationMembership OrganizationMembership @relation(fields: [orgMembershipId], references: [id], onDelete: Cascade)
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
userId String @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role Role
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
@@id([projectId, userId])
@@index([userId])
@@index([projectId])
@@index([orgMembershipId])
@@map("project_memberships")
}
model MembershipInvitation {
id String @id @unique @default(cuid())
email String
role ProjectRole
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
senderId String? @map("sender_id")
sender User? @relation(fields: [senderId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
id String @id @unique @default(cuid())
email String
orgId String @map("org_id")
organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
orgRole Role @map("org_role")
projectId String? @map("project_id")
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
projectRole Role? @map("project_role")
invitedByUserId String? @map("invited_by_user_id")
invitedByUser User? @relation(fields: [invitedByUserId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
@@index([projectId])
@@index([orgId])
@@index([email])
@@map("membership_invitations")
}
enum ProjectRole {
enum Role {
OWNER
ADMIN
MEMBER
VIEWER
NONE
}
model TraceSession {
@@ -238,7 +279,6 @@ model Trace {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
DatasetItem DatasetItem[]
JobExecution JobExecution[]
@@index([projectId])
@@ -323,9 +363,8 @@ model Observation {
calculatedOutputCost Decimal? @map("calculated_output_cost")
calculatedTotalCost Decimal? @map("calculated_total_cost")
completionStartTime DateTime? @map("completion_start_time")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
derivedDatasetItems DatasetItem[]
completionStartTime DateTime? @map("completion_start_time")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
promptId String? @map("prompt_id") // no fk constraint, prompt can be deleted
@@ -376,7 +415,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 +530,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,21 +550,21 @@ 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")
metadata Json?
sourceTraceId String? @map("source_trace_id")
sourceTrace Trace? @relation(fields: [sourceTraceId], references: [id], onDelete: SetNull)
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 +579,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 +599,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)
@@ -643,22 +690,25 @@ model Model {
@@map("models")
}
// No FK constraints to preserve audit logs
model AuditLog {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
userId String @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
userProjectRole ProjectRole @map("user_project_role")
resourceType String @map("resource_type")
resourceId String @map("resource_id")
id String @id @default(cuid())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
userId String @map("user_id")
orgId String @map("org_id")
userOrgRole String @map("user_org_role")
projectId String? @map("project_id")
userProjectRole String? @map("user_project_role")
resourceType String @map("resource_type")
resourceId String @map("resource_id")
action String
before String? //stringified JSON
after String? // stringified JSON
@@index([projectId])
@@index([userId])
@@index([orgId])
@@index([createdAt])
@@index([updatedAt])
@@map("audit_logs")
+119 -16
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;
@@ -33,39 +34,111 @@ async function main() {
options,
}).values.environment;
const seedOrgId = "seed-org-id";
const seedProjectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
const seedUserId1 = "user-1"; // Owner of org
const seedUserId2 = "user-2"; // Member of org, admin of project
const user = await prisma.user.upsert({
where: { id: "user-1" },
where: { id: seedUserId1 },
update: {
name: "Demo User",
email: "demo@langfuse.com",
password: await hash("password", 12),
},
create: {
id: "user-1",
id: seedUserId1,
name: "Demo User",
email: "demo@langfuse.com",
password: await hash("password", 12),
image: "https://static.langfuse.com/langfuse-dev%2Fexample-avatar.png",
},
});
const user2 = await prisma.user.upsert({
where: { id: seedUserId2 },
update: {
name: "Demo User 2",
email: "member@langfuse.com",
password: await hash("password", 12),
},
create: {
id: seedUserId2,
name: "Demo User 2",
email: "member@langfuse.com",
password: await hash("password", 12),
},
});
const seedProjectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
await prisma.organization.upsert({
where: { id: seedOrgId },
update: {
name: "Seed Org",
},
create: {
id: seedOrgId,
name: "Seed Org",
},
});
const project1 = await prisma.project.upsert({
where: { id: seedProjectId },
update: {
name: "llm-app",
orgId: seedOrgId,
},
create: {
id: seedProjectId,
name: "llm-app",
projectMembers: {
create: {
role: "OWNER",
userId: user.id,
},
orgId: seedOrgId,
},
});
const orgMembership = await prisma.organizationMembership.upsert({
where: {
orgId_userId: {
userId: user.id,
orgId: seedOrgId,
},
},
create: {
userId: user.id,
orgId: seedOrgId,
role: "OWNER",
},
update: {},
});
const orgMembership2 = await prisma.organizationMembership.upsert({
where: {
orgId_userId: {
userId: user2.id,
orgId: seedOrgId,
},
},
create: {
userId: user2.id,
orgId: seedOrgId,
role: "MEMBER",
},
update: {},
});
const projectMembership = await prisma.projectMembership.upsert({
where: {
projectId_userId: {
projectId: project1.id,
userId: user2.id,
},
},
create: {
userId: user2.id,
projectId: project1.id,
role: "ADMIN",
orgMembershipId: orgMembership2.id,
},
update: {
orgMembershipId: orgMembership2.id,
},
});
await prisma.prompt.upsert({
@@ -113,18 +186,39 @@ async function main() {
// Do not run the following for local docker compose setup
if (environment === "examples" || environment === "load") {
const project2 = await prisma.project.upsert({
where: { id: "239ad00f-562f-411d-af14-831c75ddd875" },
const seedOrgIdOrg2 = "demo-org-id";
const project2Id = "239ad00f-562f-411d-af14-831c75ddd875";
const org2 = await prisma.organization.upsert({
where: { id: seedOrgIdOrg2 },
update: {
name: "Langfuse Demo",
},
create: {
id: "239ad00f-562f-411d-af14-831c75ddd875",
id: seedOrgIdOrg2,
name: "Langfuse Demo",
},
});
const project2 = await prisma.project.upsert({
where: { id: project2Id },
create: {
id: project2Id,
name: "demo-app",
projectMembers: {
create: {
role: "OWNER",
userId: user.id,
},
orgId: org2.id,
},
update: { orgId: seedOrgIdOrg2 },
});
await prisma.organizationMembership.upsert({
where: {
orgId_userId: {
userId: user.id,
orgId: seedOrgIdOrg2,
},
},
create: {
userId: user.id,
orgId: seedOrgIdOrg2,
role: "VIEWER",
},
update: {},
});
@@ -286,6 +380,7 @@ async function main() {
: undefined;
const datasetItem = await prisma.datasetItem.create({
data: {
projectId: project2.id,
datasetId: dataset.id,
sourceTraceId: sourceObservation?.traceId,
sourceObservationId:
@@ -312,6 +407,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 +432,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 +448,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);
});
@@ -765,6 +866,7 @@ function createObjects(
traceId: trace.id,
source: ScoreSource.API,
projectId: trace.projectId,
timestamp: generationTsEnd,
});
if (Math.random() > 0.6)
scores.push({
@@ -774,6 +876,7 @@ function createObjects(
traceId: trace.id,
source: ScoreSource.API,
projectId: trace.projectId,
timestamp: generationTsEnd,
});
for (let l = 0; l < Math.floor(Math.random() * 2); l++) {
+11 -9
View File
@@ -1,7 +1,7 @@
import crypto from "crypto";
import { env } from "../env";
const ENCRYPTION_KEY: string = env.ENCRYPTION_KEY || ""; // Must be 256 bits (32 bytes, 64 hex characters)
const ENCRYPTION_KEY: string | undefined = env.ENCRYPTION_KEY; // Must be 256 bits (32 bytes, 64 hex characters)
const IV_LENGTH: number = 16; // For AES, this is always 16
// Alternatively: openssl rand -hex 32
@@ -13,17 +13,16 @@ export function keyGen() {
* Encrypts the given plain text using AES-256-GCM algorithm.
*
* @param {string} plainText - The text to encrypt.
* @param {string} [keyHex=ENCRYPTION_KEY] - The encryption key in hexadecimal format.
* @returns {string} The encrypted data in hex format, including IV and authentication tag.
*/
export function encrypt(
plainText: string,
keyHex: string = ENCRYPTION_KEY
): string {
export function encrypt(plainText: string): string {
if (!ENCRYPTION_KEY) {
throw new Error("Missing environment variable: `ENCRYPTION_KEY`");
}
const iv = crypto.randomBytes(IV_LENGTH); // Directly use Buffer returned by randomBytes
const cipher = crypto.createCipheriv(
"aes-256-gcm",
Buffer.from(keyHex, "hex"),
Buffer.from(ENCRYPTION_KEY, "hex"),
iv
);
let encrypted = cipher.update(plainText, "utf8", "hex");
@@ -34,7 +33,10 @@ export function encrypt(
return iv.toString("hex") + ":" + encrypted + ":" + authTag.toString("hex");
}
export function decrypt(text: string, keyHex: string = ENCRYPTION_KEY): string {
export function decrypt(text: string): string {
if (!ENCRYPTION_KEY) {
throw new Error("Missing environment variable: `ENCRYPTION_KEY`");
}
const [ivHex, encryptedHex, authTagHex] = text.split(":");
if (!ivHex || !encryptedHex || !authTagHex) {
throw new Error("Invalid or corrupted cipher format");
@@ -46,7 +48,7 @@ export function decrypt(text: string, keyHex: string = ENCRYPTION_KEY): string {
const decipher = crypto.createDecipheriv(
"aes-256-gcm",
Buffer.from(keyHex, "hex"),
Buffer.from(ENCRYPTION_KEY, "hex"),
iv
);
decipher.setAuthTag(authTag);
+29
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,20 @@ 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(),
LANGFUSE_INGESTION_FLUSH_DELAY_MS: z.coerce
.number()
.nonnegative()
.default(10000),
LANGFUSE_INGESTION_FLUSH_ATTEMPTS: z.coerce.number().positive().default(3),
LANGFUSE_INGESTION_BUFFER_TTL_SECONDS: z.coerce
.number()
.positive()
.default(60 * 10),
});
export const env = EnvSchema.parse(process.env);
@@ -14,7 +14,7 @@ const NumericData = z.object({
});
const CategoricalData = z.object({
value: z.number().optional().nullish(),
value: z.number().nullish(),
stringValue: z.string(),
dataType: z.literal("CATEGORICAL"),
});
@@ -31,7 +31,7 @@ const CreateAnnotationScoreBase = z.object({
traceId: z.string(),
configId: z.string().optional(),
observationId: z.string().optional(),
comment: z.string().optional().nullish(),
comment: z.string().nullish(),
});
const UpdateAnnotationScoreBase = CreateAnnotationScoreBase.extend({
@@ -81,6 +81,7 @@ export const evalModelsByAdapter: Record<LLMAdapter, string[]> = {
[LLMAdapter.OpenAI]: [
"gpt-4o",
"gpt-4o-mini",
"gpt-4o-2024-08-06", // cheaper than gpt-4o
"gpt-4-turbo-preview",
"gpt-3.5-turbo",
] as const,
@@ -1,12 +1,13 @@
import { isPresent } from "@/src/utils/typeChecks";
import { z } from "zod";
import { ScoreConfig as ScoreConfigDbType } from "@prisma/client";
import { isPresent } from "../../utils/typeChecks";
import {
jsonSchema,
paginationMetaResponseZod,
paginationZod,
type ScoreConfig as ScoreConfigDbType,
} from "@langfuse/shared";
import { z } from "zod";
import * as Sentry from "@sentry/node";
} from "../../utils/zod";
/**
* Types to use across codebase
@@ -16,7 +17,7 @@ export type ValidatedScoreConfig = z.infer<typeof ValidatedScoreConfigSchema>;
const validateCategories = (
categories: ConfigCategory[],
ctx: z.RefinementCtx,
ctx: z.RefinementCtx
) => {
const uniqueNames = new Set<string>();
const uniqueValues = new Set<number>();
@@ -53,8 +54,8 @@ export const Category = z.object({
const Categories = z.array(Category);
const NumericScoreConfig = z.object({
maxValue: z.number().optional().nullish(),
minValue: z.number().optional().nullish(),
maxValue: z.number().nullish(),
minValue: z.number().nullish(),
dataType: z.literal("NUMERIC"),
categories: z.undefined().nullish(),
});
@@ -93,7 +94,7 @@ const BooleanScoreConfig = z.object({
return categories.every(
(category, index) =>
category.label === expectedCategories[index].label &&
category.value === expectedCategories[index].value,
category.value === expectedCategories[index].value
);
}),
});
@@ -102,7 +103,7 @@ const ScoreConfigBase = z.object({
id: z.string(),
name: z.string().min(1).max(35),
isArchived: z.boolean(),
description: z.string().optional().nullish(),
description: z.string().nullish(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
projectId: z.string(),
@@ -122,7 +123,7 @@ const ValidatedScoreConfigSchema = z
minValue: z.undefined().nullish(),
dataType: z.literal("CATEGORICAL"),
categories: Categories.superRefine(validateCategories),
}),
})
),
ScoreConfigBase.merge(BooleanScoreConfig),
])
@@ -149,13 +150,14 @@ const ValidatedScoreConfigSchema = z
*/
export const filterAndValidateDbScoreConfigList = (
scoreConfigs: ScoreConfigDbType[],
onParseError?: (error: z.ZodError) => void
): ValidatedScoreConfig[] =>
scoreConfigs.reduce((acc, ts) => {
const result = ValidatedScoreConfigSchema.safeParse(ts);
if (result.success) {
acc.push(result.data);
} else {
Sentry.captureException(result.error);
onParseError?.(result.error);
}
return acc;
}, [] as ValidatedScoreConfig[]);
@@ -168,7 +170,7 @@ export const filterAndValidateDbScoreConfigList = (
* @throws error if score fails validation
*/
export const validateDbScoreConfig = (
scoreConfig: ScoreConfigDbType,
scoreConfig: ScoreConfigDbType
): ValidatedScoreConfig => ValidatedScoreConfigSchema.parse(scoreConfig);
/**
@@ -203,7 +205,7 @@ export const PostScoreConfigBody = z
z.object({
dataType: z.literal("BOOLEAN"),
categories: z.undefined().nullish(),
}),
})
),
])
.superRefine((data, ctx) => {
@@ -1,19 +1,19 @@
import * as Sentry from "@sentry/node";
import {
paginationZod,
paginationMetaResponseZod,
NonEmptyString,
type Score,
stringDateTime,
} from "@langfuse/shared";
import { z } from "zod";
import { isPresent } from "@/src/utils/typeChecks";
import { Category as ConfigCategory } from "./score-configs";
import { Score } from "@prisma/client";
import { isPresent, stringDateTime } from "../../utils/typeChecks";
import {
NonEmptyString,
paginationMetaResponseZod,
paginationZod,
} from "../../utils/zod";
import { Category as ConfigCategory } from "./scoreConfigTypes";
/**
* Types to use across codebase
*/
export type APIScore = z.infer<typeof APIScore>;
export type APIScore = z.infer<typeof APIScoreSchema>;
/**
* Helpers
@@ -31,7 +31,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"),
});
@@ -69,7 +69,7 @@ const BaseScoreBody = z.object({
* Objects
*/
export const APIScore = z.discriminatedUnion("dataType", [
export const APIScoreSchema = z.discriminatedUnion("dataType", [
ScoreBase.merge(NumericData),
ScoreBase.merge(CategoricalData),
ScoreBase.merge(BooleanData),
@@ -83,13 +83,13 @@ export const ScoreBodyWithoutConfig = z.discriminatedUnion("dataType", [
z.object({
value: z.number(),
dataType: z.literal("NUMERIC"),
}),
})
),
BaseScoreBody.merge(
z.object({
value: z.string(),
dataType: z.literal("CATEGORICAL"),
}),
})
),
BaseScoreBody.merge(
z.object({
@@ -97,7 +97,7 @@ export const ScoreBodyWithoutConfig = z.discriminatedUnion("dataType", [
message: "Value must be either 0 or 1",
}),
dataType: z.literal("BOOLEAN"),
}),
})
),
]);
@@ -159,14 +159,17 @@ export const ScorePropsAgainstConfig = z.union([
* @param scores
* @returns list of validated scores
*/
export const filterAndValidateDbScoreList = (scores: Score[]): APIScore[] =>
export const filterAndValidateDbScoreList = (
scores: Score[],
onParseError?: (error: z.ZodError) => void
): APIScore[] =>
scores.reduce((acc, ts) => {
const result = APIScore.safeParse(ts);
const result = APIScoreSchema.safeParse(ts);
if (result.success) {
acc.push(result.data);
} else {
console.error("Score parsing error: ", result.error);
Sentry.captureException(result.error);
onParseError?.(result.error);
}
return acc;
}, [] as APIScore[]);
@@ -179,7 +182,7 @@ export const filterAndValidateDbScoreList = (scores: Score[]): APIScore[] =>
* @throws error if score fails validation
*/
export const validateDbScore = (score: Score): APIScore =>
APIScore.parse(score);
APIScoreSchema.parse(score);
/**
* Endpoints
@@ -189,63 +192,39 @@ export const validateDbScore = (score: Score): APIScore =>
/**
* PostScoresBody is copied for the ingestion API as `ScoreBody`. Please copy any changes here in `packages/shared/src/features/ingestion/types.ts`
*/
export const PostScoresBody = z
.discriminatedUnion("dataType", [
BaseScoreBody.merge(
z.object({
value: z.number(),
dataType: z.literal("NUMERIC"),
configId: z.string().nullish(),
export const PostScoresBody = 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",
}),
),
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}`,
});
}
}
}
});
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(),
})
),
]);
export const PostScoresResponse = z.object({ id: z.string() });
@@ -272,12 +251,12 @@ export const GetScoresQuery = z.object({
// LegacyGetScoreResponseDataV1 is only used for response of GET /scores list endpoint
const LegacyGetScoreResponseDataV1 = z.intersection(
APIScore,
APIScoreSchema,
z.object({
trace: z.object({
userId: z.string().nullish(),
}),
}),
})
);
export const GetScoresResponse = z.object({
data: z.array(LegacyGetScoreResponseDataV1),
@@ -286,6 +265,7 @@ export const GetScoresResponse = z.object({
export const legacyFilterAndValidateV1GetScoreList = (
scores: unknown[],
onParseError?: (error: z.ZodError) => void
): z.infer<typeof LegacyGetScoreResponseDataV1>[] =>
scores.reduce(
(acc: z.infer<typeof LegacyGetScoreResponseDataV1>[], ts) => {
@@ -294,11 +274,11 @@ export const legacyFilterAndValidateV1GetScoreList = (
acc.push(result.data);
} else {
console.error("Score parsing error: ", result.error);
Sentry.captureException(result.error);
onParseError?.(result.error);
}
return acc;
},
[] as z.infer<typeof LegacyGetScoreResponseDataV1>[],
[] as z.infer<typeof LegacyGetScoreResponseDataV1>[]
);
// GET /scores/{scoreId}
@@ -306,7 +286,7 @@ export const GetScoreQuery = z.object({
scoreId: z.string(),
});
export const GetScoreResponse = APIScore;
export const GetScoreResponse = APIScoreSchema;
// DELETE /scores/{scoreId}
export const DeleteScoreQuery = z.object({
+7 -3
View File
@@ -1,6 +1,5 @@
export * from "./constants";
export * from "./queries";
export * from "./queues";
export * from "./interfaces/filters";
export * from "./interfaces/orderBy";
export * from "./tableDefinitions";
@@ -8,11 +7,12 @@ export * from "./types";
export * from "./filterToPrisma";
export * from "./orderByToPrisma";
export * from "./tracesTable";
export * from "./server/auth";
export * from "./server/auth/auth";
export * from "./observationsTable";
export * from "./features/ingestion/types";
export * from "./utils/zod";
export * from "./utils/json";
export * from "./utils/objects";
export * from "./utils/typeChecks";
export { env } from "./env";
// llm api
@@ -26,6 +26,10 @@ export * from "./features/batchExport/types";
// annotation
export * from "./features/annotation/types";
// scores
export * from "./features/scores/scoreConfigTypes";
export * from "./features/scores/scoreTypes";
// export db types only
export * from "@prisma/client";
export { type DB } from "../prisma/generated/types";
+16
View File
@@ -0,0 +1,16 @@
export type AuthHeaderVerificationResult =
| AuthHeaderValidVerificationResult
| {
validKey: false;
error: string;
};
export type AuthHeaderValidVerificationResult = {
validKey: true;
scope: ApiAccessScope;
};
export type ApiAccessScope = {
projectId: string;
accessLevel: "all" | "scores";
};
+16
View File
@@ -0,0 +1,16 @@
import { createClient } from "@clickhouse/client";
import { env } from "../env";
export type ClickhouseClientType = ReturnType<typeof createClient>;
export const clickhouseClient = createClient({
url: env.CLICKHOUSE_URL,
username: env.CLICKHOUSE_USER,
password: env.CLICKHOUSE_PASSWORD,
database: "default",
clickhouse_settings: {
async_insert: 1,
wait_for_async_insert: 1, // if disabled, we won't get errors from clickhouse
},
});
+167
View File
@@ -0,0 +1,167 @@
import z from "zod";
export const clickhouseStringDateSchema = z
.string()
// clickhouse stores UTC like '2024-05-23 18:33:41.602000'
// we need to convert it to '2024-05-23T18:33:41.602000Z'
.transform((str) => str.replace(" ", "T") + "Z")
.pipe(z.string().datetime());
export const observationRecordBaseSchema = z.object({
id: z.string(),
trace_id: z.string().nullish(),
project_id: z.string(),
type: z.string(),
parent_observation_id: z.string().nullish(),
name: z.string().nullish(),
metadata: z.record(z.string()),
level: z.string().nullish(),
status_message: z.string().nullish(),
version: z.string().nullish(),
input: z.string().nullish(),
output: z.string().nullish(),
provided_model_name: z.string().nullish(),
internal_model_id: z.string().nullish(),
model_parameters: z.string().nullish(),
unit: z.string().nullish(),
input_usage_units: z.number().nullish(),
output_usage_units: z.number().nullish(),
total_usage_units: z.number().nullish(),
input_cost: z.number().nullish(),
output_cost: z.number().nullish(),
total_cost: z.number().nullish(),
provided_input_usage_units: z.number().nullish(),
provided_output_usage_units: z.number().nullish(),
provided_total_usage_units: z.number().nullish(),
provided_input_cost: z.number().nullish(),
provided_output_cost: z.number().nullish(),
provided_total_cost: z.number().nullish(),
prompt_id: z.string().nullish(),
prompt_name: z.string().nullish(),
prompt_version: z.number().nullish(),
});
export type ObservationRecordBaseType = z.infer<
typeof observationRecordBaseSchema
>;
export const observationRecordReadSchema = observationRecordBaseSchema.extend({
created_at: clickhouseStringDateSchema,
updated_at: clickhouseStringDateSchema,
start_time: clickhouseStringDateSchema,
end_time: clickhouseStringDateSchema.nullish(),
completion_start_time: clickhouseStringDateSchema.nullish(),
});
export type ObservationRecordReadType = z.infer<
typeof observationRecordReadSchema
>;
export const observationRecordInsertSchema = observationRecordBaseSchema.extend(
{
created_at: z.number(),
updated_at: z.number(),
start_time: z.number(),
end_time: z.number().nullish(),
completion_start_time: z.number().nullish(),
}
);
export type ObservationRecordInsertType = z.infer<
typeof observationRecordInsertSchema
>;
export const traceRecordBaseSchema = z.object({
id: z.string(),
name: z.string().nullish(),
user_id: z.string().nullish(),
metadata: z.record(z.string()),
release: z.string().nullish(),
version: z.string().nullish(),
project_id: z.string(),
public: z.boolean(),
bookmarked: z.boolean(),
tags: z.array(z.string()),
input: z.string().nullish(),
output: z.string().nullish(),
session_id: z.string().nullish(),
});
export type TraceRecordBaseType = z.infer<typeof traceRecordBaseSchema>;
export const traceRecordReadSchema = traceRecordBaseSchema.extend({
timestamp: clickhouseStringDateSchema,
created_at: clickhouseStringDateSchema,
updated_at: clickhouseStringDateSchema,
});
export type TraceRecordReadType = z.infer<typeof traceRecordReadSchema>;
export const traceRecordInsertSchema = traceRecordBaseSchema.extend({
timestamp: z.number(),
created_at: z.number(),
updated_at: z.number(),
});
export type TraceRecordInsertType = z.infer<typeof traceRecordInsertSchema>;
export const scoreRecordBaseSchema = z.object({
id: z.string(),
project_id: z.string(),
trace_id: z.string(),
observation_id: z.string().nullish(),
name: z.string().nullish(),
value: z.union([z.number(), z.string()]).nullish(),
source: z.string(),
comment: z.string().nullish(),
author_user_id: z.string().nullish(),
config_id: z.string().nullish(),
data_type: z.enum(["NUMERIC", "CATEGORICAL", "BOOLEAN"]).nullish(),
string_value: z.string().nullish(),
});
export type ScoreRecordBaseType = z.infer<typeof scoreRecordBaseSchema>;
export const scoreRecordReadSchema = scoreRecordBaseSchema.extend({
created_at: clickhouseStringDateSchema,
updated_at: clickhouseStringDateSchema,
timestamp: clickhouseStringDateSchema,
});
export type ScoreRecordReadType = z.infer<typeof scoreRecordReadSchema>;
export const scoreRecordInsertSchema = scoreRecordBaseSchema.extend({
created_at: z.number(),
updated_at: z.number(),
timestamp: z.number(),
});
export type ScoreRecordInsertType = z.infer<typeof scoreRecordInsertSchema>;
export const convertTraceReadToInsert = (
record: TraceRecordReadType
): TraceRecordInsertType => {
return {
...record,
created_at: new Date(record.created_at).getTime(),
updated_at: new Date(record.created_at).getTime(),
timestamp: new Date(record.timestamp).getTime(),
};
};
export const convertObservationReadToInsert = (
record: ObservationRecordReadType
): ObservationRecordInsertType => {
return {
...record,
created_at: new Date(record.created_at).getTime(),
updated_at: new Date(record.created_at).getTime(),
start_time: new Date(record.start_time).getTime(),
end_time: record.end_time ? new Date(record.end_time).getTime() : undefined,
completion_start_time: record.completion_start_time
? new Date(record.completion_start_time).getTime()
: undefined,
};
};
export const convertScoreReadToInsert = (
record: ScoreRecordReadType
): ScoreRecordInsertType => {
return {
...record,
created_at: new Date(record.created_at).getTime(),
updated_at: new Date(record.updated_at).getTime(),
timestamp: new Date(record.timestamp).getTime(),
};
};
+19 -2
View File
@@ -1,9 +1,26 @@
export * from "./services/S3StorageService";
export * from "./services/email/projectInvitation/sendProjectInvitationEmail";
export * from "./services/email/organizationInvitation/sendMembershipInvitationEmail";
export * from "./services/email/batchExportSuccess/sendBatchExportSuccessEmail";
export * from "./services/email/passwordReset/sendResetPasswordVerificationRequest";
export * from "./auth";
export * from "./services/PromptService";
export * from "./auth/auth";
export * from "./llm/fetchLLMCompletion";
export * from "./llm/types";
export * from "./utils/DatabaseReadStream";
export * from "./utils/transforms";
export * from "./clickhouse";
export * from "../server/definitions";
export * from "../server/ingestion/IngestionUtils";
export * from "../server/ingestion/types";
export * from "../server/ingestion/model-match";
export * from "../server/ingestion/types";
export * from "../server/ingestion/validateAndInflateScore";
export * from "./redis/redis";
export * from "./redis/trace-upsert";
export * from "./redis/batch-export";
export * from "./redis/ingestionFlushQueue";
export * from "./redis/legacy-ingestion";
export * from "./auth/types";
export * from "./ingestion/legacy/index";
export * from "./queues";
export * from "./ingestion/legacy/EventProcessor";
@@ -0,0 +1,86 @@
import { eventTypes, IngestionEventType } from "./types";
const reservedCharsEscapeMap = [
{ reserved: ":", escape: "|%|" },
{ reserved: "_", escape: "|#|" },
];
export enum ClickhouseEntityType {
Trace = "trace",
Score = "score",
Observation = "observation",
SDK_LOG = "sdk-log",
}
export class IngestionUtils {
public static getBufferKey(flushKey: string): string {
const { projectId, eventType, entityId } =
IngestionUtils.parseFlushKey(flushKey);
const sanitizedEntityId = IngestionUtils.escapeReservedChars(entityId);
return (
"ingestionBuffer:" + `${projectId}_${eventType}_${sanitizedEntityId}`
);
}
public static getFlushKey(params: {
projectId: string;
eventType: ClickhouseEntityType;
entityId: string;
batchTimestamp: string;
}): string {
const { projectId, eventType, entityId, batchTimestamp } = params;
const sanitizedEntityId = IngestionUtils.escapeReservedChars(entityId);
return `${projectId}_${eventType}_${sanitizedEntityId}_${batchTimestamp}`;
}
public static parseFlushKey(projectEntityKey: string) {
const split = projectEntityKey.split("_");
if (split.length < 3) {
throw new Error(
`Invalid project entity key format ${projectEntityKey}, expected 3 or 4 parts`
);
}
const [projectId, eventType, escapedEntityId, batchTimestamp] = split;
const entityId = IngestionUtils.unescapeReservedChars(escapedEntityId);
return { projectId, eventType, entityId, batchTimestamp };
}
private static escapeReservedChars(string: string): string {
return reservedCharsEscapeMap.reduce(
(acc, { reserved, escape }) => acc.replaceAll(reserved, escape),
string
);
}
private static unescapeReservedChars(escapedString: string): string {
return reservedCharsEscapeMap.reduce(
(acc, { reserved, escape }) => acc.replaceAll(escape, reserved),
escapedString
);
}
public static getEventType(event: IngestionEventType): ClickhouseEntityType {
switch (event.type) {
case eventTypes.TRACE_CREATE:
return ClickhouseEntityType.Trace;
case eventTypes.OBSERVATION_CREATE:
case eventTypes.OBSERVATION_UPDATE:
case eventTypes.EVENT_CREATE:
case eventTypes.SPAN_CREATE:
case eventTypes.SPAN_UPDATE:
case eventTypes.GENERATION_CREATE:
case eventTypes.GENERATION_UPDATE:
return ClickhouseEntityType.Observation;
case eventTypes.SCORE_CREATE:
return ClickhouseEntityType.Score;
case eventTypes.SDK_LOG:
return ClickhouseEntityType.SDK_LOG;
}
}
}
@@ -1,131 +1,53 @@
import { tokenCount } from "@/src/features/ingest/lib/usage";
import { type ApiAccessScope } from "@/src/features/public-api/server/types";
import {
type legacyObservationCreateEvent,
eventTypes,
type scoreEvent,
type eventCreateEvent,
type spanCreateEvent,
type generationCreateEvent,
type spanUpdateEvent,
type generationUpdateEvent,
type legacyObservationUpdateEvent,
type sdkLogEvent,
type traceEvent,
LangfuseNotFoundError,
InvalidRequestError,
} from "@langfuse/shared";
import { ScoreDataType, prisma } from "@langfuse/shared/src/db";
import { ResourceNotFoundError } from "@/src/utils/exceptions";
import { mergeJson } from "@langfuse/shared";
import {
type Trace,
type Observation,
type Score,
Prisma,
type Model,
} from "@langfuse/shared/src/db";
import { v4 } from "uuid";
import { type z } from "zod";
import { jsonSchema } from "@langfuse/shared";
import { ForbiddenError } from "@langfuse/shared";
import { instrument } from "@/src/utils/instrumentation";
import Decimal from "decimal.js";
import { findModel } from "../model-match";
import {
ScoreBodyWithoutConfig,
ScorePropsAgainstConfig,
} from "@/src/features/public-api/types/scores";
import {
validateDbScoreConfigSafe,
type ValidatedScoreConfig,
} from "@/src/features/public-api/types/score-configs";
ObservationEvent,
eventTypes,
legacyObservationCreateEvent,
generationCreateEvent,
traceEvent,
scoreEvent,
sdkLogEvent,
} from "../types";
import { validateAndInflateScore } from "../validateAndInflateScore";
import { ApiAccessScope } from "../../auth/types";
import { Trace, Observation, Score, Prisma, Model } from "@prisma/client";
import { ForbiddenError, LangfuseNotFoundError } from "../../../errors";
import { mergeJson } from "../../../utils/json";
import { jsonSchema } from "../../../utils/zod";
import { prisma } from "../../../db";
export interface EventProcessor {
auth(apiScope: ApiAccessScope): void;
process(
apiScope: ApiAccessScope,
apiScope: ApiAccessScope
): Promise<Trace | Observation | Score> | undefined;
}
export async function findModel(p: {
event: {
projectId: string;
model?: string;
unit?: string;
startTime?: Date;
};
existingDbObservation?: Observation;
}): Promise<Model | null> {
const { event, existingDbObservation } = p;
// either get the model from the existing observation
// or match pattern on the user provided model name
const modelCondition = event.model
? Prisma.sql`AND ${event.model} ~ match_pattern`
: existingDbObservation?.internalModel
? Prisma.sql`AND model_name = ${existingDbObservation.internalModel}`
: undefined;
if (!modelCondition) return null;
// unit based on the current event or the existing observation, both can be undefined
const mergedUnit = event.unit ?? existingDbObservation?.unit;
const unitCondition = mergedUnit
? Prisma.sql`AND unit = ${mergedUnit}`
: Prisma.empty;
const sql = Prisma.sql`
SELECT
id,
created_at AS "createdAt",
updated_at AS "updatedAt",
project_id AS "projectId",
model_name AS "modelName",
match_pattern AS "matchPattern",
start_date AS "startDate",
input_price AS "inputPrice",
output_price AS "outputPrice",
total_price AS "totalPrice",
unit,
tokenizer_id AS "tokenizerId",
tokenizer_config AS "tokenizerConfig"
FROM
models
WHERE (project_id = ${event.projectId}
OR project_id IS NULL)
${modelCondition}
${unitCondition}
AND (start_date IS NULL OR start_date <= ${
event.startTime ? new Date(event.startTime) : new Date()
}::timestamp with time zone at time zone 'UTC')
ORDER BY
project_id ASC,
start_date DESC NULLS LAST
LIMIT 1
`;
const foundModels = await prisma.$queryRaw<Array<Model>>(sql);
return foundModels[0] ?? null;
}
type ObservationEvent =
| z.infer<typeof legacyObservationCreateEvent>
| z.infer<typeof legacyObservationUpdateEvent>
| z.infer<typeof eventCreateEvent>
| z.infer<typeof spanCreateEvent>
| z.infer<typeof spanUpdateEvent>
| z.infer<typeof generationCreateEvent>
| z.infer<typeof generationUpdateEvent>;
export class ObservationProcessor implements EventProcessor {
event: ObservationEvent;
calculateTokenDelegate: (p: {
model: Model;
text: unknown;
}) => number | undefined;
constructor(event: ObservationEvent) {
constructor(
event: ObservationEvent,
calculateTokenDelegate: (p: {
model: Model;
text: unknown;
}) => number | undefined
) {
this.event = event;
this.calculateTokenDelegate = calculateTokenDelegate;
}
async convertToObservation(
apiScope: ApiAccessScope,
existingObservation: Observation | null,
existingObservation: Observation | null
): Promise<{
id: string;
create: Prisma.ObservationUncheckedCreateInput;
@@ -154,7 +76,9 @@ export class ObservationProcessor implements EventProcessor {
this.event.type === eventTypes.OBSERVATION_UPDATE &&
!existingObservation
) {
throw new ResourceNotFoundError(this.event.id, "Observation not found");
throw new LangfuseNotFoundError(
`Observation with id ${this.event.id} not found`
);
}
// find matching model definition based on event and existing observation in db
@@ -197,8 +121,9 @@ export class ObservationProcessor implements EventProcessor {
"usage" in this.event.body
? this.calculateTokenCounts(
this.event.body,
this.calculateTokenDelegate,
internalModel ?? undefined,
existingObservation ?? undefined,
existingObservation ?? undefined
)
: [undefined, undefined];
@@ -234,7 +159,7 @@ export class ObservationProcessor implements EventProcessor {
const calculatedCosts = ObservationProcessor.calculateTokenCosts(
internalModel,
userProvidedTokenCosts,
tokenCounts,
tokenCounts
);
// merge metadata from existingObservation.metadata and metadata
@@ -242,7 +167,7 @@ export class ObservationProcessor implements EventProcessor {
existingObservation?.metadata
? jsonSchema.parse(existingObservation.metadata)
: undefined,
this.event.body.metadata ?? undefined,
this.event.body.metadata ?? undefined
);
const prompt =
@@ -388,34 +313,34 @@ export class ObservationProcessor implements EventProcessor {
body:
| z.infer<typeof legacyObservationCreateEvent>["body"]
| z.infer<typeof generationCreateEvent>["body"],
calculateTokenDelegate: (p: {
model: Model;
text: unknown;
}) => number | undefined,
model?: Model,
existingObservation?: Observation,
existingObservation?: Observation
) {
return instrument({ name: "calculate-tokens" }, () => {
const newPromptTokens =
body.usage?.input ??
((body.input || existingObservation?.input) &&
model &&
model.tokenizerId
? tokenCount({
model: model,
text: body.input ?? existingObservation?.input,
})
: undefined);
const newPromptTokens =
body.usage?.input ??
((body.input || existingObservation?.input) && model && model.tokenizerId
? calculateTokenDelegate({
model: model,
text: body.input ?? existingObservation?.input,
})
: undefined);
const newCompletionTokens =
body.usage?.output ??
((body.output || existingObservation?.output) &&
model &&
model.tokenizerId
? tokenCount({
model: model,
text: body.output ?? existingObservation?.output,
})
: undefined);
const newCompletionTokens =
body.usage?.output ??
((body.output || existingObservation?.output) &&
model &&
model.tokenizerId
? calculateTokenDelegate({
model: model,
text: body.output ?? existingObservation?.output,
})
: undefined);
return [newPromptTokens, newCompletionTokens];
});
return [newPromptTokens, newCompletionTokens];
}
static calculateTokenCosts(
@@ -425,7 +350,7 @@ export class ObservationProcessor implements EventProcessor {
outputCost?: Decimal | null;
totalCost?: Decimal | null;
},
tokenCounts: { input?: number; output?: number; total?: number },
tokenCounts: { input?: number; output?: number; total?: number }
): {
inputCost?: Decimal | null;
outputCost?: Decimal | null;
@@ -442,7 +367,7 @@ export class ObservationProcessor implements EventProcessor {
totalCost:
userProvidedCosts.totalCost ??
(userProvidedCosts.inputCost ?? new Decimal(0)).add(
userProvidedCosts.outputCost ?? new Decimal(0),
userProvidedCosts.outputCost ?? new Decimal(0)
),
};
}
@@ -473,9 +398,13 @@ export class ObservationProcessor implements EventProcessor {
};
}
async process(apiScope: ApiAccessScope): Promise<Observation> {
auth(apiScope: ApiAccessScope): void {
if (apiScope.accessLevel !== "all")
throw new ForbiddenError("Access denied for observation creation");
}
async process(apiScope: ApiAccessScope): Promise<Observation> {
this.auth(apiScope);
const existingObservation = this.event.body.id
? await prisma.observation.findFirst({
@@ -488,7 +417,7 @@ export class ObservationProcessor implements EventProcessor {
existingObservation.projectId !== apiScope.projectId
) {
throw new ForbiddenError(
`Access denied for observation creation ${existingObservation.projectId} `,
`Access denied for observation creation ${existingObservation.projectId} `
);
}
@@ -512,15 +441,17 @@ export class TraceProcessor implements EventProcessor {
this.event = event;
}
auth(apiScope: ApiAccessScope): void {
if (apiScope.accessLevel !== "all")
throw new ForbiddenError("Access denied for trace creation");
}
async process(
apiScope: ApiAccessScope,
apiScope: ApiAccessScope
): Promise<Trace | Observation | Score> {
const { body } = this.event;
if (apiScope.accessLevel !== "all")
throw new ForbiddenError(
`Access denied for trace creation, ${apiScope.accessLevel}`,
);
this.auth(apiScope);
const internalId = body.id ?? v4();
@@ -528,7 +459,7 @@ export class TraceProcessor implements EventProcessor {
"Trying to create trace, project ",
apiScope.projectId,
", id:",
internalId,
internalId
);
const existingTrace = await prisma.trace.findFirst({
@@ -539,7 +470,7 @@ export class TraceProcessor implements EventProcessor {
if (existingTrace && existingTrace.projectId !== apiScope.projectId) {
throw new ForbiddenError(
`Access denied for trace creation ${existingTrace.projectId}`,
`Access denied for trace creation ${existingTrace.projectId}`
);
}
@@ -547,7 +478,7 @@ export class TraceProcessor implements EventProcessor {
existingTrace?.metadata
? jsonSchema.parse(existingTrace.metadata)
: undefined,
body.metadata ?? undefined,
body.metadata ?? undefined
);
const mergedTags =
@@ -622,154 +553,19 @@ export class ScoreProcessor implements EventProcessor {
this.event = event;
}
static inferDataType(value: string | number): ScoreDataType {
return typeof value === "number"
? ScoreDataType.NUMERIC
: ScoreDataType.CATEGORICAL;
}
static mapStringValueToNumericValue(
config: ValidatedScoreConfig,
label: string,
): number | null {
return (
config.categories?.find((category) => category.label === label)?.value ??
null
);
}
static inflateScoreBody(
body: any,
id: string,
projectId: string,
config?: ValidatedScoreConfig,
): Score {
const relevantDataType = config?.dataType ?? body.dataType;
const scoreProps = { ...body, id, projectId, source: "API" };
if (typeof body.value === "number") {
if (relevantDataType && relevantDataType === ScoreDataType.BOOLEAN) {
return {
...scoreProps,
value: body.value,
stringValue: body.value === 1 ? "True" : "False",
dataType: ScoreDataType.BOOLEAN,
};
}
return {
...scoreProps,
value: body.value,
dataType: ScoreDataType.NUMERIC,
};
}
return {
...scoreProps,
value: config
? ScoreProcessor.mapStringValueToNumericValue(config, body.value)
: null,
stringValue: body.value,
dataType: ScoreDataType.CATEGORICAL,
};
}
validateConfigAgainstBody(body: any, config: ValidatedScoreConfig): void {
const { maxValue, minValue, categories, dataType: configDataType } = config;
if (body.dataType && body.dataType !== configDataType) {
throw new InvalidRequestError(
`Data type mismatch based on config: expected ${configDataType}, got ${body.dataType}`,
auth(apiScope: ApiAccessScope) {
if (apiScope.accessLevel !== "scores" && apiScope.accessLevel !== "all")
throw new ForbiddenError(
`Access denied for score creation, ${apiScope.accessLevel}`
);
}
if (config.isArchived) {
throw new InvalidRequestError(
"Config is archived and cannot be used to create new scores. Please restore the config first.",
);
}
if (config.name !== body.name) {
throw new InvalidRequestError(
`Name mismatch based on config: expected ${config.name}, got ${body.name}`,
);
}
const relevantDataType = configDataType ?? body.dataType;
const dataTypeValidation = ScoreBodyWithoutConfig.safeParse({
...body,
dataType: relevantDataType,
});
if (!dataTypeValidation.success) {
throw new InvalidRequestError(
`Ingested score body not valid against provided config data type.`,
);
}
const rangeValidation = ScorePropsAgainstConfig.safeParse({
value: body.value,
dataType: relevantDataType,
...(maxValue !== null && maxValue !== undefined && { maxValue }),
...(minValue !== null && minValue !== undefined && { minValue }),
...(categories && { categories }),
});
if (!rangeValidation.success) {
const errorDetails = rangeValidation.error.errors
.map((error) => `${error.path.join(".")} - ${error.message}`)
.join(", ");
throw new InvalidRequestError(
`Ingested score body not valid against provided config: ${errorDetails}`,
);
}
}
async validateAndInflate(
body: any,
id: string,
projectId: string,
): Promise<Score> {
if (body.configId) {
const config = await prisma.scoreConfig.findFirst({
where: {
projectId,
id: body.configId,
},
});
if (!config || !validateDbScoreConfigSafe(config).success)
throw new LangfuseNotFoundError(
"The configId you provided does not match a valid config in this project",
);
this.validateConfigAgainstBody(body, config as ValidatedScoreConfig);
return ScoreProcessor.inflateScoreBody(
body,
id,
projectId,
config as ValidatedScoreConfig,
);
} else {
const validation = ScoreBodyWithoutConfig.safeParse({
...body,
dataType: body.dataType ?? ScoreProcessor.inferDataType(body.value),
});
if (!validation.success) {
throw new InvalidRequestError(
`Ingested score value type not valid against provided data type. Provide numeric values for numeric and boolean scores, and string values for categorical scores.`,
);
}
return ScoreProcessor.inflateScoreBody(body, id, projectId);
}
}
async process(
apiScope: ApiAccessScope,
apiScope: ApiAccessScope
): Promise<Trace | Observation | Score> {
const { body } = this.event;
if (apiScope.accessLevel !== "scores" && apiScope.accessLevel !== "all")
throw new ForbiddenError(
`Access denied for score creation, ${apiScope.accessLevel}`,
);
this.auth(apiScope);
const id = body.id ?? v4();
@@ -783,15 +579,15 @@ export class ScoreProcessor implements EventProcessor {
});
if (existingScore && existingScore.projectId !== apiScope.projectId) {
throw new ForbiddenError(
`Access denied for score creation ${existingScore.projectId}`,
`Access denied for score creation ${existingScore.projectId}`
);
}
const validatedScore = await this.validateAndInflate(
const validatedScore = await validateAndInflateScore({
body,
id,
apiScope.projectId,
);
scoreId: id,
projectId: apiScope.projectId,
});
return await prisma.score.upsert({
where: {
@@ -817,6 +613,10 @@ export class SdkLogProcessor implements EventProcessor {
this.event = event;
}
auth(apiScope: ApiAccessScope) {
return;
}
process() {
try {
console.log("SDK Log", this.event);
@@ -0,0 +1,71 @@
import { type Redis } from "ioredis";
import { QueueJobs } from "../../queues";
import {
getIngestionFlushQueue,
IngestionFlushQueue,
} from "../../redis/ingestionFlushQueue";
import { IngestionUtils } from "../IngestionUtils";
import { IngestionEventType } from "../types";
import { redis } from "../../redis/redis";
import { env } from "../../../env";
export async function enqueueIngestionEvents(
projectId: string,
events: IngestionEventType[]
) {
const ingestionFlushQueue = getIngestionFlushQueue();
if (!ingestionFlushQueue) {
throw Error("IngestionFlushQueue not initialized");
}
if (!redis) throw Error("Redis connection not available");
const queuedEventPromises: Promise<void>[] = [];
const batchTimestamp = Date.now().toString();
// Use for loop as TS does not narrow redis type in map function
for (const event of events) {
queuedEventPromises.push(
enqueueSingleIngestionEvent(
projectId,
event,
redis,
ingestionFlushQueue,
batchTimestamp
)
);
}
await Promise.all(queuedEventPromises);
}
async function enqueueSingleIngestionEvent(
projectId: string,
event: IngestionEventType,
redis: Redis,
ingestionFlushQueue: IngestionFlushQueue,
batchTimestamp: string
): Promise<void> {
if (!("id" in event.body && event.body.id)) {
console.warn(
`Received ingestion event without id: ${JSON.stringify(event)}`
);
return;
}
const flushKey = IngestionUtils.getFlushKey({
entityId: event.body.id,
eventType: IngestionUtils.getEventType(event),
projectId,
batchTimestamp,
});
const bufferKey = IngestionUtils.getBufferKey(flushKey);
const serializedEventData = JSON.stringify({ ...event, projectId });
await redis.lpush(bufferKey, serializedEventData);
await redis.expire(bufferKey, env.LANGFUSE_INGESTION_BUFFER_TTL_SECONDS);
await ingestionFlushQueue.add(QueueJobs.FlushIngestionEntity, null, {
jobId: flushKey,
});
}
@@ -0,0 +1,252 @@
import { env } from "node:process";
import z from "zod";
import { ForbiddenError, UnauthorizedError } from "../../../errors";
import { eventTypes, ingestionApiSchema, ingestionEvent } from "../types";
import {
EventProcessor,
TraceProcessor,
ObservationProcessor,
ScoreProcessor,
SdkLogProcessor,
} from "./EventProcessor";
import { EventBodyType, EventName, TraceUpsertEventType } from "../../queues";
import {
convertTraceUpsertEventsToRedisEvents,
getTraceUpsertQueue,
} from "../../redis/trace-upsert";
import { ApiAccessScope, AuthHeaderVerificationResult } from "../../auth/types";
import { redis } from "../../redis/redis";
import { backOff } from "exponential-backoff";
import { Model } from "../../..";
import { enqueueIngestionEvents } from "./enqueueIngestionEvents";
export type BatchResult = {
result: unknown;
id: string;
type: string;
};
type TokenCountInput = {
model: Model;
text: unknown;
};
export const handleBatch = async (
events: z.infer<typeof ingestionApiSchema>["batch"],
authCheck: AuthHeaderVerificationResult,
calculateTokenDelegate: (p: TokenCountInput) => number | undefined
) => {
console.log(`handling ingestion ${events.length} events`);
if (!authCheck.validKey) throw new UnauthorizedError(authCheck.error);
const results: BatchResult[] = []; // Array to store the results
const errors: {
error: unknown;
id: string;
type: string;
}[] = []; // Array to store the errors
for (const singleEvent of events) {
try {
const result = await retry(async () => {
return await handleSingleEvent(
singleEvent,
authCheck.scope,
calculateTokenDelegate
);
});
results.push({
result: result,
id: singleEvent.id,
type: singleEvent.type,
}); // Push each result into the array
} catch (error) {
// Handle or log the error if `handleSingleEvent` fails
console.error("Error handling event:", error);
// Decide how to handle the error: rethrow, continue, or push an error object to results
// For example, push an error object:
errors.push({
error: error,
id: singleEvent.id,
type: singleEvent.type,
});
}
}
if (env.CLICKHOUSE_URL) {
try {
await enqueueIngestionEvents(authCheck.scope.projectId, events);
console.log(`Added ${events.length} ingestion events to queue`);
} catch (err) {
console.error("Error adding ingestion events to queue", err);
}
}
return { results, errors };
};
async function retry<T>(request: () => Promise<T>): Promise<T> {
return await backOff(request, {
numOfAttempts: env.LANGFUSE_ASYNC_INGESTION_PROCESSING === "true" ? 5 : 3,
retry: (e: Error, attemptNumber: number) => {
if (e instanceof UnauthorizedError || e instanceof ForbiddenError) {
console.log("not retrying auth error");
return false;
}
console.log(`retrying processing events ${attemptNumber}`);
return true;
},
});
}
const handleSingleEvent = async (
event: z.infer<typeof ingestionEvent>,
apiScope: ApiAccessScope,
calculateTokenDelegate: (p: {
model: Model;
text: unknown;
}) => number | undefined
) => {
const { body } = event;
let restEvent = body;
if ("input" in body) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { input, ...rest } = body;
restEvent = rest;
}
if ("output" in restEvent) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { output, ...rest } = restEvent;
restEvent = rest;
}
console.log(
`handling single event ${event.id} of type ${event.type}: ${JSON.stringify({ body: restEvent })}`
);
const cleanedEvent = ingestionEvent.parse(cleanEvent(event));
const { type } = cleanedEvent;
let processor: EventProcessor;
switch (type) {
case eventTypes.TRACE_CREATE:
processor = new TraceProcessor(cleanedEvent);
break;
case eventTypes.OBSERVATION_CREATE:
case eventTypes.OBSERVATION_UPDATE:
case eventTypes.EVENT_CREATE:
case eventTypes.SPAN_CREATE:
case eventTypes.SPAN_UPDATE:
case eventTypes.GENERATION_CREATE:
case eventTypes.GENERATION_UPDATE:
processor = new ObservationProcessor(
cleanedEvent,
calculateTokenDelegate
);
break;
case eventTypes.SCORE_CREATE: {
processor = new ScoreProcessor(cleanedEvent);
break;
}
case eventTypes.SDK_LOG:
processor = new SdkLogProcessor(cleanedEvent);
}
// Deny access to non-score events if the access level is not "all"
// This is an additional safeguard to auth checks in EventProcessor
if (apiScope.accessLevel !== "all" && type !== eventTypes.SCORE_CREATE) {
throw new ForbiddenError("Access denied. Event type not allowed.");
}
return await processor.process(apiScope);
};
// cleans NULL characters from the event
export function cleanEvent(obj: unknown): unknown {
if (typeof obj === "string") {
return obj.replace(/\u0000/g, "");
} else if (typeof obj === "object" && obj !== null) {
if (Array.isArray(obj)) {
return obj.map(cleanEvent);
} else {
// Here we assert that obj is a Record<string, unknown>
const objAsRecord = obj as Record<string, unknown>;
const newObj: Record<string, unknown> = {};
for (const key in objAsRecord) {
newObj[key] = cleanEvent(objAsRecord[key]);
}
return newObj;
}
} else {
return obj;
}
}
export const isNotNullOrUndefined = <T>(
val?: T | null
): val is Exclude<T, null | undefined> => !isUndefinedOrNull(val);
export const isUndefinedOrNull = <T>(val?: T | null): val is undefined | null =>
val === undefined || val === null;
export const sendToWorkerIfEnvironmentConfigured = async (
batchResults: BatchResult[],
projectId: string
): Promise<void> => {
const traceEvents: TraceUpsertEventType[] = batchResults
.filter((result) => result.type === eventTypes.TRACE_CREATE) // we only have create, no update.
.map((result) =>
result.result &&
typeof result.result === "object" &&
"id" in result.result
? // ingestion API only gets traces for one projectId
{ traceId: result.result.id as string, projectId }
: null
)
.filter(isNotNullOrUndefined);
try {
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION && redis) {
console.log(`Sending ${traceEvents.length} events to worker via Redis`);
const queue = getTraceUpsertQueue();
if (!queue) {
console.error("TraceUpsertQueue not initialized");
return;
}
await queue.addBulk(convertTraceUpsertEventsToRedisEvents(traceEvents));
} else if (
env.LANGFUSE_WORKER_HOST &&
env.LANGFUSE_WORKER_PASSWORD &&
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
) {
console.log(`Sending ${traceEvents.length} events to worker via HTTP`);
const body: EventBodyType = {
name: EventName.TraceUpsert,
payload: traceEvents,
};
if (traceEvents.length > 0) {
await fetch(`${env.LANGFUSE_WORKER_HOST}/api/events`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization:
"Basic " +
Buffer.from(
"admin" + ":" + env.LANGFUSE_WORKER_PASSWORD
).toString("base64"),
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(8 * 1000),
});
}
}
} catch (error) {
console.error("Error sending events to worker", error);
}
};
@@ -0,0 +1,63 @@
import { Model, Observation, Prisma } from "@prisma/client";
import { prisma } from "../../db";
export async function findModel(p: {
event: {
projectId: string;
model?: string;
unit?: string;
startTime?: Date;
};
existingDbObservation?: Observation;
}): Promise<Model | null> {
const { event, existingDbObservation } = p;
// either get the model from the existing observation
// or match pattern on the user provided model name
const modelCondition = event.model
? Prisma.sql`AND ${event.model} ~ match_pattern`
: existingDbObservation?.internalModel
? Prisma.sql`AND model_name = ${existingDbObservation.internalModel}`
: undefined;
if (!modelCondition) return null;
// unit based on the current event or the existing observation, both can be undefined
const mergedUnit = event.unit ?? existingDbObservation?.unit;
const unitCondition = mergedUnit
? Prisma.sql`AND unit = ${mergedUnit}`
: Prisma.empty;
const sql = Prisma.sql`
SELECT
id,
created_at AS "createdAt",
updated_at AS "updatedAt",
project_id AS "projectId",
model_name AS "modelName",
match_pattern AS "matchPattern",
start_date AS "startDate",
input_price AS "inputPrice",
output_price AS "outputPrice",
total_price AS "totalPrice",
unit,
tokenizer_id AS "tokenizerId",
tokenizer_config AS "tokenizerConfig"
FROM
models
WHERE (project_id = ${event.projectId}
OR project_id IS NULL)
${modelCondition}
${unitCondition}
AND (start_date IS NULL OR start_date <= ${
event.startTime ? new Date(event.startTime) : new Date()
}::timestamp with time zone at time zone 'UTC')
ORDER BY
project_id ASC,
start_date DESC NULLS LAST
LIMIT 1
`;
const foundModels = await prisma.$queryRaw<Array<Model>>(sql);
return foundModels[0] ?? null;
}
@@ -168,63 +168,39 @@ const BaseScoreBody = z.object({
/**
* ScoreBody exactly mirrors `PostScoresBody` in the public API. Please refer there for source of truth.
*/
export const ScoreBody = z
.discriminatedUnion("dataType", [
BaseScoreBody.merge(
z.object({
value: z.number(),
dataType: z.literal("NUMERIC"),
configId: z.string().nullish(),
})
),
BaseScoreBody.merge(
z.object({
value: z.string(),
dataType: z.literal("CATEGORICAL"),
configId: z.string().nullish(),
})
),
BaseScoreBody.merge(
z.object({
value: z.number().refine((val) => val === 0 || val === 1, {
message: "Value must be either 0 or 1",
}),
dataType: z.literal("BOOLEAN"),
configId: z.string().nullish(),
})
),
BaseScoreBody.merge(
z.object({
value: z.union([z.string(), z.number()]),
dataType: z.undefined(),
configId: z.string().nullish(),
})
),
])
.superRefine((data, ctx) => {
if (data.dataType) {
if (typeof data.value === "number") {
if (data.dataType === "CATEGORICAL") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Value must be a string for data type ${data.dataType}`,
});
}
} else if (typeof data.value === "string") {
if (data.dataType === "NUMERIC") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Value must be a number for data type ${data.dataType}`,
});
} else if (data.dataType === "BOOLEAN") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Value must number equal to either 0 or 1 for data type ${data.dataType}`,
});
}
}
}
});
export const ScoreBody = z.discriminatedUnion("dataType", [
BaseScoreBody.merge(
z.object({
value: z.number(),
dataType: z.literal("NUMERIC"),
configId: z.string().nullish(),
})
),
BaseScoreBody.merge(
z.object({
value: z.string(),
dataType: z.literal("CATEGORICAL"),
configId: z.string().nullish(),
})
),
BaseScoreBody.merge(
z.object({
value: z.number().refine((value) => value === 0 || value === 1, {
message:
"Value must be a number equal to either 0 or 1 for data type BOOLEAN",
}),
dataType: z.literal("BOOLEAN"),
configId: z.string().nullish(),
})
),
BaseScoreBody.merge(
z.object({
value: z.union([z.string(), z.number()]),
dataType: z.undefined(),
configId: z.string().nullish(),
})
),
]);
// LEGACY, only required for backwards compatibility
export const LegacySpanPostSchema = z.object({
@@ -334,6 +310,16 @@ export const SdkLogEvent = z.object({
// definitions for the ingestion API
export const observationTypes = [
"observation-create",
"observation-update",
"generation-create",
"generation-update",
"span-create",
"span-update",
"event-create",
];
export const eventTypes = {
TRACE_CREATE: "trace-create",
SCORE_CREATE: "score-create",
@@ -343,7 +329,6 @@ export const eventTypes = {
GENERATION_CREATE: "generation-create",
GENERATION_UPDATE: "generation-update",
SDK_LOG: "sdk-log",
// LEGACY, only required for backwards compatibility
OBSERVATION_CREATE: "observation-create",
OBSERVATION_UPDATE: "observation-update",
@@ -358,6 +343,7 @@ export const traceEvent = base.extend({
type: z.literal(eventTypes.TRACE_CREATE),
body: TraceBody,
});
export type TraceEventType = z.infer<typeof traceEvent>;
export const eventCreateEvent = base.extend({
type: z.literal(eventTypes.EVENT_CREATE),
@@ -383,6 +369,7 @@ export const scoreEvent = base.extend({
type: z.literal(eventTypes.SCORE_CREATE),
body: ScoreBody,
});
export type ScoreEventType = z.infer<typeof scoreEvent>;
export const sdkLogEvent = base.extend({
type: z.literal(eventTypes.SDK_LOG),
body: SdkLogEvent,
@@ -409,10 +396,35 @@ export const ingestionEvent = z.discriminatedUnion("type", [
legacyObservationCreateEvent,
legacyObservationUpdateEvent,
]);
export type IngestionEventType = z.infer<typeof ingestionEvent>;
export const ingestionBatchEvent = z.array(ingestionEvent);
export type IngestionBatchEventType = z.infer<typeof ingestionBatchEvent>;
export const ingestionEventWithProjectId = ingestionEvent.and(
z.object({ projectId: z.string() })
);
export type IngestionEventWithProjectIdType = z.infer<
typeof ingestionEventWithProjectId
>;
export const ingestionApiSchema = z.object({
batch: ingestionBatchEvent,
metadata: jsonSchema.nullish(),
});
export const ingestionApiSchemaWithProjectId = ingestionApiSchema.extend({
projectId: z.string(),
});
export type IngestionApiSchemaWithProjectId = z.infer<
typeof ingestionApiSchemaWithProjectId
>;
export type ObservationEvent =
| z.infer<typeof legacyObservationCreateEvent>
| z.infer<typeof legacyObservationUpdateEvent>
| z.infer<typeof eventCreateEvent>
| z.infer<typeof spanCreateEvent>
| z.infer<typeof spanUpdateEvent>
| z.infer<typeof generationCreateEvent>
| z.infer<typeof generationUpdateEvent>;
@@ -0,0 +1,160 @@
import {
ScoreBodyWithoutConfig,
ScorePropsAgainstConfig,
validateDbScoreConfigSafe,
ValidatedScoreConfig,
} from "../../../src";
import { prisma, Score, ScoreDataType } from "../../db";
import { InvalidRequestError, LangfuseNotFoundError } from "../../errors";
type ValidateAndInflateScoreParams = {
projectId: string;
scoreId: string;
body: any;
};
export async function validateAndInflateScore(
params: ValidateAndInflateScoreParams
): Promise<Score> {
const { body, projectId } = params;
if (body.configId) {
const config = await prisma.scoreConfig.findFirst({
where: {
projectId,
id: body.configId,
},
});
if (!config || !validateDbScoreConfigSafe(config).success)
throw new LangfuseNotFoundError(
"The configId you provided does not match a valid config in this project"
);
validateConfigAgainstBody(body, config as ValidatedScoreConfig);
return inflateScoreBody({
...params,
config: config as ValidatedScoreConfig,
});
}
const validation = ScoreBodyWithoutConfig.safeParse({
...body,
dataType: body.dataType ?? inferDataType(body.value),
});
if (!validation.success) {
throw new InvalidRequestError(
`Ingested score value type not valid against provided data type. Provide numeric values for numeric and boolean scores, and string values for categorical scores.`
);
}
return inflateScoreBody(params);
}
function inferDataType(value: string | number): ScoreDataType {
return typeof value === "number"
? ScoreDataType.NUMERIC
: ScoreDataType.CATEGORICAL;
}
function mapStringValueToNumericValue(
config: ValidatedScoreConfig,
label: string
): number | null {
return (
config.categories?.find((category) => category.label === label)?.value ??
null
);
}
function inflateScoreBody(
params: ValidateAndInflateScoreParams & { config?: ValidatedScoreConfig }
): Score {
const { body, projectId, scoreId, config } = params;
const relevantDataType = config?.dataType ?? body.dataType;
const scoreProps = { ...body, id: scoreId, projectId, source: "API" };
if (typeof body.value === "number") {
if (relevantDataType && relevantDataType === ScoreDataType.BOOLEAN) {
return {
...scoreProps,
value: body.value,
stringValue: body.value === 1 ? "True" : "False",
dataType: ScoreDataType.BOOLEAN,
};
}
return {
...scoreProps,
value: body.value,
dataType: ScoreDataType.NUMERIC,
};
}
return {
...scoreProps,
value: config ? mapStringValueToNumericValue(config, body.value) : null,
stringValue: body.value,
dataType: ScoreDataType.CATEGORICAL,
};
}
function validateConfigAgainstBody(
body: any,
config: ValidatedScoreConfig
): void {
const { maxValue, minValue, categories, dataType: configDataType } = config;
if (body.dataType && body.dataType !== configDataType) {
throw new InvalidRequestError(
`Data type mismatch based on config: expected ${configDataType}, got ${body.dataType}`
);
}
if (config.isArchived) {
throw new InvalidRequestError(
"Config is archived and cannot be used to create new scores. Please restore the config first."
);
}
if (config.name !== body.name) {
throw new InvalidRequestError(
`Name mismatch based on config: expected ${config.name}, got ${body.name}`
);
}
const relevantDataType = configDataType ?? body.dataType;
const dataTypeValidation = ScoreBodyWithoutConfig.safeParse({
...body,
dataType: relevantDataType,
});
if (!dataTypeValidation.success) {
throw new InvalidRequestError(
`Ingested score body not valid against provided config data type.`
);
}
const rangeValidation = ScorePropsAgainstConfig.safeParse({
value: body.value,
dataType: relevantDataType,
...(maxValue !== null && maxValue !== undefined && { maxValue }),
...(minValue !== null && minValue !== undefined && { minValue }),
...(categories && { categories }),
});
if (!rangeValidation.success) {
const errorDetails = rangeValidation.error.errors
.map((error) => `${error.path.join(".")} - ${error.message}`)
.join(", ");
throw new InvalidRequestError(
`Ingested score body not valid against provided config: ${errorDetails}`
);
}
}
+3
View File
@@ -45,8 +45,10 @@ export const ZodModelConfig = z.object({
top_p: z.coerce.number().optional(),
});
// NOTE: Update docs page when changing this!
export const openAIModels = [
"gpt-4o",
"gpt-4o-2024-08-06",
"gpt-4o-2024-05-13",
"gpt-4o-mini",
"gpt-4o-mini-2024-07-18",
@@ -66,6 +68,7 @@ export const openAIModels = [
export type OpenAIModel = (typeof openAIModels)[number];
// NOTE: Update docs page when changing this!
export const anthropicModels = [
"claude-3-5-sonnet-20240620",
"claude-3-opus-20240229",
@@ -1,11 +1,24 @@
import { z } from "zod";
import { ingestionBatchEvent } from ".";
export enum EventName {
TraceUpsert = "TraceUpsert",
BatchExport = "BatchExport",
EvaluationExecution = "EvaluationExecution",
LegacyIngestion = "LegacyIngestion",
}
export const LegacyIngestionEvent = z.object({
data: ingestionBatchEvent,
authCheck: z.object({
validKey: z.literal(true),
scope: z.object({
projectId: z.string(),
accessLevel: z.enum(["all", "scores"]),
}),
}),
});
export const BatchExportJobSchema = z.object({
projectId: z.string(),
batchExportId: z.string(),
@@ -22,6 +35,7 @@ export const EvalExecutionEvent = z.object({
export type BatchExportJobType = z.infer<typeof BatchExportJobSchema>;
export type TraceUpsertEventType = z.infer<typeof TraceUpsertEventSchema>;
export type EvalExecutionEventType = z.infer<typeof EvalExecutionEvent>;
export type LegacyIngestionEventType = z.infer<typeof LegacyIngestionEvent>;
export const EventBodySchema = z.union([
z.object({
@@ -44,6 +58,8 @@ export enum QueueName {
EvaluationExecution = "evaluation-execution-queue", // Worker executes Evals
BatchExport = "batch-export-queue",
RepeatQueue = "repeat-queue",
IngestionFlushQueue = "ingestion-flush-queue",
LegacyIngestionQueue = "legacy-ingestion-queue",
}
export enum QueueJobs {
@@ -51,6 +67,8 @@ export enum QueueJobs {
EvaluationExecution = "evaluation-execution-job",
BatchExportJob = "batch-export-job",
EnqueueBatchExportJobs = "enqueue-batch-export-jobs",
FlushIngestionEntity = "flush-ingestion-entity",
LegacyIngestionJob = "legacy-ingestion-job",
}
export type TQueueJobTypes = {
@@ -72,4 +90,10 @@ export type TQueueJobTypes = {
payload: BatchExportJobType;
name: QueueJobs.BatchExportJob;
};
[QueueName.LegacyIngestionQueue]: {
timestamp: Date;
id: string;
payload: LegacyIngestionEventType;
name: QueueJobs.LegacyIngestionJob;
};
};
@@ -0,0 +1,23 @@
import { Queue } from "bullmq";
import { QueueName, TQueueJobTypes } from "../queues";
import { redis } from "./redis";
let batchExportQueue: Queue<TQueueJobTypes[QueueName.BatchExport]> | null =
null;
export const getBatchExportQueue = () => {
if (batchExportQueue) return batchExportQueue;
batchExportQueue = redis
? new Queue<TQueueJobTypes[QueueName.BatchExport]>(QueueName.BatchExport, {
connection: redis,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
attempts: 2,
},
})
: null;
return batchExportQueue;
};
@@ -0,0 +1,27 @@
import { Queue } from "bullmq";
import { env } from "../../env";
import { redis } from "../redis/redis";
import { QueueName } from "../queues";
export type IngestionFlushQueue = Queue<null>;
let ingestionFlushQueue: IngestionFlushQueue | null = null;
export const getIngestionFlushQueue = () => {
if (ingestionFlushQueue) return ingestionFlushQueue;
ingestionFlushQueue = redis
? new Queue<null>(QueueName.IngestionFlushQueue, {
connection: redis,
defaultJobOptions: {
removeOnComplete: true, // Important: If not true, new jobs for that ID would be ignored as jobs in the complete set are still considered as part of the queue
removeOnFail: 1000,
delay: env.LANGFUSE_INGESTION_FLUSH_DELAY_MS,
attempts: env.LANGFUSE_INGESTION_FLUSH_ATTEMPTS,
},
})
: null;
return ingestionFlushQueue;
};
@@ -0,0 +1,27 @@
import { Queue } from "bullmq";
import { QueueName, TQueueJobTypes } from "../queues";
import { redis } from "./redis";
let legacyIngestionQueue: Queue<
TQueueJobTypes[QueueName.LegacyIngestionQueue]
> | null = null;
export const getLegacyIngestionQueue = () => {
if (legacyIngestionQueue) return legacyIngestionQueue;
legacyIngestionQueue = redis
? new Queue<TQueueJobTypes[QueueName.LegacyIngestionQueue]>(
QueueName.LegacyIngestionQueue,
{
connection: redis,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
attempts: 5,
},
}
)
: null;
return legacyIngestionQueue;
};
@@ -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,245 @@
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;
}
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",
}
@@ -15,25 +15,25 @@ import {
Text,
} from "@react-email/components";
interface ProjectInvitationTemplateProps {
interface MembershipInvitationTemplateProps {
invitedByUsername: string;
invitedByUserEmail: string;
projectName: string;
orgName: string;
receiverEmail: string;
inviteLink: string;
emailFromAddress: string;
langfuseCloudRegion?: string;
}
export const ProjectInvitationTemplate = ({
export const MembershipInvitationTemplate = ({
invitedByUsername,
invitedByUserEmail,
projectName,
orgName,
receiverEmail,
inviteLink,
emailFromAddress,
langfuseCloudRegion,
}: ProjectInvitationTemplateProps) => {
}: MembershipInvitationTemplateProps) => {
const previewText = `Join ${invitedByUsername} on Langfuse`;
return (
@@ -53,7 +53,7 @@ export const ProjectInvitationTemplate = ({
/>
</Section>
<Heading className="mx-0 my-[30px] p-0 text-center text-2xl font-normal text-black">
Join <strong>{projectName}</strong> on <strong>Langfuse</strong>
Join <strong>{orgName}</strong> on <strong>Langfuse</strong>
</Heading>
<Text className="text-sm leading-6 text-black">Hello,</Text>
<Text className="text-sm leading-6 text-black">
@@ -64,7 +64,8 @@ export const ProjectInvitationTemplate = ({
>
{invitedByUserEmail}
</Link>
) has invited you to the <strong>{projectName}</strong> project on
) has invited you to join the <strong>{orgName}</strong>{" "}
organization on
{langfuseCloudRegion
? ` Langfuse (${langfuseCloudRegion} data region)`
: " Langfuse"}
@@ -78,7 +79,7 @@ export const ProjectInvitationTemplate = ({
Accept Invitation
</Button>
<Text className="mt-2 text-xs leading-3 text-muted-foreground">
(you need to create an account)
(you need to create an account with this email address)
</Text>
</Section>
<Text className="text-sm leading-6 text-black">
@@ -102,4 +103,4 @@ export const ProjectInvitationTemplate = ({
);
};
export default ProjectInvitationTemplate;
export default MembershipInvitationTemplate;
@@ -2,7 +2,7 @@ import { createTransport } from "nodemailer";
import { parseConnectionUrl } from "nodemailer/lib/shared/index.js";
import { render } from "@react-email/render";
import ProjectInvitationTemplate from "./ProjectInvitationEmailTemplate";
import MembershipInvitationTemplate from "./MembershipInvitationEmailTemplate";
const langfuseUrls = {
US: "https://us.cloud.langfuse.com",
@@ -10,7 +10,7 @@ const langfuseUrls = {
STAGING: "https://staging.langfuse.com",
};
type SendProjectInvitationParams = {
type SendMembershipInvitationParams = {
env: Partial<
Record<
| "EMAIL_FROM_ADDRESS"
@@ -23,19 +23,19 @@ type SendProjectInvitationParams = {
to: string;
inviterName: string;
inviterEmail: string;
projectName: string;
orgName: string;
};
export const sendProjectInvitationEmail = async ({
export const sendMembershipInvitationEmail = async ({
env,
to,
inviterName,
inviterEmail,
projectName,
}: SendProjectInvitationParams) => {
orgName,
}: SendMembershipInvitationParams) => {
if (!env.EMAIL_FROM_ADDRESS || !env.SMTP_CONNECTION_URL) {
console.error(
"Missing environment variables for sending project invitation email."
"Missing environment variables for sending membership invitation email."
);
return;
}
@@ -59,10 +59,10 @@ export const sendProjectInvitationEmail = async ({
const mailer = createTransport(parseConnectionUrl(env.SMTP_CONNECTION_URL));
const htmlTemplate = render(
ProjectInvitationTemplate({
MembershipInvitationTemplate({
invitedByUsername: inviterName,
invitedByUserEmail: inviterEmail,
projectName: projectName,
orgName: orgName,
receiverEmail: to,
inviteLink: authUrl,
emailFromAddress: env.EMAIL_FROM_ADDRESS,
@@ -72,11 +72,8 @@ export const sendProjectInvitationEmail = async ({
await mailer.sendMail({
to,
from: {
address: env.EMAIL_FROM_ADDRESS,
name: "Langfuse",
},
subject: `${inviterName} invited you to join "${projectName}"`,
from: `Langfuse <${env.EMAIL_FROM_ADDRESS}>`,
subject: `${inviterName} invited you to join "${orgName}" organization on Langfuse`,
html: htmlTemplate,
});
} catch (error) {
+51 -2
View File
@@ -1,6 +1,6 @@
import { type z } from "zod";
import { z } from "zod";
import lodash from "lodash";
import { jsonSchema } from "./zod";
import { JsonNested, jsonSchema, jsonSchemaNullable } from "./zod";
export const parseJson = (input: string) => {
try {
@@ -57,3 +57,52 @@ export const mergeJson = (
}
return lodash.merge(json1, json2);
};
export const parseJsonPrioritised = (
json: string
): z.infer<typeof jsonSchema> | string | undefined => {
try {
const parsedJson = JSON.parse(json);
if (Object.keys(parsedJson).length === 0) {
return parsedJson;
}
const parsedArray = z.array(jsonSchemaNullable).safeParse(parsedJson);
if (parsedArray.success) {
return parsedArray.data;
}
const parsedObject = z.record(jsonSchemaNullable).safeParse(parsedJson);
if (parsedObject.success) {
return parsedObject.data;
}
return jsonSchema.parse(parsedJson);
} catch (error) {
const parsed = jsonSchema.safeParse(json);
return parsed.success ? parsed.data : json;
}
};
export const convertRecordToJsonSchema = (
record: Record<string, string>
): JsonNested | undefined => {
const jsonSchema: JsonNested = {};
// if record is empty, return undefined
if (Object.keys(record).length === 0) {
return undefined;
}
for (const key in record) {
try {
jsonSchema[key] = JSON.parse(record[key]);
} catch (e) {
jsonSchema[key] = record[key];
}
}
return jsonSchema;
};
+16
View File
@@ -0,0 +1,16 @@
type OmitKeys<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
/**
* Removes specified keys from an object and returns a new object without those keys.
*/
export function removeObjectKeys<T, K extends keyof T>(
obj: T,
keys: K[]
): OmitKeys<T, K> {
const result = { ...obj };
for (const key of keys) {
delete result[key];
}
return result;
}
@@ -1,2 +1,6 @@
import { z } from "zod";
export const isPresent = <T>(value: T | null | undefined): value is T =>
value !== null && value !== undefined && value !== "";
export const stringDateTime = z.string().datetime({ offset: true }).nullish();
+2 -2
View File
@@ -17,11 +17,11 @@ const nestedLiteralSchema = z.union([
type Root = z.infer<typeof rootLiteralSchema>;
type Literal = z.infer<typeof nestedLiteralSchema>;
type JsonNested = Literal | { [key: string]: JsonNested } | JsonNested[];
export type JsonNested = Literal | { [key: string]: JsonNested } | JsonNested[];
type Json = Root | { [key: string]: JsonNested } | JsonNested[];
// Here, you define the schema recursively
const jsonSchemaNullable: z.ZodType<JsonNested> = z.lazy(() =>
export const jsonSchemaNullable: z.ZodType<JsonNested> = z.lazy(() =>
z.union([
nestedLiteralSchema,
z.array(jsonSchemaNullable),
+1 -1
View File
@@ -4,7 +4,7 @@
"jsx": "react",
"moduleResolution": "NodeNext",
"module": "NodeNext",
"lib": ["ES2020"],
"lib": ["ES2021"],
"outDir": "./dist",
"types": ["node"],
"target": "ES2020",
+1766 -1598
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -34,13 +34,13 @@ ENV NEXT_MANUAL_SIG_HANDLE true
# pass public variables in build step
ARG NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
ARG NEXT_PUBLIC_DEMO_ORG_ID
ARG NEXT_PUBLIC_DEMO_PROJECT_ID
ARG NEXT_PUBLIC_SIGN_UP_DISABLED
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
ARG NEXT_PUBLIC_POSTHOG_KEY
ARG NEXT_PUBLIC_POSTHOG_HOST
ARG NEXT_PUBLIC_CRISP_WEBSITE_ID
ARG LANFUSE_WEB_MIGRATION_DISABLED
# Copy source code of isolated subworkspace
COPY --from=pruner /app/out/full/ .
@@ -74,7 +74,7 @@ RUN apk add --no-cache dumb-init
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN npm install -g --no-package-lock --no-save prisma
RUN npm install -g --no-package-lock --no-save prisma@5.13.0
COPY --from=builder --chown=nextjs:nodejs /app/web/next.config.mjs .
COPY --from=builder --chown=nextjs:nodejs /app/web/package.json .
+6
View File
@@ -16,6 +16,8 @@ const serverTestConfig = {
displayName: "server",
testMatch: ["/**/*.servertest.[jt]s?(x)"],
testEnvironment: "jest-environment-node",
setupFilesAfterEnv: ["<rootDir>/src/__tests__/after-teardown.ts"],
globalTeardown: "<rootDir>/src/__tests__/teardown.ts",
};
// To avoid the "Cannot use import statement outside a module" errors while transforming ESM.
@@ -37,4 +39,8 @@ const config = {
],
};
process.env = Object.assign(process.env, {
LANGFUSE_CACHE_API_KEY_ENABLED: "true",
});
export default config;
+11 -9
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "2.65.0",
"version": "2.71.0",
"private": true,
"license": "MIT",
"engines": {
@@ -14,9 +14,10 @@
"prettier": "prettier --write ./src *.{ts,js}",
"clean": "rm -rf node_modules",
"start": "dotenv -e ../.env -- sh -c 'NEXT_MANUAL_SIG_HANDLE=true next start'",
"test": "dotenv -e ../.env -- jest --runInBand",
"test": "dotenv -e ../.env -- jest --runInBand --detectOpenHandles --verbose --testPathIgnorePatterns='__e2e__'",
"test:watch": "dotenv -e ../.env -- jest --watch --runInBand",
"test:e2e": "dotenv -e ../.env -- playwright test",
"test:e2e:server": "dotenv -e ../.env -- jest --runInBand --detectOpenHandles --verbose --testPathPattern='__e2e__'",
"models:migrate": "dotenv -e ../.env -- tsx scripts/model-match.ts",
"generationCost:backfill": "dotenv -e ../.env -- tsx scripts/observations-backfill-calculated-cost.ts"
},
@@ -60,10 +61,9 @@
"@remixicon/react": "^4.2.0",
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@sentry/nextjs": "^8.13.0",
"@sentry/node": "^8.13.0",
"@sentry/profiling-node": "^8.13.0",
"@sentry/types": "^8.13.0",
"@sentry/nextjs": "8.13.0",
"@sentry/node": "8.13.0",
"@sentry/profiling-node": "8.13.0",
"@t3-oss/env-nextjs": "^0.10.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/react-query": "^4.36.1",
@@ -78,6 +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",
@@ -86,8 +87,8 @@
"date-fns": "^3.3.1",
"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",
@@ -98,7 +99,7 @@
"next-auth": "^4.24.7",
"next-query-params": "^5.0.0",
"next-themes": "^0.3.0",
"posthog-js": "^1.122.0",
"posthog-js": "^1.154.6",
"posthog-node": "^3.6.3",
"prexit": "^2.2.0",
"prisma": "^5.13.0",
@@ -156,7 +157,8 @@
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.7.2",
"typescript": "^5.4.5"
"typescript": "^5.4.5",
"wait-for-expect": "^3.0.2"
},
"ct3aMetadata": {
"initVersion": "7.13.0"
+110 -5
View File
@@ -1007,6 +1007,13 @@ paths:
type: string
format: date-time
nullable: true
- name: version
in: query
description: Optional filter to only include observations with a certain version.
required: false
schema:
type: string
nullable: true
responses:
'200':
description: ''
@@ -1181,6 +1188,26 @@ paths:
schema:
type: integer
nullable: true
- name: fromUpdatedAt
in: query
description: >-
Optional filter to only include prompt versions created/updated on
or after a certain datetime (ISO 8601)
required: false
schema:
type: string
format: date-time
nullable: true
- name: toUpdatedAt
in: query
description: >-
Optional filter to only include prompt versions created/updated
before a certain datetime (ISO 8601)
required: false
schema:
type: string
format: date-time
nullable: true
responses:
'200':
description: ''
@@ -1215,7 +1242,7 @@ paths:
schema: {}
security: *ref_0
post:
description: Create a prompt
description: Create a new version for the prompt with the given `name`
operationId: prompts_create
tags:
- Prompts
@@ -1675,7 +1702,7 @@ paths:
security: *ref_0
/api/public/sessions:
get:
description: Get sessions.
description: Get sessions
operationId: sessions_list
tags:
- Sessions
@@ -1846,7 +1873,7 @@ paths:
security: *ref_0
/api/public/traces:
get:
description: Get list of traces.
description: Get list of traces
operationId: trace_list
tags:
- Trace
@@ -1924,6 +1951,20 @@ paths:
items:
type: string
nullable: true
- name: version
in: query
description: Optional filter to only include traces with a certain version.
required: false
schema:
type: string
nullable: true
- name: release
in: query
description: Optional filter to only include traces with a certain release.
required: false
schema:
type: string
nullable: true
responses:
'200':
description: ''
@@ -1969,32 +2010,44 @@ components:
timestamp:
type: string
format: date-time
description: The timestamp when the trace was created
name:
type: string
nullable: true
description: The name of the trace
input:
nullable: true
description: The input data of the trace. Can be any JSON.
output:
nullable: true
description: The output data of the trace. Can be any JSON.
sessionId:
type: string
nullable: true
description: The session identifier associated with the trace
release:
type: string
nullable: true
description: The release version of the application when the trace was created
version:
type: string
nullable: true
description: The version of the trace
userId:
type: string
nullable: true
description: The user identifier associated with the trace
metadata:
nullable: true
description: The metadata associated with the trace. Can be any JSON.
tags:
type: array
items:
type: string
nullable: true
description: >-
The tags associated with the trace. Can be an array of strings or
null.
public:
type: boolean
nullable: true
@@ -2101,56 +2154,74 @@ components:
properties:
id:
type: string
description: The unique identifier of the observation
traceId:
type: string
nullable: true
description: The trace ID associated with the observation
type:
type: string
description: The type of the observation
name:
type: string
nullable: true
description: The name of the observation
startTime:
type: string
format: date-time
description: The start time of the observation
endTime:
type: string
format: date-time
nullable: true
description: The end time of the observation.
completionStartTime:
type: string
format: date-time
nullable: true
description: The completion start time of the observation
model:
type: string
nullable: true
description: The model used for the observation
modelParameters:
type: object
additionalProperties:
$ref: '#/components/schemas/MapValue'
nullable: true
description: The parameters of the model used for the observation
input:
nullable: true
description: The input data of the observation
version:
type: string
nullable: true
description: The version of the observation
metadata:
nullable: true
description: Additional metadata of the observation
output:
nullable: true
description: The output data of the observation
usage:
$ref: '#/components/schemas/Usage'
nullable: true
description: The usage data of the observation
level:
$ref: '#/components/schemas/ObservationLevel'
description: The level of the observation
statusMessage:
type: string
nullable: true
description: The status message of the observation
parentObservationId:
type: string
nullable: true
description: The parent observation ID
promptId:
type: string
nullable: true
description: The prompt ID associated with the observation
required:
- id
- type
@@ -2160,41 +2231,58 @@ components:
title: ObservationsView
type: object
properties:
promptName:
type: string
nullable: true
description: The name of the prompt associated with the observation
promptVersion:
type: integer
nullable: true
description: The version of the prompt associated with the observation
modelId:
type: string
nullable: true
description: The unique identifier of the model
inputPrice:
type: number
format: double
nullable: true
description: The price of the input in USD
outputPrice:
type: number
format: double
nullable: true
description: The price of the output in USD.
totalPrice:
type: number
format: double
nullable: true
description: The total price in USD.
calculatedInputCost:
type: number
format: double
nullable: true
description: The calculated cost of the input in USD
calculatedOutputCost:
type: number
format: double
nullable: true
description: The calculated cost of the output in USD
calculatedTotalCost:
type: number
format: double
nullable: true
description: The calculated total cost in USD
latency:
type: number
format: double
nullable: true
description: The latency in seconds.
timeToFirstToken:
type: number
format: double
nullable: true
description: The time to the first token in seconds
allOf:
- $ref: '#/components/schemas/Observation'
Usage:
@@ -2540,23 +2628,31 @@ components:
properties:
id:
type: string
description: Unique identifier of the dataset run
name:
type: string
description: Name of the dataset run
description:
type: string
nullable: true
description: Description of the run
metadata:
nullable: true
description: Metadata of the dataset run
datasetId:
type: string
description: Id of the associated dataset
datasetName:
type: string
description: Name of the associated dataset
createdAt:
type: string
format: date-time
description: The date and time when the dataset run was created
updatedAt:
type: string
format: date-time
description: The date and time when the dataset run was last updated
required:
- id
- name
@@ -2715,8 +2811,8 @@ components:
type: string
nullable: true
description: >-
Dataset items are upserted on their id. Id needs to be globally
unique and cannot be reused across datasets.
Dataset items are upserted on their id. Id needs to be unique
(project-level) and cannot be reused across datasets.
status:
$ref: '#/components/schemas/DatasetStatus'
nullable: true
@@ -3611,11 +3707,20 @@ components:
type: array
items:
type: string
lastUpdatedAt:
type: string
format: date-time
lastConfig:
description: >-
Config object of the most recent prompt version that matches the
filters (if any are provided)
required:
- name
- versions
- labels
- tags
- lastUpdatedAt
- lastConfig
CreatePromptRequest:
title: CreatePromptRequest
oneOf:
+31 -6
View File
@@ -718,7 +718,7 @@
"request": {
"description": "Get a list of observations",
"url": {
"raw": "{{baseUrl}}/api/public/observations?page=&limit=&name=&userId=&type=&traceId=&parentObservationId=&fromStartTime=&toStartTime=",
"raw": "{{baseUrl}}/api/public/observations?page=&limit=&name=&userId=&type=&traceId=&parentObservationId=&fromStartTime=&toStartTime=&version=",
"host": [
"{{baseUrl}}"
],
@@ -772,6 +772,11 @@
"key": "toStartTime",
"value": "",
"description": "Retrieve only observations with a start_time before this datetime (ISO 8601)."
},
{
"key": "version",
"value": "",
"description": "Optional filter to only include observations with a certain version."
}
],
"variable": []
@@ -872,7 +877,7 @@
"request": {
"description": "Get a list of prompt names with versions and labels",
"url": {
"raw": "{{baseUrl}}/api/public/v2/prompts?name=&label=&tag=&page=&limit=",
"raw": "{{baseUrl}}/api/public/v2/prompts?name=&label=&tag=&page=&limit=&fromUpdatedAt=&toUpdatedAt=",
"host": [
"{{baseUrl}}"
],
@@ -907,6 +912,16 @@
"key": "limit",
"value": "",
"description": "limit of items per page"
},
{
"key": "fromUpdatedAt",
"value": "",
"description": "Optional filter to only include prompt versions created/updated on or after a certain datetime (ISO 8601)"
},
{
"key": "toUpdatedAt",
"value": "",
"description": "Optional filter to only include prompt versions created/updated before a certain datetime (ISO 8601)"
}
],
"variable": []
@@ -922,7 +937,7 @@
"_type": "endpoint",
"name": "Create",
"request": {
"description": "Create a prompt",
"description": "Create a new version for the prompt with the given `name`",
"url": {
"raw": "{{baseUrl}}/api/public/v2/prompts",
"host": [
@@ -1261,7 +1276,7 @@
"_type": "endpoint",
"name": "List",
"request": {
"description": "Get sessions.",
"description": "Get sessions",
"url": {
"raw": "{{baseUrl}}/api/public/sessions?page=&limit=&fromTimestamp=&toTimestamp=",
"host": [
@@ -1378,9 +1393,9 @@
"_type": "endpoint",
"name": "List",
"request": {
"description": "Get list of traces.",
"description": "Get list of traces",
"url": {
"raw": "{{baseUrl}}/api/public/traces?page=&limit=&userId=&name=&sessionId=&fromTimestamp=&toTimestamp=&orderBy=&tags=",
"raw": "{{baseUrl}}/api/public/traces?page=&limit=&userId=&name=&sessionId=&fromTimestamp=&toTimestamp=&orderBy=&tags=&version=&release=",
"host": [
"{{baseUrl}}"
],
@@ -1434,6 +1449,16 @@
"key": "tags",
"value": "",
"description": "Only traces that include all of these tags will be returned."
},
{
"key": "version",
"value": "",
"description": "Optional filter to only include traces with a certain version."
},
{
"key": "release",
"value": "",
"description": "Optional filter to only include traces with a certain release."
}
],
"variable": []
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 140"><g fill-rule="nonzero" fill="none"><path d="M55.383 75.225c-1.874 3.748-7.222 3.748-9.096 0l-4.482-8.963a5.085 5.085 0 0 1 0-4.548l4.482-8.964c1.874-3.748 7.222-3.748 9.096 0l4.482 8.964a5.084 5.084 0 0 1 0 4.548l-4.482 8.963Zm0 50.836c-1.874 3.747-7.222 3.747-9.096 0l-4.482-8.964a5.085 5.085 0 0 1 0-4.548l4.482-8.964c1.874-3.748 7.222-3.748 9.096 0l4.482 8.964a5.084 5.084 0 0 1 0 4.548l-4.482 8.964Z" fill="#1D4AFF"/><path d="M0 106.765c0-4.53 5.477-6.8 8.68-3.596l23.307 23.307c3.204 3.204.935 8.68-3.595 8.68H5.085A5.085 5.085 0 0 1 0 130.073v-23.307Zm0-24.55c0 1.35.536 2.643 1.49 3.596l47.856 47.856a5.086 5.086 0 0 0 3.595 1.49h26.286c4.53 0 6.799-5.477 3.595-8.681L8.681 52.334C5.477 49.131 0 51.4 0 55.93v26.286Zm0-50.834c0 1.348.536 2.642 1.49 3.595l98.69 98.691a5.086 5.086 0 0 0 3.596 1.49h26.285c4.53 0 6.8-5.477 3.596-8.681L8.681 1.5C5.477-1.704 0 .565 0 5.095v26.286Zm50.835 0c0 1.348.536 2.642 1.49 3.595l91.5 91.5c3.203 3.204 8.68.935 8.68-3.596V96.595a5.086 5.086 0 0 0-1.49-3.596l-91.5-91.5c-3.203-3.203-8.68-.934-8.68 3.596v26.286ZM110.35 1.5c-3.203-3.204-8.68-.935-8.68 3.595v26.286c0 1.348.536 2.642 1.49 3.595l40.664 40.665c3.204 3.204 8.68.935 8.68-3.596V45.76a5.086 5.086 0 0 0-1.489-3.596L110.35 1.5Z" fill="#F9BD2B"/><path d="m216.24 107.388-47.864-47.863c-3.204-3.204-8.681-.935-8.681 3.595v66.952a5.085 5.085 0 0 0 5.085 5.085h74.142a5.085 5.085 0 0 0 5.085-5.085v-6.097c0-2.809-2.286-5.052-5.07-5.414a39.27 39.27 0 0 1-22.698-11.173Zm-32.145 11.502a8.137 8.137 0 0 1-8.133-8.134 8.137 8.137 0 0 1 8.133-8.134 8.137 8.137 0 0 1 8.134 8.134 8.137 8.137 0 0 1-8.134 8.134Z" fill="#000"/><path d="M0 130.072a5.085 5.085 0 0 0 5.085 5.085h23.307c4.53 0 6.799-5.477 3.595-8.681L8.681 103.169C5.477 99.966 0 102.235 0 106.765v23.307Zm50.835-86.418L8.68 1.5C5.477-1.704 0 .565 0 5.095v26.286c0 1.348.536 2.642 1.49 3.595l49.345 49.346V43.654ZM8.68 52.334C5.477 49.131 0 51.4 0 55.93v26.286c0 1.348.536 2.642 1.49 3.595l49.345 49.346V94.489L8.68 52.334Z" fill="#1D4AFF"/><path d="M101.67 45.76a5.083 5.083 0 0 0-1.49-3.596L59.516 1.5c-3.204-3.204-8.681-.935-8.681 3.595v26.286c0 1.348.536 2.642 1.49 3.595l49.345 49.346V45.76Zm-50.835 89.397h28.392c4.53 0 6.799-5.477 3.595-8.681L50.835 94.489v40.668Zm0-91.503v38.562c0 1.348.536 2.642 1.49 3.595l49.345 49.346V96.595a5.084 5.084 0 0 0-1.49-3.596L50.835 43.654Z" fill="#F54E00"/><path d="M303.32 114.86h20.888V80.22h17.452c19.17 0 31.466-11.37 31.466-28.954 0-17.584-12.295-28.954-31.466-28.954h-38.34v92.547Zm20.888-52.488V40.16h15.337c7.932 0 12.692 4.23 12.692 11.105 0 6.876-4.76 11.106-12.692 11.106h-15.337Zm86.71 53.545c20.36 0 35.167-14.543 35.167-34.375 0-19.831-14.807-34.374-35.167-34.374-20.625 0-35.168 14.543-35.168 34.374 0 19.832 14.543 34.375 35.168 34.375Zm-15.866-34.375c0-10.577 6.346-17.848 15.866-17.848 9.386 0 15.733 7.271 15.733 17.848 0 10.577-6.347 17.849-15.733 17.849-9.52 0-15.866-7.272-15.866-17.849Zm84.462 34.375c15.601 0 26.178-9.784 26.178-21.286 0-26.97-35.829-18.245-35.829-28.822 0-2.908 3.04-4.759 7.404-4.759 4.495 0 9.916 2.776 11.634 8.858l15.601-6.479c-3.04-9.65-14.279-16.261-27.896-16.261-14.676 0-23.798 8.725-23.798 19.17 0 25.252 35.3 18.245 35.3 28.69 0 3.702-3.437 6.214-8.594 6.214-7.403 0-12.56-5.156-14.146-11.37l-15.601 6.081c3.438 10.048 13.486 19.964 29.747 19.964Zm76.43-1.718-1.321-16.791c-2.248 1.19-5.157 1.586-7.536 1.586-4.76 0-7.933-3.437-7.933-9.387V64.355h16.13v-16.13h-16.13V28.923h-19.435v19.302h-10.577v16.13h10.577v27.764c0 16.13 10.974 23.798 25.384 23.798 3.967 0 7.669-.66 10.842-1.718Zm67.764-91.887v35.961h-36.755v-35.96h-20.89v92.546h20.89V76.122h36.755v38.737h21.021V22.312h-21.021Zm67.386 93.605c20.36 0 35.168-14.543 35.168-34.375 0-19.831-14.807-34.374-35.168-34.374-20.625 0-35.168 14.543-35.168 34.374 0 19.832 14.543 34.375 35.168 34.375ZM675.23 81.542c0-10.577 6.346-17.848 15.865-17.848 9.387 0 15.733 7.271 15.733 17.848 0 10.577-6.346 17.849-15.733 17.849-9.519 0-15.865-7.272-15.865-17.849Zm88.545 31.202c7.272 0 13.75-2.512 17.188-6.875v6.346c0 7.404-5.95 12.56-15.072 12.56-6.479 0-12.164-3.173-13.09-8.594l-17.715 2.777c2.38 12.56 15.204 21.022 30.805 21.022 20.492 0 34.11-12.032 34.11-29.88V48.225h-19.17v5.685c-3.57-4.098-9.652-6.742-17.452-6.742-18.51 0-30.144 12.692-30.144 32.788 0 20.096 11.634 32.788 30.54 32.788ZM752.14 79.956c0-9.916 5.817-16.262 14.807-16.262 9.123 0 14.94 6.346 14.94 16.262s-5.817 16.262-14.94 16.262c-8.99 0-14.807-6.346-14.807-16.262Z" fill="#000"/></g></svg>

Before

Width:  |  Height:  |  Size: 4.4 KiB

+2 -2
View File
@@ -5,11 +5,11 @@
import "dotenv/config";
import { findModel } from "@/src/server/api/services/EventProcessor";
import { findModel } from "@langfuse/shared/src/server";
import { prisma } from "@langfuse/shared/src/db";
import lodash from "lodash";
import { tokenCount } from "@/src/features/ingest/lib/usage";
import { type Prisma } from "@langfuse/shared/src/db";
import { tokenCount } from "@/src/features/ingest/usage";
async function main() {
return await modelMatch();
+203
View File
@@ -0,0 +1,203 @@
import { v4 } from "uuid";
import { JobExecutionStatus, prisma } from "@langfuse/shared/src/db";
import { redis } from "@langfuse/shared/src/server";
import waitForExpect from "wait-for-expect";
import { ApiKeyZod } from "@/src/features/public-api/server/apiAuth";
const generateAuth = (username: string, password: string) => {
const auth = Buffer.from(`${username}:${password}`).toString("base64");
return `Basic ${auth}`;
};
const workerAdminAuth = generateAuth("admin", "myworkerpassword");
const userApiKeyAuth = generateAuth("pk-lf-1234567890", "sk-lf-1234567890");
describe("Health endpoints", () => {
it("web container returns healthy", async () => {
// Arrange
const url = "http://localhost:3000/api/public/health";
// Act
const response = await fetch(url);
expect(response.status).toBe(200);
});
it("worker container returns healthy", async () => {
// Arrange
const url = "http://localhost:3030/api/health";
// Act
const response = await fetch(url, {
headers: {
Authorization: workerAdminAuth,
},
});
expect(response.status).toBe(200);
});
});
describe("Ingestion Pipeline", () => {
it("ingest a trace", async () => {
const traceId = v4();
const spanId = v4();
const event = {
batch: [
{
id: v4(),
type: "trace-create",
timestamp: new Date().toISOString(),
body: {
name: "test trace",
id: traceId,
userId: "user-1", // triggers the eval
},
},
{
id: v4(),
type: "span-create",
timestamp: new Date().toISOString(),
body: {
id: spanId,
traceId: traceId,
name: "test span",
},
},
],
};
// Arrange
const url = "http://localhost:3000/api/public/ingestion";
// Act
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: userApiKeyAuth,
},
body: JSON.stringify(event),
});
await waitForExpect(async () => {
// we need a second call to the public API with the API key, so that it is stored in redis
// first call (ingestion above) generates the new, fast API hash
// second call (below) stores the API key in redis
const traceUrl = `http://localhost:3000/api/public/traces/${traceId}`;
const traceResponse = await fetch(traceUrl, {
headers: {
Authorization: userApiKeyAuth,
},
});
expect(traceResponse.status).toBe(200);
expect(traceResponse.body).not.toBeNull();
expect((await traceResponse.json()).id).toBe(traceId);
const trace = await prisma.trace.findUnique({
where: {
id: traceId,
},
});
expect(trace).not.toBeNull();
expect(trace?.name).toBe("test trace");
const observation = await prisma.observation.findUnique({
where: {
id: spanId,
traceId: traceId,
},
});
expect(observation).not.toBeNull();
expect(observation?.name).toBe("test span");
expect(redis).not.toBeNull();
const redisKeys = await redis?.keys(`api-key:*`);
expect(redisKeys?.length).toBe(1);
const redisValue = await redis?.get(redisKeys![0]);
const llmApiKey = ApiKeyZod.parse(JSON.parse(redisValue!));
expect(llmApiKey.projectId).toBe("7a88fb47-b4e2-43b8-a06c-a5ce950dc53a");
});
// check for eval
await waitForExpect(async () => {
const evalExecution = await prisma.jobExecution.findFirst({
where: {
jobInputTraceId: traceId,
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
},
});
expect(evalExecution).not.toBeNull();
if (!evalExecution) {
return;
}
// failure due to missing openai key in the pipeline. Expected
expect(evalExecution.status).toBe(JobExecutionStatus.ERROR);
}, 20000);
expect(response.status).toBe(207);
}, 25000);
});
describe("Prompts endpoint", () => {
it("creates and returns a prompt", async () => {
const promptName = "prompt-name" + v4();
const chatMessages = [
{ role: "system", content: "You are a bot" },
{ role: "user", content: "What's up?" },
];
const response = await fetch(
"http://localhost:3000/api/public/v2/prompts",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: userApiKeyAuth,
},
body: JSON.stringify({
name: promptName,
prompt: chatMessages,
type: "chat",
labels: ["production"],
}),
},
);
expect(response.status).toBe(201);
const fetchedPrompt = await fetch(
`http://localhost:3000/api/public/v2/prompts/${encodeURIComponent(promptName)}`,
{
headers: {
"Content-Type": "application/json",
Authorization: userApiKeyAuth,
},
},
);
expect(fetchedPrompt.status).toBe(200);
expect(fetchedPrompt.body).not.toBeNull();
if (fetchedPrompt.body === null) {
return;
}
const validatedPrompt = await fetchedPrompt.json();
expect(validatedPrompt.name).toBe(promptName);
const redisKey = `prompt:7a88fb47-b4e2-43b8-a06c-a5ce950dc53a:${promptName}:${validatedPrompt.labels[0]}`;
const redisValue = await redis?.get(redisKey);
expect(redisValue).not.toBeNull();
if (!redisValue) {
return;
}
expect(JSON.parse(redisValue)).toEqual(validatedPrompt);
});
});
+6 -14
View File
@@ -15,16 +15,7 @@ test("should redirect to home if signed in", async ({ page }) => {
// wait 2 seconds
await page.waitForTimeout(2000);
if (process.env.CI)
await expect(page).toHaveURL(
// project id from seed.ts
"/project/7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
);
else
console.log(
"Test skipped as redirect depends on db state, URL after signing in:",
page.url(),
);
await expect(page).toHaveURL("/");
});
test("Successfully sign up & able to go to homepage", async ({ page }) => {
@@ -35,7 +26,7 @@ test("Successfully sign up & able to go to homepage", async ({ page }) => {
await page.click('button[data-testid="submit-email-password-sign-up-form"]');
await page.waitForTimeout(2000);
// see get started page
await expect(page).toHaveURL("/?getStarted=1");
await expect(page).toHaveURL("/");
});
test("Successfully sign up & able to go to homepage with uppercase email", async ({
@@ -48,7 +39,7 @@ test("Successfully sign up & able to go to homepage with uppercase email", async
await page.click('button[data-testid="submit-email-password-sign-up-form"]');
await page.waitForTimeout(2000);
// see get started page
await expect(page).toHaveURL("/?getStarted=1");
await expect(page).toHaveURL("/");
});
test("Signup input validation", async ({ page }) => {
@@ -64,7 +55,7 @@ test("Signup input validation", async ({ page }) => {
await page.click('button[data-testid="submit-email-password-sign-up-form"]');
await page.waitForTimeout(2000);
// don't see get started page
await expect(page).not.toHaveURL("/?getStarted=1");
await expect(page).not.toHaveURL("/");
});
// random email address to be used in tests
@@ -83,7 +74,8 @@ test("Unauthenticated user should be redirected to target URL after login", asyn
await page.waitForTimeout(2000);
// project id and prompt from seed.ts
const promptUrl = "/project/7a88fb47-b4e2-43b8-a06c-a5ce950dc53a/prompts/summary-prompt";
const promptUrl =
"/project/7a88fb47-b4e2-43b8-a06c-a5ce950dc53a/prompts/summary-prompt";
await page.getByRole("button", { name: /Demo User/ }).click();
+49 -38
View File
@@ -1,48 +1,59 @@
import { test, expect } from "@playwright/test";
test("should see new projects dialog open after clicking new project btn", async ({
page,
}) => {
await page.goto("auth/sign-up");
await page.fill('input[name="name"]', "demo user");
await page.fill('input[name="email"]', randomEmailAddress());
await page.fill('input[type="password"]', "password");
await page.click('button[data-testid="submit-email-password-sign-up-form"]');
await page.waitForTimeout(2000);
await page.waitForTimeout(2000);
expect(await page.innerHTML("data-testid=create-new-project-title")).toBe(
"Create new project",
);
await page.click('[data-testid="create-project-btn"]');
await page.waitForTimeout(2000);
await expect(page.locator("data-testid=new-project-form")).toBeVisible();
});
test("Create a project with provided name", async ({ page }) => {
test("Sign in, create an organization, create a project", async ({ page }) => {
test.setTimeout(60000);
// Sign in
await page.goto("/auth/sign-in");
await page.fill('input[name="email"]', "demo@langfuse.com");
await page.fill('input[type="password"]', "password");
await page.click('button[data-testid="submit-email-password-sign-in-form"]');
await page.waitForTimeout(2000);
await page.isVisible('[data-testid="new-project-form"]');
await page.click('[data-testid="create-project-btn"]');
await page.waitForTimeout(2000);
await expect(page.locator("data-testid=new-project-form")).toBeVisible();
await page.fill(
'[data-testid="new-project-name-input"]',
"my e2e demo project",
);
await page.click('button[type="submit"]');
await page.waitForTimeout(2000);
expect(page.url()).toContain("/project/");
await page.waitForTimeout(2000);
expect(await page.getByTestId("project-name").textContent()).toContain(
"my e2e demo project",
);
});
await expect(page).toHaveURL("/");
// random email address to be used in tests
const randomEmailAddress = () =>
Math.random().toString(36).substring(2, 11) + "@example.com";
// Start create org flow
await page.isVisible('[data-testid="create-organization-btn"]');
await page.click('[data-testid="create-organization-btn"]');
await page.waitForTimeout(2000);
await expect(page).toHaveURL("/setup");
// Create an organization
await expect(page.locator("data-testid=new-org-form")).toBeVisible();
await page.fill('[data-testid="new-org-name-input"]', "e2e test org");
await page.click('button[type="submit"]');
await page.waitForTimeout(5000);
expect(page.url()).toContain("/organization/");
expect(page.url()).toContain("/setup?orgstep=invite-members");
// Parse the organization ID from the URL using a simpler method
const url = new URL(page.url());
const organizationId = url.pathname.split("/")[2];
console.log("organization", organizationId);
// Skip add new members step
await page.isVisible('[data-testid="btn-skip-add-members"]');
await page.click('[data-testid="btn-skip-add-members"]');
expect(page.url()).toContain(
"/organization/" + organizationId + "/setup?orgstep=create-project",
);
// Create project
await expect(page.locator("data-testid=new-project-form")).toBeVisible();
await page.fill('[data-testid="new-project-name-input"]', "e2e test project");
await page.click('button[type="submit"]');
await page.waitForTimeout(5000);
expect(page.url()).toContain("/project/");
expect(page.url()).toContain("/setup");
const projectUrl = new URL(page.url());
const projectId = projectUrl.pathname.split("/")[2];
// check that the project exists by navigating to its dashboard
await page.goto("/project/" + projectId);
await page.waitForTimeout(2000);
expect(page.url()).toContain("/project/" + projectId);
expect(page.url()).not.toContain("/setup");
const headings = await page.locator("h2").allTextContents();
expect(headings).toContain("Dashboard");
});
+5
View File
@@ -0,0 +1,5 @@
import teardown from "@/src/__tests__/teardown";
afterAll(async () => {
await teardown();
});
@@ -0,0 +1,189 @@
import { aggregateScores } from "@/src/features/scores/lib/aggregateScores";
import { type APIScore } from "@langfuse/shared";
describe("aggregateScores", () => {
it("should return an empty object for an empty array", () => {
const scores: APIScore[] = [];
expect(aggregateScores(scores)).toEqual({});
});
it("should correctly aggregate a single numeric score", () => {
const scores = [
{
name: "test",
source: "API",
dataType: "NUMERIC",
value: 5,
comment: "test comment",
},
] as APIScore[];
expect(aggregateScores(scores)).toEqual({
"test-API-NUMERIC": {
type: "NUMERIC",
values: [5],
average: 5,
comment: "test comment",
},
});
});
it("should correctly aggregate multiple numeric scores with the same key", () => {
const scores = [
{
name: "test",
source: "API",
dataType: "NUMERIC",
value: 5,
comment: "test comment",
},
{
name: "test",
source: "API",
dataType: "NUMERIC",
value: 7,
comment: "another comment",
},
] as APIScore[];
expect(aggregateScores(scores)).toEqual({
"test-API-NUMERIC": {
type: "NUMERIC",
values: [5, 7],
average: 6,
comment: undefined,
},
});
});
it("should correctly aggregate scores with different keys", () => {
const scores: APIScore[] = [
{
name: "test1",
source: "API",
dataType: "NUMERIC",
value: 5,
comment: "test comment",
},
{
name: "test1",
source: "ANNOTATION",
dataType: "NUMERIC",
value: 7,
comment: "another comment",
},
] as APIScore[];
expect(aggregateScores(scores)).toEqual({
"test1-API-NUMERIC": {
type: "NUMERIC",
values: [5],
average: 5,
comment: "test comment",
},
"test1-ANNOTATION-NUMERIC": {
type: "NUMERIC",
values: [7],
average: 7,
comment: "another comment",
},
});
});
it("should correctly aggregate a single Categorical score", () => {
const scores = [
{
name: "test",
source: "ANNOTATION",
dataType: "CATEGORICAL",
stringValue: "good",
comment: "test comment",
},
] as APIScore[];
expect(aggregateScores(scores)).toEqual({
"test-ANNOTATION-CATEGORICAL": {
type: "CATEGORICAL",
values: ["good"],
valueCounts: [{ value: "good", count: 1 }],
comment: "test comment",
},
});
});
it("should correctly aggregate multiple Categorical scores with the same key", () => {
const scores = [
{
name: "test",
source: "API",
dataType: "BOOLEAN",
stringValue: "True",
comment: "test comment",
},
{
name: "test",
source: "API",
dataType: "BOOLEAN",
stringValue: "False",
comment: "another comment",
},
] as APIScore[];
expect(aggregateScores(scores)).toEqual({
"test-API-BOOLEAN": {
type: "CATEGORICAL",
values: ["True", "False"],
valueCounts: [
{ value: "True", count: 1 },
{ value: "False", count: 1 },
],
comment: undefined,
},
});
});
it("should correctly aggregate scores with mixed types and the same name", () => {
const scores = [
{
name: "test",
source: "API",
dataType: "NUMERIC",
value: 5,
comment: "test comment",
},
{
name: "test",
source: "ANNOTATION",
dataType: "CATEGORICAL",
stringValue: "good",
comment: "another comment",
},
{
name: "test",
source: "ANNOTATION",
dataType: "CATEGORICAL",
stringValue: "bad",
comment: "last comment",
},
{
name: "test",
source: "ANNOTATION",
dataType: "CATEGORICAL",
stringValue: "good",
comment: "last comment",
},
] as APIScore[];
expect(aggregateScores(scores)).toEqual({
"test-API-NUMERIC": {
type: "NUMERIC",
values: [5],
average: 5,
comment: "test comment",
},
"test-ANNOTATION-CATEGORICAL": {
type: "CATEGORICAL",
values: ["good", "bad", "good"],
valueCounts: [
{ value: "good", count: 2 },
{ value: "bad", count: 1 },
],
comment: undefined,
},
});
});
});
+371 -60
View File
@@ -2,87 +2,398 @@ import {
getDisplaySecretKey,
hashSecretKey,
} from "@langfuse/shared/src/server";
import { verifyAuthHeaderAndReturnScope } from "@/src/features/public-api/server/apiAuth";
import { prisma } from "@langfuse/shared/src/db";
import {
ApiAuthService,
ApiKeyZod,
} from "@/src/features/public-api/server/apiAuth";
import { type PrismaClient, prisma } from "@langfuse/shared/src/db";
import { Redis } from "ioredis";
import { env } from "@/src/env.mjs";
describe("Validate api calls", () => {
describe("Authenticate API calls", () => {
beforeEach(async () => {
await prisma.score.deleteMany();
await prisma.observation.deleteMany();
await prisma.trace.deleteMany();
await prisma.apiKey.deleteMany();
});
it("should create new api key", async () => {
await createAPIKey();
const auth = await verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(auth.validKey).toBe(true);
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
});
expect(apiKey).not.toBeNull();
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
afterEach(async () => {
await prisma.score.deleteMany();
await prisma.observation.deleteMany();
await prisma.trace.deleteMany();
await prisma.apiKey.deleteMany();
});
it("should create new api key and succeed with new key", async () => {
await createAPIKey();
const auth = await verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(auth.validKey).toBe(true);
describe("validates without redis", () => {
it("should create new api key", async () => {
await createAPIKey();
const auth = await new ApiAuthService(
prisma,
null,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
expect(auth.validKey).toBe(true);
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
});
expect(apiKey).not.toBeNull();
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
});
expect(apiKey).not.toBeNull();
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
const auth2 = await verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(auth2.validKey).toBe(true);
it("should create new api key and succeed with new key", async () => {
await createAPIKey();
const auth = await new ApiAuthService(
prisma,
null,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(auth.validKey).toBe(true);
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
});
expect(apiKey).not.toBeNull();
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
const auth2 = await new ApiAuthService(
prisma,
null,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(auth2.validKey).toBe(true);
});
it("should fail on wrong api key with new key", async () => {
await createAPIKey();
const auth = await new ApiAuthService(
prisma,
null,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(auth.validKey).toBe(true);
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
});
expect(apiKey).not.toBeNull();
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
const wrongAuth = await new ApiAuthService(
prisma,
null,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkx",
);
expect(wrongAuth.validKey).toBe(false);
});
it("should fail on wrong api key without new key", async () => {
await createAPIKey();
const initialApiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
});
expect(initialApiKey).not.toBeNull();
expect(initialApiKey?.fastHashedSecretKey).toBeNull();
const auth = await new ApiAuthService(
prisma,
null,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkx",
);
expect(auth.validKey).toBe(false);
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
});
expect(apiKey).not.toBeNull();
expect(apiKey?.fastHashedSecretKey).toBeNull();
});
});
it("should fail on wrong api key with new key", async () => {
await createAPIKey();
const auth = await verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(auth.validKey).toBe(true);
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
describe("validates with redis", () => {
const redis = new Redis("redis://:myredissecret@127.0.0.1:6379", {
maxRetriesPerRequest: null,
});
expect(apiKey).not.toBeNull();
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
const wrongAuth = await verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkx",
);
expect(wrongAuth.validKey).toBe(false);
});
beforeEach(async () => {
// if we do not remove the key, it will remain in the cache and
// calling the test twice will not add the key to the cache
it("should fail on wrong api key without new key", async () => {
await createAPIKey();
const initialApiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
const keys = await redis.keys("api-key*");
if (keys.length > 0) {
await redis.del(keys);
}
});
expect(initialApiKey).not.toBeNull();
expect(initialApiKey?.fastHashedSecretKey).toBeNull();
const auth = await verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkx",
);
expect(auth.validKey).toBe(false);
afterEach(async () => {
// if we do not remove the key, it will remain in the cache and
// calling the test twice will not add the key to the cache
const keys = await redis.keys("api-key*");
if (keys.length > 0) {
await redis.del(keys);
}
});
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
afterAll(async () => {
redis.disconnect();
});
it("should create new api key and read from cache", async () => {
await createAPIKey();
// first auth will generate the fast hashed api key
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
});
expect(apiKey).not.toBeNull();
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
const cachedKey = await redis.get(
`api-key:${apiKey?.fastHashedSecretKey}`,
);
expect(cachedKey).toBeNull();
// second will add the key to redis
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
const cachedKey2 = await redis.get(
`api-key:${apiKey?.fastHashedSecretKey}`,
);
expect(cachedKey2).not.toBeNull();
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey2!));
expect(parsed).toEqual({
...apiKey,
createdAt: apiKey?.createdAt.toISOString(),
});
});
it("searching for non-existing key stores flag in redis and fails auth", async () => {
// key does not exist in database
const verification = await new ApiAuthService(
prisma,
redis,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(verification.validKey).toBe(false);
const redisKeys = await redis.keys(`api-key:*`);
expect(redisKeys.length).toBe(1);
const redisValue = await redis.get(redisKeys[0]);
expect(redisValue).toBe('"api-key-non-existent"');
});
it("searching for non-existing key again fails auth", async () => {
// key does not exist in database
const verification = await new ApiAuthService(
prisma,
redis,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(verification.validKey).toBe(false);
const redisKeys = await redis.keys(`api-key:*`);
expect(redisKeys.length).toBe(1);
const redisValue = await redis.get(redisKeys[0]);
expect(redisValue).toBe('"api-key-non-existent"');
const verification2 = await new ApiAuthService(
prisma,
redis,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(verification2.validKey).toBe(false);
const redisKeys2 = await redis.keys(`api-key:*`);
expect(redisKeys2.length).toBe(1);
const redisValue2 = await redis.get(redisKeys[0]);
expect(redisValue2).toBe('"api-key-non-existent"');
});
it("prisma should not be used when reading cached keys", async () => {
await createAPIKey();
// Mock prisma
const mockPrisma = {
apiKey: {
findUnique: jest.fn(),
},
};
// first auth will generate the fast hashed api key
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
// second will add the key to redis
const auth2 = await new ApiAuthService(
prisma,
redis,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
// third will read from redis only
await new ApiAuthService(
mockPrisma as unknown as PrismaClient,
redis,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(auth2.validKey).toBe(true);
// Ensure prisma was not called
expect(mockPrisma.apiKey.findUnique).not.toHaveBeenCalled();
const cachedKey = await redis.get(
"api-key:ed6818ada09bdad405a74ac72773dde1708dd3fc6fe8bb81b59927400419d227",
);
expect(cachedKey).not.toBeNull();
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey!));
expect(parsed).toEqual({
id: expect.any(String),
note: "seeded key",
publicKey: "pk-lf-1234567890",
hashedSecretKey: expect.any(String),
fastHashedSecretKey: expect.any(String),
displaySecretKey: expect.any(String),
createdAt: expect.any(String),
lastUsedAt: null,
expiresAt: null,
projectId: expect.any(String),
});
});
it("ttl should be increased when reading from redis", async () => {
await createAPIKey();
// Mock prisma
const mockPrisma = {
apiKey: {
findUnique: jest.fn(),
},
};
// first auth will generate the fast hashed api key
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
// second will add the key to redis
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
// third will read from redis only
await new ApiAuthService(
mockPrisma as unknown as PrismaClient,
redis,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
const ttl = await redis.ttl(
"api-key:ed6818ada09bdad405a74ac72773dde1708dd3fc6fe8bb81b59927400419d227",
);
expect(ttl).toBeGreaterThan(env.LANGFUSE_CACHE_API_KEY_TTL_SECONDS - 2);
// wait for 5 seconds
await new Promise((resolve) => setTimeout(resolve, 5000));
await new ApiAuthService(
mockPrisma as unknown as PrismaClient,
redis,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
const ttl2 = await redis.ttl(
"api-key:ed6818ada09bdad405a74ac72773dde1708dd3fc6fe8bb81b59927400419d227",
);
expect(ttl2).toBeGreaterThan(env.LANGFUSE_CACHE_API_KEY_TTL_SECONDS - 2);
}, 10000);
it("should delete API keys from cache and db", async () => {
await createAPIKey();
// first auth will generate the fast hashed api key
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
// second will add the key to redis
const auth2 = await new ApiAuthService(
prisma,
redis,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(auth2.validKey).toBe(true);
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
});
expect(apiKey).not.toBeNull();
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
const cachedKey = await redis.get(
`api-key:${apiKey?.fastHashedSecretKey}`,
);
expect(cachedKey).not.toBeNull();
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey!));
expect(parsed).toEqual({
...apiKey,
createdAt: apiKey?.createdAt.toISOString(),
});
await new ApiAuthService(prisma, redis).deleteApiKey(
apiKey?.id!,
apiKey?.projectId!,
);
const deletedApiKey = await prisma.apiKey.findUnique({
where: { id: apiKey?.id! },
});
expect(deletedApiKey).toBeNull();
const deletedCachedKey = await redis.get(
`api-key:${apiKey?.fastHashedSecretKey}`,
);
expect(deletedCachedKey).toBeNull();
});
expect(apiKey).not.toBeNull();
expect(apiKey?.fastHashedSecretKey).toBeNull();
});
const createAPIKey = async () => {
+112 -13
View File
@@ -21,6 +21,7 @@ import {
PostDatasetsV1Response,
PostDatasetsV2Response,
} from "@/src/features/public-api/types/datasets";
import { v4 as uuidv4 } from "uuid";
describe("/api/public/datasets and /api/public/dataset-items API Endpoints", () => {
const traceId = v4();
@@ -295,12 +296,15 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
},
});
expect(dbDatasetItems.length).toBe(5);
const dbDatasetItemsApiResponseFormat = dbDatasetItems.map((item) => ({
...item,
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
datasetName: "dataset-name",
}));
const dbDatasetItemsApiResponseFormat = dbDatasetItems.map(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
({ projectId, ...item }) => ({
...item,
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
datasetName: "dataset-name",
}),
);
// add another dataset to test the list endpoint
await makeZodVerifiedAPICall(
@@ -333,7 +337,8 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
});
expect(dbDatasetItemsOther.length).toBe(1);
const dbDatasetItemsOtherApiResponseFormat = dbDatasetItemsOther.map(
(item) => ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
({ projectId, ...item }) => ({
...item,
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
@@ -784,12 +789,15 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
},
});
expect(dbRuns.length).toBe(3);
const dbRunsApiResponseFormat = dbRuns.map((run) => ({
...run,
createdAt: run.createdAt.toISOString(),
updatedAt: run.updatedAt.toISOString(),
datasetName: "dataset-name",
}));
const dbRunsApiResponseFormat = dbRuns.map(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
({ projectId, ...run }) => ({
...run,
createdAt: run.createdAt.toISOString(),
updatedAt: run.updatedAt.toISOString(),
datasetName: "dataset-name",
}),
);
// test get runs
const getRuns = await makeZodVerifiedAPICall(
@@ -835,4 +843,95 @@ 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",
organization: {
connectOrCreate: {
where: { id: "other-org" },
create: { name: "other-org", id: "other-org" },
},
},
},
});
// 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,
}),
]),
);
});
});
@@ -71,12 +71,19 @@ describe("observations.export RPC", () => {
expires: "1",
user: {
id: "clgb17vnp000008jjere5g15i",
name: "John Doe",
projects: [
name: "Demo User",
organizations: [
{
id: projectId,
role: "ADMIN",
name: "test",
id: "seed-org-id",
role: "OWNER",
plan: "cloud:hobby",
cloudConfig: undefined,
projects: [
{
id: projectId,
role: "ADMIN",
},
],
},
],
featureFlags: {
+7 -15
View File
@@ -4,8 +4,8 @@ import { v4 } from "uuid";
import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
import { ModelUsageUnit } from "@langfuse/shared";
import { cleanEvent } from "@/src/pages/api/public/ingestion";
import { prisma } from "@langfuse/shared/src/db";
import { cleanEvent } from "@langfuse/shared/src/server";
describe("/api/public/ingestion API Endpoint", () => {
beforeEach(async () => await pruneDatabase());
@@ -707,19 +707,6 @@ describe("/api/public/ingestion API Endpoint", () => {
expect(dbScore?.traceId).toBe(traceId);
expect(dbScore?.observationId).toBe(generationId);
expect(dbScore?.value).toBe(100.5);
const logEvent = await prisma.events.findFirst({
where: {
data: {
path: ["body", "log"],
string_contains: "ERROR",
},
},
});
expect(logEvent).toBeDefined();
expect(logEvent).not.toBeFalsy();
expect(JSON.stringify(logEvent?.data)).toContain("KeyError: 'model_name'");
});
it("should upsert threats", async () => {
@@ -1140,11 +1127,16 @@ describe("/api/public/ingestion API Endpoint", () => {
it("should not override a trace from a different project", async () => {
const traceId = v4();
const newProjectId = v4();
await prisma.organization.upsert({
where: { id: "other-org" },
create: { id: "other-org", name: "other-org" },
update: {},
});
await prisma.project.create({
data: {
id: newProjectId,
name: "another-project",
orgId: "other-org",
},
});
+12 -5
View File
@@ -18,19 +18,26 @@ describe("llmApiKey.all RPC", () => {
user: {
id: "user-1",
name: "Demo User",
projects: [
organizations: [
{
id: projectId,
role: "ADMIN",
name: "test",
id: "seed-org-id",
role: "OWNER",
plan: "cloud:hobby",
cloudConfig: undefined,
projects: [
{
id: projectId,
role: "ADMIN",
},
],
},
],
featureFlags: {
templateFlag: true,
evals: true,
},
admin: true,
},
environment: {} as any,
};
const ctx = createInnerTRPCContext({ session });
@@ -110,6 +110,10 @@ describe("/api/public/observations API Endpoint", () => {
fetchedObservations.body.data[0]?.calculatedTotalCost,
).toBeGreaterThan(0);
expect(fetchedObservations.body.data[0]?.promptId).toBe(prompt.id);
expect(fetchedObservations.body.data[0]?.promptName).toBe(prompt.name);
expect(fetchedObservations.body.data[0]?.promptVersion).toBe(
prompt.version,
);
});
it("should fetch all observations, filtered by generations", async () => {
await pruneDatabase();
+311
View File
@@ -0,0 +1,311 @@
import { type PrismaClient, type Prompt } from "@prisma/client";
import { PromptService, type redis } from "@langfuse/shared/src/server"; // Adjust the import path as needed
type Redis = NonNullable<typeof redis>;
describe("PromptService", () => {
let promptService: PromptService;
let mockPrisma: jest.Mocked<PrismaClient>;
let mockRedis: jest.Mocked<Redis>;
let mockMetricIncrementer: jest.Mock;
const mockPrompt: Omit<Prompt, "updatedAt" | "createdAt"> = {
id: "1",
projectId: "project1",
name: "testPrompt",
version: 1,
prompt: "Test prompt content",
labels: ["test"],
createdBy: "API",
type: "text",
isActive: null,
config: {},
tags: [],
};
beforeEach(() => {
mockPrisma = {
prompt: {
findFirst: jest.fn(),
},
} as unknown as jest.Mocked<PrismaClient>;
mockRedis = {
getex: jest.fn(),
set: jest.fn(),
setex: jest.fn(),
del: jest.fn(),
exists: jest.fn(),
eval: jest.fn(),
sadd: jest.fn(),
smembers: jest.fn(),
} as unknown as jest.Mocked<Redis>;
mockMetricIncrementer = jest.fn();
promptService = new PromptService(
mockPrisma,
mockRedis,
mockMetricIncrementer,
true,
);
});
describe("getPrompt", () => {
it("should return cached prompt if available", async () => {
mockRedis.exists.mockResolvedValue(0);
mockRedis.getex.mockResolvedValue(JSON.stringify(mockPrompt));
const result = await promptService.getPrompt({
projectId: "project1",
promptName: "testPrompt",
version: 1,
label: undefined,
});
expect(result).toEqual(mockPrompt);
expect(mockMetricIncrementer).toHaveBeenCalledWith("prompt_cache_hit", 1);
});
it("should fetch from database if not in cache", async () => {
mockRedis.exists.mockResolvedValue(0);
mockRedis.getex.mockResolvedValue(null);
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
const result = await promptService.getPrompt({
projectId: "project1",
promptName: "testPrompt",
version: 1,
label: undefined,
});
expect(result).toEqual(mockPrompt);
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
expect(mockMetricIncrementer).toHaveBeenCalledWith(
"prompt_cache_miss",
1,
);
expect(mockRedis.set).toHaveBeenCalledWith(
"prompt:project1:testPrompt:1",
JSON.stringify(mockPrompt),
"EX",
3600,
);
expect(mockRedis.sadd).toHaveBeenCalledWith(
"prompt_key_index:project1:testPrompt",
"prompt:project1:testPrompt:1",
);
});
it("should not use cache if locked", async () => {
mockRedis.exists.mockResolvedValue(1);
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
const result = await promptService.getPrompt({
projectId: "project1",
promptName: "testPrompt",
version: 1,
label: undefined,
});
expect(result).toEqual(mockPrompt);
expect(mockRedis.getex).not.toHaveBeenCalled();
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
});
});
describe("lockCache", () => {
it("should set a lock in Redis", async () => {
await promptService.lockCache({
projectId: "project1",
promptName: "testPrompt",
});
expect(mockRedis.setex).toHaveBeenCalledWith(
"LOCK:prompt:project1:testPrompt",
30,
"locked",
);
});
});
describe("unlockCache", () => {
it("should remove the lock from Redis", async () => {
await promptService.unlockCache({
projectId: "project1",
promptName: "testPrompt",
});
expect(mockRedis.del).toHaveBeenCalledWith(
"LOCK:prompt:project1:testPrompt",
);
});
});
describe("invalidateCache", () => {
it("should call deleteKeysByPrefix with correct prefix", async () => {
await promptService.invalidateCache({
projectId: "project1",
promptName: "testPrompt",
});
expect(mockRedis.smembers).toHaveBeenCalledWith(
"prompt_key_index:project1:testPrompt",
);
});
});
describe("caching disabled", () => {
beforeEach(() => {
promptService = new PromptService(
mockPrisma,
mockRedis,
mockMetricIncrementer,
false,
);
});
it("should not use cache when disabled", async () => {
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
const result = await promptService.getPrompt({
projectId: "project1",
promptName: "testPrompt",
version: 1,
label: undefined,
});
expect(result).toEqual(mockPrompt);
expect(mockRedis.getex).not.toHaveBeenCalled();
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
expect(mockMetricIncrementer).not.toHaveBeenCalled();
});
});
describe("null Redis instance", () => {
beforeEach(() => {
promptService = new PromptService(
mockPrisma,
null,
mockMetricIncrementer,
true,
);
});
it("should not use cache with null Redis instance", async () => {
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
const result = await promptService.getPrompt({
projectId: "project1",
promptName: "testPrompt",
version: 1,
label: undefined,
});
expect(result).toEqual(mockPrompt);
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
expect(mockMetricIncrementer).not.toHaveBeenCalled();
});
});
describe("getPrompt with Redis errors", () => {
it("should fallback to database if Redis.exists throws an error", async () => {
mockRedis.exists.mockRejectedValue(new Error("Redis error"));
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
const result = await promptService.getPrompt({
projectId: "project1",
promptName: "testPrompt",
version: 1,
label: undefined,
});
expect(result).toEqual(mockPrompt);
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
expect(mockMetricIncrementer).toHaveBeenCalledWith(
"prompt_cache_miss",
1,
);
});
it("should fallback to database if Redis.getex throws an error", async () => {
mockRedis.exists.mockResolvedValue(0);
mockRedis.getex.mockRejectedValue(new Error("Redis error"));
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
const result = await promptService.getPrompt({
projectId: "project1",
promptName: "testPrompt",
version: 1,
label: undefined,
});
expect(result).toEqual(mockPrompt);
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
expect(mockMetricIncrementer).toHaveBeenCalledWith(
"prompt_cache_miss",
1,
);
});
it("should not cache if Redis.set throws an error after database fetch", async () => {
mockRedis.exists.mockResolvedValue(0);
mockRedis.getex.mockResolvedValue(null);
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
mockRedis.set.mockRejectedValue(new Error("Redis error"));
const result = await promptService.getPrompt({
projectId: "project1",
promptName: "testPrompt",
version: 1,
label: undefined,
});
expect(result).toEqual(mockPrompt);
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
expect(mockMetricIncrementer).toHaveBeenCalledWith(
"prompt_cache_miss",
1,
);
});
});
describe("lockCache with Redis errors", () => {
it("should throw an error if Redis.setex fails", async () => {
mockRedis.setex.mockRejectedValue(new Error("Redis error"));
await expect(
promptService.lockCache({
projectId: "project1",
promptName: "testPrompt",
}),
).rejects.toThrow("Redis error");
});
});
describe("unlockCache with Redis errors", () => {
it("should log error but not throw if Redis.del fails", async () => {
mockRedis.del.mockRejectedValue(new Error("Redis error"));
await promptService.unlockCache({
projectId: "project1",
promptName: "testPrompt",
});
});
});
describe("invalidateCache with Redis errors", () => {
it("should throw an error if Redis.eval fails", async () => {
mockRedis.smembers.mockRejectedValue(new Error("Redis error"));
await expect(
promptService.invalidateCache({
projectId: "project1",
promptName: "testPrompt",
}),
).rejects.toThrow("Redis error");
});
});
});
+143 -15
View File
@@ -23,11 +23,13 @@ type CreatePromptInDBParams = {
prompt: string;
labels: string[];
version: number;
config: Record<string, object | number | string>;
config: any;
projectId: string;
createdBy: string;
type?: PromptType;
tags?: string[];
createdAt?: Date;
updatedAt?: Date;
};
const createPromptInDB = async (params: CreatePromptInDBParams) => {
return await prisma.prompt.create({
@@ -44,6 +46,8 @@ const createPromptInDB = async (params: CreatePromptInDBParams) => {
createdBy: params.createdBy,
type: params.type,
tags: params.tags,
createdAt: params.createdAt,
updatedAt: params.updatedAt,
},
});
};
@@ -75,7 +79,7 @@ describe("/api/public/v2/prompts API Endpoint", () => {
const projectId = uuidv4();
const response = await makeAPICall(
"GET",
`/api/public/v2/prompts`,
baseURI,
undefined,
`Bearer ${projectId}`,
);
@@ -769,19 +773,26 @@ describe("/api/public/v2/prompts API Endpoint", () => {
});
const otherProjectId = "239ad00f-562f-411d-af14-831c75ddd875";
await prisma.organization.upsert({
where: { id: "other-org" },
create: { id: "other-org", name: "other-org" },
update: {},
});
await prisma.organizationMembership.upsert({
where: {
orgId_userId: { orgId: "other-org", userId: "user-test" },
},
create: { userId: "user-test", orgId: "other-org", role: "OWNER" },
update: { role: "OWNER" },
});
await prisma.project.upsert({
where: { id: otherProjectId },
create: {
id: otherProjectId,
name: "demo-app",
projectMembers: {
create: {
role: "OWNER",
userId: "user-test",
},
},
orgId: "other-org",
},
update: {},
update: { name: "demo-app", orgId: "other-org" },
});
await createPromptInDB({
@@ -833,20 +844,23 @@ describe("/api/public/v2/prompts API Endpoint", () => {
// Validate prompt-1 meta
expect(promptMeta1.name).toBe("prompt-1");
expect(promptMeta1.versions).toEqual([1, 2, 4]);
expect(promptMeta1.labels).toEqual(["production"]);
expect(promptMeta1.labels).toEqual(["production", "version2"]);
expect(promptMeta1.tags).toEqual([]);
expect(promptMeta1.lastUpdatedAt).toBeDefined();
// Validate prompt-2 meta
expect(promptMeta2.name).toBe("prompt-2");
expect(promptMeta2.versions).toEqual([1, 2, 3]);
expect(promptMeta2.labels).toEqual(["dev", "production", "staging"]);
expect(promptMeta2.tags).toEqual([]);
expect(promptMeta2.lastUpdatedAt).toBeDefined();
// Validate prompt-3 meta
expect(promptMeta3.name).toBe("prompt-3");
expect(promptMeta3.versions).toEqual([1]);
expect(promptMeta3.labels).toEqual(["production"]);
expect(promptMeta3.tags).toEqual(["tag-1"]);
expect(promptMeta3.lastUpdatedAt).toBeDefined();
// Validate pagination
expect(body.meta.page).toBe(1);
@@ -870,7 +884,7 @@ describe("/api/public/v2/prompts API Endpoint", () => {
expect(body.data).toHaveLength(1);
expect(body.data[0].name).toBe("prompt-1");
expect(body.data[0].versions).toEqual([1, 2, 4]);
expect(body.data[0].labels).toEqual(["production"]);
expect(body.data[0].labels).toEqual(["production", "version2"]);
expect(body.data[0].tags).toEqual([]);
// Validate pagination
@@ -991,6 +1005,112 @@ describe("/api/public/v2/prompts API Endpoint", () => {
expect(body.meta.totalPages).toBe(3);
expect(body.meta.totalItems).toBe(3);
});
it("should fetch lastConfig correctly for a prompt with multiple versions", async () => {
// no filters
const response = await makeAPICall("GET", `${baseURI}`);
expect(response.status).toBe(200);
const body = response.body as unknown as PromptsMetaResponse;
expect(body.data).toHaveLength(3);
expect(body.data.some((promptMeta) => promptMeta.name === "prompt-1")).toBe(
true,
);
expect(body.data.some((promptMeta) => promptMeta.name === "prompt-2")).toBe(
true,
);
expect(body.data.some((promptMeta) => promptMeta.name === "prompt-3")).toBe(
true,
);
const prompt1 = body.data.find(
(promptMeta) => promptMeta.name === "prompt-1",
);
expect(prompt1).toBeDefined();
expect(prompt1?.lastConfig).toEqual({ version: 4 });
const prompt2 = body.data.find(
(promptMeta) => promptMeta.name === "prompt-2",
);
expect(prompt2).toBeDefined();
expect(prompt2?.lastConfig).toEqual({});
// validate with label filter
const response2 = await makeAPICall("GET", `${baseURI}?label=version2`);
expect(response2.status).toBe(200);
const body2 = response2.body as unknown as PromptsMetaResponse;
expect(body2.data).toHaveLength(1);
expect(body2.data[0].name).toBe("prompt-1");
expect(body2.data[0].lastConfig).toEqual({ version: 2 });
// validate with version filter
const response3 = await makeAPICall("GET", `${baseURI}?version=1`);
expect(response3.status).toBe(200);
const body3 = response3.body as unknown as PromptsMetaResponse;
expect(body3.data).toHaveLength(3);
const prompt1v1 = body3.data.find(
(promptMeta) => promptMeta.name === "prompt-1",
);
expect(prompt1v1?.lastConfig).toEqual({ version: 1 });
});
it("should respect the fromUpdatedAt and toUpdatedAt filters on GET /prompts", async () => {
// to and from
const from = new Date("2024-01-02T00:00:00.000Z");
const to = new Date("2024-01-04T00:00:00.000Z");
const response = await makeAPICall(
"GET",
`${baseURI}?fromUpdatedAt=${from.toISOString()}&toUpdatedAt=${to.toISOString()}`,
);
expect(response.status).toBe(200);
const body = response.body as unknown as PromptsMetaResponse;
expect(body.data).toHaveLength(1);
expect(body.data[0].name).toBe("prompt-1");
expect(body.data[0].lastUpdatedAt).toBe("2024-01-02T00:00:00.000Z");
expect(body.data[0].versions.length).toBe(1);
expect(body.meta.totalItems).toBe(1);
// only from
const response2 = await makeAPICall(
"GET",
`${baseURI}?fromUpdatedAt=${from.toISOString()}`,
);
expect(response2.status).toBe(200);
const body2 = response2.body as unknown as PromptsMetaResponse;
expect(body2.data).toHaveLength(1);
expect(body2.data[0].name).toBe("prompt-1");
expect(body2.data[0].lastUpdatedAt).toBe("2024-01-04T00:00:00.000Z");
expect(body2.data[0].versions.length).toBe(2);
expect(body2.meta.totalItems).toBe(1);
// only to
const response3 = await makeAPICall(
"GET",
`${baseURI}?toUpdatedAt=${to.toISOString()}`,
);
expect(response3.status).toBe(200);
const body3 = response3.body as unknown as PromptsMetaResponse;
expect(body3.data).toHaveLength(3);
expect(body3.data[0].name).toBe("prompt-1");
expect(body3.data[0].lastUpdatedAt).toBe("2024-01-02T00:00:00.000Z");
expect(body3.data[0].versions.length).toBe(2);
expect(body3.data[1].name).toBe("prompt-2");
expect(body3.data[1].lastUpdatedAt).toBe("2000-03-01T00:00:00.000Z");
expect(body3.data[1].versions.length).toBe(3);
expect(body3.data[2].name).toBe("prompt-3");
expect(body3.data[2].lastUpdatedAt).toBe("2000-01-01T00:00:00.000Z");
expect(body3.data[2].versions.length).toBe(1);
expect(body3.meta.totalItems).toBe(3);
});
});
const isPrompt = (x: unknown): x is Prompt => {
@@ -1029,17 +1149,19 @@ const mockPrompts = [
prompt: "prompt-1",
createdBy: "user-test",
projectId,
config: {},
config: { version: 1 },
version: 1,
updatedAt: new Date("2024-01-01T00:00:00.000Z"),
},
{
name: "prompt-1",
labels: ["production"],
labels: ["production", "version2"],
prompt: "prompt-1",
createdBy: "user-test",
projectId,
config: {},
config: { version: 2 },
version: 2,
updatedAt: new Date("2024-01-02T00:00:00.000Z"),
},
{
name: "prompt-1",
@@ -1047,8 +1169,9 @@ const mockPrompts = [
prompt: "prompt-1",
createdBy: "user-test",
projectId,
config: {},
config: { version: 4 },
version: 4,
updatedAt: new Date("2024-01-04T00:00:00.000Z"),
},
// Prompt with different labels
@@ -1060,6 +1183,7 @@ const mockPrompts = [
projectId,
config: {},
version: 1,
updatedAt: new Date("2000-01-01T00:00:00.000Z"),
},
{
name: "prompt-2",
@@ -1069,6 +1193,7 @@ const mockPrompts = [
projectId,
config: {},
version: 2,
updatedAt: new Date("2000-03-01T00:00:00.000Z"),
},
{
name: "prompt-2",
@@ -1078,6 +1203,7 @@ const mockPrompts = [
projectId,
config: {},
version: 3,
updatedAt: new Date("2000-02-01T00:00:00.000Z"),
},
// Prompt with different labels
@@ -1090,6 +1216,7 @@ const mockPrompts = [
config: {},
tags: ["tag-1"],
version: 1,
updatedAt: new Date("2000-01-01T00:00:00.000Z"),
},
// Prompt in different project
@@ -1101,5 +1228,6 @@ const mockPrompts = [
projectId: "239ad00f-562f-411d-af14-831c75ddd875",
config: {},
version: 1,
updatedAt: new Date("2000-01-01T00:00:00.000Z"),
},
];
@@ -50,7 +50,7 @@ describe("Build valid SQL queries", () => {
table: "traces_scores",
values: ["project-id"],
strings: [
` FROM traces t JOIN scores s ON t.id = s.trace_id AND s.data_type != 'CATEGORICAL' AND t.project_id = s.project_id WHERE t."project_id" = `,
` FROM traces t JOIN scores s ON t.id = s.trace_id AND t.project_id = s.project_id WHERE t."project_id" = `,
";",
],
} as const,
@@ -182,13 +182,19 @@ describe("Build valid SQL queries", () => {
describe("should retrieve data", () => {
it("should get a simple trace", async () => {
await prisma.organization.upsert({
where: { id: "other-org" },
create: { id: "other-org", name: "other-org" },
update: {},
});
await prisma.project.upsert({
where: { id: "different-project-id" },
create: {
id: "different-project-id",
name: "test-project",
orgId: "other-org",
},
update: {},
update: { name: "test-project", orgId: "other-org" },
});
await prisma.trace.createMany({
@@ -14,7 +14,7 @@ import {
GetScoreConfigResponse,
PostScoreConfigResponse,
GetScoreConfigsResponse,
} from "@/src/features/public-api/types/score-configs";
} from "@langfuse/shared";
const configOne = [
{

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