Compare commits

...
136 Commits
Author SHA1 Message Date
Marc Klingen 6aa5b20cce chore: release v2.60.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-05 15:36:50 +02:00
Marc Klingen fa0011f80f fix(db): sequential ordering of migrations 2024-07-05 15:09:43 +02:00
Marc KlingenandGitHub 82cf939be6 perf(ui): do not return trace input/output/metadata on traces.all trpc route (#2546) 2024-07-05 15:02:41 +02:00
Marc KlingenandGitHub ba02577ace docs(api): fix docs of delete /models/:id route (#2548) 2024-07-05 14:51:39 +02:00
Marc KlingenandGitHub 474634b632 perf(ui): disable trpc batching for scores.filterOptions (#2547) 2024-07-05 12:37:19 +00:00
Marc KlingenandGitHub 2441af3981 docs: Update README.md 2024-07-05 13:59:05 +02:00
Marc KlingenandGitHub 1f9b2f600c feat(ui): add 5-minute and 3-hour timeframes to dashboard (#2544) 2024-07-05 10:37:04 +00:00
68fe3357b6 feat(scores): add non-numeric scores and score-configs via API, drop non-null cond on value in scores table (#2499)
* chore(scores): drop not null 'value' column

* fix: resulting TS

* fix: adjust seeder to new schema

* push

* docs

* types: add union types for scores

* push

* push

* push

* push

* feat(api): POST score-configs POST scores (#2436)

* feat(api): add POST score-configs

* feat(api): add POST scores with configs

* fix: types

* push

* docs(scores): add

* push

* push

* feat(api): allow filtering by `configId`

* push

* push

* push

* push

* push

* push

* push

* push

* push

* push

* push

* refactor: pass `value` as `string | number` in score ingestion

* fix test

* push

* test

* push

* refactor: use `withMiddlewares` handler

* push

* push

* refactor scores api

* refactor: config api to new schema

* rename

* refactor

* push

* rm: explicit `trpcErrorToast(error)`

* rename

* fixup

* fixup

* fixup

* fixup

* fixup

* feat: add filter by `dataType` to GET all scores endpoint

* refactor: reference score data types manually instead of via the enum to protect against changes of the enum

* refactor

* push

* refactor

* push

* docs: adjust and regenerate docs

* push

* push

* push

* push

* push

* add ingestion docs

* push

* push

* push

* nit

* don't use database calls in score tests

* rm typecast from public api score-config

* cast prisma query raw as unknown

* refactor: extract helpers to public-api types

* unify types

* comments

* fix types

* push

* push

* push

* push

* push

* push

* push

* tyoes

* clean up types

* fern docs

* fix typo

* push

---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-07-05 12:11:58 +02:00
Hassieb PakzadandGitHub fc007fd0fa perf: read calculated cost from observations table directly if available (#2500) 2024-07-05 11:10:55 +02:00
Hassieb Pakzad e850ac6578 chore: add temp column drop in backfill script 2024-07-05 11:04:44 +02:00
Marc Klingen c65f877063 docs(api): scores api 2024-07-05 00:23:30 +02:00
Marc Klingen 3fb7ec55fb chore: release v2.59.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-04 14:55:24 +02:00
Marc KlingenandGitHub ddf90217bc feat(models): add aws bedrock model names for anthropic models (#2537) 2024-07-04 14:53:10 +02:00
Marc KlingenandGitHub 63968392cc feat(ui): format token numbers (#2538)
* feat(ui): format token numbers

* remove unused import
2024-07-03 22:23:35 +00:00
Max DeichmannandGitHub 0f76b010df fix: rephrase ingestion error event (#2536) 2024-07-03 19:58:16 +00:00
Hassieb PakzadandGitHub 9b718c556f chore: fixes flaky vitest suite by running in child process (#2533) 2024-07-03 19:20:09 +02:00
Marc Klingen 05d28db250 chore: release v2.59.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-03 18:51:46 +02:00
Marc KlingenandGitHub c97ffde598 fix(ui): utc startDate in model form (#2532) 2024-07-03 16:43:48 +00:00
Marc KlingenandGitHub 42c12b7ce7 feat(api): add models api routes (#2531) 2024-07-03 16:06:59 +00:00
Hassieb PakzadandGitHub 602e4998d4 fix: calculate costs only if there are no user provided cost (#2530) 2024-07-03 15:22:49 +02:00
marliessophieandGitHub 29da3162da feat(trace timeline): add expand/collapse all buttons (#2524) 2024-07-03 07:47:32 +00:00
Marc Klingen 747b7e9b7f fix(ui): model based eval template builder 2024-07-03 01:46:02 +02:00
Marc KlingenandGitHub 966894cee6 docs(api): improve display names of api routes (#2526) 2024-07-02 17:33:48 +00:00
Marc KlingenandGitHub 9ba9098303 fix(ui): bg of trace tabs on published traces (#2525)
fix(ui): background of trace tabs on published traces
2024-07-02 17:11:50 +00:00
Hassieb PakzadandGitHub 5561668b43 fix: keep the tmp col on backfill script (#2518) 2024-07-02 14:30:29 +02:00
Marc KlingenandGitHub 3fe5bf16d1 perf(ui): load metdata individually on trace table (#2506) 2024-07-02 08:54:57 +00:00
Marc KlingenandGitHub bb19675769 perf(ui): fetch metadata individually on generations table (#2507) 2024-07-02 10:47:11 +02:00
Marc Klingen 2fb70c207b chore: release v2.58.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-02 10:28:38 +02:00
Ikko Eltociear AshimineandGitHub 0bbc27d53b chore: fix typo (#2490) 2024-07-01 22:23:41 +02:00
Max DeichmannandGitHub ad7f273638 fix: capture worker sentry exceptions (#2505) 2024-07-01 20:17:33 +00:00
Marc KlingenandGitHub 7637d45fd3 chore: minor refactor of api abstractions and errors (#2504) 2024-07-01 21:55:58 +02:00
Max DeichmannandGitHub 995b30b3d7 chore: sentry sample rate in env (#2503) 2024-07-01 18:48:00 +00:00
Max DeichmannandGitHub 26d615842c chore: rename db migration disablement environment variable (#2502) 2024-07-01 20:30:10 +02:00
Marc KlingenandGitHub 61cf13a1db feat(api): datasets v2 api & new typed public api interface for improved tests (#2484) 2024-07-01 15:08:37 +00:00
Max DeichmannandGitHub 078f7d6f83 infra: adjust sample rate (#2494) 2024-07-01 08:58:04 +00:00
Hassieb PakzadandGitHub b4ce9a486b chore: adds backfill script for generations calculated costs (#2466) 2024-07-01 10:25:03 +02:00
Max DeichmannandGitHub 801090fdf1 deps: upgrade sentry (#2492)
something
2024-06-30 18:29:59 +00:00
Hassieb PakzadandGitHub 4fd42b7b5b fix(tokenCost): if user provided any cost point, do not calculate costs (#2487) 2024-06-29 09:17:53 +00:00
Max DeichmannandGitHub 2591f0d7b7 infra: increase sentry sample rate (#2485) 2024-06-28 17:18:46 +02:00
marliessophieandGitHub 95e8a2a793 fix(trace timeline view): prefix tree keys with respective item (#2483)
* fix(trace timeline view): prefix tree key's with respective item

* push
2024-06-28 14:07:05 +00:00
Marc KlingenandGitHub 8b367b1a10 feat(ui): auto-select first operator in filter builder (#2482) 2024-06-28 13:38:52 +00:00
Marc Klingen 2b80cba1fe fix: include *.langfuse.com in CSP header 2024-06-28 15:06:32 +02:00
Max DeichmannandGitHub 0b7fd5e70e fix: do not report unauthorized error to sentry (#2480) 2024-06-28 10:57:52 +02:00
Marc KlingenandGitHub fcec58f976 fix: include scores with missing traces in ui and api (#2477) 2024-06-27 23:48:10 +00:00
Marc Klingen 62101ba598 docs: add LANFUSE_WEB_MIGRATION_DISABLED to example env 2024-06-28 01:10:23 +02:00
Marc KlingenandGitHub 5a6eba5242 fix(ci): platform arg optional in dockerfiles (#2476) 2024-06-28 00:34:17 +02:00
Marc Klingen 20c2bb9dc2 Revert "fix(ci): set DOCKER_DEFAULT_PLATFORM in porter deployments"
This reverts commit 3196ee7d04.
2024-06-27 22:29:30 +02:00
Marc Klingen 4d0e431f2a Revert "fix(ci): conditionally set platform in dockerfiles if available"
This reverts commit 3bed36e0e7.
2024-06-27 22:29:23 +02:00
Marc Klingen 3bed36e0e7 fix(ci): conditionally set platform in dockerfiles if available 2024-06-27 21:55:46 +02:00
Marc Klingen 3196ee7d04 fix(ci): set DOCKER_DEFAULT_PLATFORM in porter deployments 2024-06-27 21:48:31 +02:00
rennokkiandGitHub 5219bb74ac feat: Docker arm64 builds (#2453) 2024-06-27 19:07:09 +02:00
Marc KlingenandGitHub 555a16bce7 chore(cloud): specify aws regions in region modal (#2444) 2024-06-27 16:34:44 +02:00
porter-deployment-app[bot]andGitHub 499a7b40f1 infra: porter web eu (#2464) 2024-06-27 16:26:49 +02:00
porter-deployment-app[bot]andGitHub 8e9acb2fe8 Enable Porter Application worker (#2463) 2024-06-27 16:18:07 +02:00
Marc KlingenandGitHub dda176f3f9 ci: add names to tests based on matrix (#2462) 2024-06-27 14:13:21 +00:00
Max DeichmannandGitHub 48aa358e99 infra: remove preview deployment (#2460)
something
2024-06-27 15:22:04 +02:00
Marc KlingenandGitHub 6127f23758 feat(auth): add AUTH_CUSTOM_SCOPE to change oauth scopes of custom oauth setups (#2459) 2024-06-27 13:14:38 +00:00
porter-deployment-app[bot]andGitHub bdfc51ca72 infra: add staging preview deployments (#2449) 2024-06-26 23:40:09 +02:00
Max DeichmannandGitHub 0bbf87abfd fix: do not execute db migration in web container if configured in env (#2446) 2024-06-26 19:25:56 +00:00
Hassieb Pakzad c7c934fbab chore: release v2.57.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20, 12) (push) Waiting to run
CI/CD / tests-web (20, 15) (push) Waiting to run
CI/CD / tests-worker (20, 12) (push) Waiting to run
CI/CD / tests-worker (20, 15) (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-06-26 18:43:32 +02:00
Hassieb PakzadandGitHub 3125f275e7 perf(tables): add start time filter to filter option queries (#2445) 2024-06-26 18:39:45 +02:00
Max DeichmannandGitHub 7cb27deb22 perf: increase worker sample rate (#2443) 2024-06-26 13:42:06 +00:00
Max DeichmannandGitHub 4b72fecb7f fix: improve porter naming (#2442) 2024-06-26 15:22:20 +02:00
Hassieb PakzadandGitHub 23efeb69cd fix(tokenCost): carryover user provided cost even if no model match (#2441) 2024-06-26 15:13:08 +02:00
Marc Klingen 479d85f4ac fix(cloud): % value of plan limit usage 2024-06-26 12:40:25 +02:00
Marc Klingen dfe9f802c8 fix(ui): bg of json user messages in pretty-view 2024-06-26 11:57:04 +02:00
porter-deployment-app[bot]GitHubporter-deployment-app[bot] <87230664+porter-deployment-app[bot]@users.noreply.github.com>Max Deichmann
6928d561b9 infra: Enable Porter Application (#2435)
* infra: Enable Porter Application worker (#2434)

* Create porter_stack_web.yml file

* push

* push

---------

Co-authored-by: porter-deployment-app[bot] <87230664+porter-deployment-app[bot]@users.noreply.github.com>
Co-authored-by: Max Deichmann <m.deichmann@tum.de>
2024-06-26 10:14:42 +02:00
Max DeichmannandGitHub f97bd913b6 fix: correct parsing of scores for evals (#2429) 2024-06-25 19:17:15 +00:00
Max DeichmannandGitHub 3bec8a535b chore: improve worker logs (#2428) 2024-06-25 18:04:22 +00:00
Hassieb PakzadandGitHub 5a9f47faef feat: write token costs at ingestion time (#2414) 2024-06-25 18:27:43 +02:00
Hassieb PakzadandGitHub 3ee6e936fb feat: add claude sonnet 3.5 to playground (#2425) 2024-06-25 17:48:18 +02:00
Max DeichmannandGitHub 9346e6feb5 fix: turn off logging (#2427) 2024-06-25 15:42:48 +00:00
Max DeichmannandGitHub 94387d1f6b feat: improve sentry on worker (#2426) 2024-06-25 15:15:49 +00:00
Max DeichmannandGitHub e2bcc6bf4e fix: capture errors in sentry (#2424) 2024-06-25 13:33:06 +00:00
Max DeichmannandGitHub defca401c9 deps: upgrade langchain in web project (#2423) 2024-06-25 12:37:32 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Max Deichmann
d12c623a96 chore(deps): bump pino from 8.20.0 to 9.2.0 (#2314)
Bumps [pino](https://github.com/pinojs/pino) from 8.20.0 to 9.2.0.
- [Release notes](https://github.com/pinojs/pino/releases)
- [Commits](https://github.com/pinojs/pino/compare/v8.20.0...v9.2.0)

---
updated-dependencies:
- dependency-name: pino
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Max Deichmann <m.deichmann@tum.de>
2024-06-25 12:19:34 +00:00
Marc Klingen 1eb945820c chore: release v2.56.1
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20, 12) (push) Waiting to run
CI/CD / tests-web (20, 15) (push) Waiting to run
CI/CD / tests-worker (20, 12) (push) Waiting to run
CI/CD / tests-worker (20, 15) (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-06-25 13:45:44 +02:00
Max DeichmannandGitHub dcd1703668 security: upgrade langchain (#2421) 2024-06-25 10:59:27 +00:00
Max DeichmannandGitHub 42b0a49e2d fix: flaky eval test (#2418) 2024-06-25 10:41:11 +00:00
983f34f49d fix: ordering of ingestion events by timestamp (#2417)
---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-06-25 12:31:40 +02:00
Max DeichmannandGitHub 3e66bb6dff perf: add prisma to worker sentry (#2413) 2024-06-24 17:02:17 +00:00
Max DeichmannandGitHub 2ea0b9bac4 fix: handle errors in worker API (#2412) 2024-06-24 18:21:20 +02:00
Max DeichmannandGitHub 2be49c2488 fix: typo in contributing file (#2411) 2024-06-24 18:00:25 +02:00
Max DeichmannandGitHub 1aeb79b913 fix: redis connection string with bull (#2410) 2024-06-24 16:46:26 +02:00
Max DeichmannandGitHub 2084f69bec feat: allow redis connection via connection string (#2409) 2024-06-24 16:27:55 +02:00
Marc KlingenandGitHub 87fd20ec9e feat(models): add support for anthropic 3.5 sonnet (#2407)
Fixes LFE-1426
2024-06-24 13:55:38 +00:00
Max Deichmann e028117453 chore: release v2.56.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20, 12) (push) Waiting to run
CI/CD / tests-web (20, 15) (push) Waiting to run
CI/CD / tests-worker (20, 12) (push) Waiting to run
CI/CD / tests-worker (20, 15) (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-06-24 15:15:16 +02:00
Max DeichmannandGitHub 8483ac749c infra: add porter for US worker (#2406) 2024-06-24 15:10:27 +02:00
Marc KlingenandGitHub d40635a860 fix(ui): render dataset items with false as value correctly (#2404)
fix(ui): render dataset items with  as value correctly
2024-06-24 13:09:44 +00:00
marliessophieandGitHub 4e27b6f0f1 feat(api): add GET /score-configs/[configId] and GET /score-configs (#2361)
* feat(api): add GET /score-configs/[configId]

* feat(api): add GET /score-configs

* push

* test: servertest

* fern

* push

* push

* push

* push

* push

* docs: address feedback

* tests: address feedback to test for full objects

* fix: address feedback and properly verify config is of correct type

* push

* push

* push

* push
2024-06-24 10:13:20 +02:00
Marc KlingenandGitHub cd3d6b0251 fix(ui): number formatting while undefined (#2396)
fix(ui): number formatting while numbers are loading
2024-06-21 13:30:36 +00:00
Max DeichmannandGitHub eea071c32f infra: add public variables to docker build steps (#2393) 2024-06-21 14:05:20 +02:00
MukeshKGandGitHub aebcac4b57 chore: removing 'version' from docker-compose.yml file (#2389) 2024-06-21 12:56:37 +02:00
porter-deployment-app[bot]GitHubporter-deployment-app[bot] <87230664+porter-deployment-app[bot]@users.noreply.github.com>
7e4836a345 Enable Porter Application worker (#2392)
Create porter_stack_worker.yml file

Co-authored-by: porter-deployment-app[bot] <87230664+porter-deployment-app[bot]@users.noreply.github.com>
2024-06-21 10:37:41 +02:00
porter-deployment-app[bot]GitHubporter-deployment-app[bot] <87230664+porter-deployment-app[bot]@users.noreply.github.com>Max Deichmann
67d37ec66b infra: Enable Porter Application web (#2391)
Create porter_stack_web.yml file

Co-authored-by: porter-deployment-app[bot] <87230664+porter-deployment-app[bot]@users.noreply.github.com>
Co-authored-by: Max Deichmann <m.deichmann@tum.de>
2024-06-21 10:25:37 +02:00
porter-deployment-app[bot]GitHubporter-deployment-app[bot] <87230664+porter-deployment-app[bot]@users.noreply.github.com>
4b36304a55 Delete Porter Application web (#2387)
Delete porter_stack_web.yml file

Co-authored-by: porter-deployment-app[bot] <87230664+porter-deployment-app[bot]@users.noreply.github.com>
2024-06-21 10:05:28 +02:00
Hassieb PakzadandGitHub d3abad82e9 fix: use correct hook to determine cloud environment (#2383) 2024-06-20 17:30:47 +02:00
Marc Klingen dc672099dc docs(api): update docs on api spec 2024-06-20 16:01:30 +02:00
porter-deployment-app[bot]GitHubporter-deployment-app[bot] <87230664+porter-deployment-app[bot]@users.noreply.github.com>
5e2ab2e25f Enable Porter Application web (#2380)
Create porter_stack_web.yml file

Co-authored-by: porter-deployment-app[bot] <87230664+porter-deployment-app[bot]@users.noreply.github.com>
2024-06-20 09:40:20 +00:00
Marc KlingenandGitHub 770d1ab7d4 docs: Update dataset-items.yml 2024-06-20 08:59:39 +02:00
Marc KlingenandGitHub b6a31f6146 fix(self-hosted): enable air-gapped use by disabling online check of react query (#2374) 2024-06-19 23:02:51 +02:00
Hassieb PakzadandGitHub 33155deb83 fix: overflow LLMApiKeyList baseUrl (#2372) 2024-06-19 18:36:14 +02:00
Hassieb PakzadandGitHub 65b4429272 fix: s3 link ttl (#2370) 2024-06-19 12:24:43 +00:00
Hassieb PakzadandGitHub 2a30f5447d perf: optimize indices (#2367) 2024-06-18 23:12:54 +02:00
Max DeichmannandGitHub f147d2bde3 fix: flaky worker tests (#2368)
push
2024-06-18 17:58:48 +00:00
Max DeichmannandGitHub 034fbac052 feat: configure default lookback for dashboards and tables (#2364) 2024-06-18 16:32:35 +02:00
Hassieb PakzadandGitHub abbca987b4 fix: hide batch export button for selfhosters (#2365) 2024-06-18 16:21:42 +02:00
Hassieb PakzadandGitHub f8080ca04c feat: adds exports to sessions table (#2360) 2024-06-18 16:09:18 +02:00
marliessophieandGitHub 0396525f1b style(annotation): small UI/UX improvements (#2359)
* style(annotation): validate numbers in annotation onKeyUp

* style(drawer): round top left corner

* style: mark discard comment as destructive

* refactor: extract config categories to type in shared

* style: improve annotation range display in configs table

* style: ensure form caters to a lot of categories

* style: show entire description on screen

* style(scores_table): allow hiding author

* push

* push

* push

* push
2024-06-17 17:04:19 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
407f442b4f chore(deps): bump the sentry group across 1 directory with 4 updates (#2332)
Bumps the sentry group with 4 updates in the / directory: [@sentry/nextjs](https://github.com/getsentry/sentry-javascript), [@sentry/node](https://github.com/getsentry/sentry-javascript), [@sentry/profiling-node](https://github.com/getsentry/sentry-javascript) and [@sentry/types](https://github.com/getsentry/sentry-javascript).


Updates `@sentry/nextjs` from 8.8.0 to 8.9.2
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/8.8.0...8.9.2)

Updates `@sentry/node` from 8.8.0 to 8.9.2
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/8.8.0...8.9.2)

Updates `@sentry/profiling-node` from 8.8.0 to 8.9.2
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/8.8.0...8.9.2)

Updates `@sentry/types` from 8.8.0 to 8.9.2
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/8.8.0...8.9.2)

---
updated-dependencies:
- dependency-name: "@sentry/nextjs"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: sentry
- dependency-name: "@sentry/node"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: sentry
- dependency-name: "@sentry/profiling-node"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: sentry
- dependency-name: "@sentry/types"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: sentry
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-17 17:02:36 +02:00
Max DeichmannandGitHub 6b36836fd6 fix: correct sentry 8.0 upgrade (#2357) 2024-06-17 15:21:33 +02:00
Marc Klingen 0a08f19ceb chore: release v2.55.1
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20, 12) (push) Waiting to run
CI/CD / tests-web (20, 15) (push) Waiting to run
CI/CD / tests-worker (20, 12) (push) Waiting to run
CI/CD / tests-worker (20, 15) (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-06-17 14:41:38 +02:00
Marc KlingenandGitHub aff27ef922 perf(prompts): drop fk contraint on observations.prompt_id (#2355) 2024-06-17 12:05:32 +00:00
Richard KrümmelandGitHub 7c65366eac fix(dashboard): change model latency unit to seconds (#2356) 2024-06-17 13:53:44 +02:00
marliessophieandGitHub f2152dd493 perf(sessions): improve filterOptions api (#2337)
* perf(sessions): improve `filterOptions` api

* limit to 1000 results

* fix: remove unintentional line from `traces.filterOptions`

* feat(table_filters): add free text search option

* push

* show custom select option in `userIds` column on sessions table

* style: fix padding

* style: improve styling

* simplify

* push

* feat(multi-select): show `customSelect` also when "no results found"
2024-06-17 08:14:32 +00:00
Marc KlingenandGitHub 42e446ed55 fix(ui): improve password-reset UX (#2350) 2024-06-15 01:16:30 +00:00
Marc KlingenandGitHub 77a65dcf78 fix(cloud): number formatting in usage meter widget (#2347) 2024-06-14 18:19:16 +00:00
Marc Klingen e9d4594e80 fix(ui): label of usage chart in settings 2024-06-14 19:55:27 +02:00
Marc Klingen a0cf26baec perf(settings): split usagemetering into separate trpc call 2024-06-14 19:53:03 +02:00
Marc KlingenandGitHub 08f4e64eb2 docs: add hiring to readme 2024-06-14 16:19:49 +02:00
Marc Klingen 788d1d67b0 chore: release v2.55.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20, 12) (push) Waiting to run
CI/CD / tests-web (20, 15) (push) Waiting to run
CI/CD / tests-worker (20, 12) (push) Waiting to run
CI/CD / tests-worker (20, 15) (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-06-14 16:06:12 +02:00
Marc KlingenandGitHub 01f95dac20 feat(auth): password reset for email/password users via email-link auth (#2276) 2024-06-14 14:00:45 +00:00
Marc Klingen f6f84d9d39 fix(security): add sentry to csp headers 2024-06-14 14:28:03 +02:00
marliessophieandGitHub ddfc42f739 feat(score_configs): add archive functionality (#2340)
* feat(score_configs): add archive functionality

* refactor: rename `AnnotateButton` to `AnnotateDrawer`

* support archived behaviour on annotation drawer

* push

* push

* adjust wording

* fix: sort observations by start time
2024-06-14 12:15:59 +00:00
Max Deichmann 22a391e315 chore: release v2.54.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20, 12) (push) Waiting to run
CI/CD / tests-web (20, 15) (push) Waiting to run
CI/CD / tests-worker (20, 12) (push) Waiting to run
CI/CD / tests-worker (20, 15) (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-06-12 19:11:33 +02:00
Max DeichmannandGitHub 46b19279c4 fix: clean up broken migration history (#2329) 2024-06-12 19:03:45 +02:00
Hassieb PakzadandGitHub 716ed04e60 perf: use composite index for prompt.metrics (#2328) 2024-06-12 17:13:28 +02:00
marliessophieandGitHub 87798edd95 feat(analytics): add posthoc event for trace timeline (#2327)
* feat(analytics): add posthoc event for trace timeline

* style: don't round corners in trace timeline

* style: title on tree item names
2024-06-12 13:46:54 +00:00
marliessophieandGitHub 7058e05ed5 build(deps): bump mui/utils version to "6.0.0-alpha.9" (#2323)
* build(deps): bump mui/utils version to "6.0.0-alpha.9"

* push
2024-06-12 13:06:29 +00:00
Hassieb PakzadandGitHub fab0ba4f11 perf: adds observations table index projectId promptId (#2325) 2024-06-12 14:46:55 +02:00
Hassieb PakzadandGitHub 2094db0101 perf: increase chatCompletion function timeout (#2326) 2024-06-12 14:20:42 +02:00
Hassieb PakzadandGitHub f23bea9272 fix: add llmapikeys:read scope to viewer role (#2324) 2024-06-12 11:10:28 +02:00
adb67f23e6 feat(auth): add google workspace domain allowlist (#2322)
---------

Co-authored-by: aablsk <alex.bielski@chaptr.xyz>
Co-authored-by: aablsk <70068680+aablsk@users.noreply.github.com>
2024-06-12 02:43:13 +02:00
Marc Klingen ed087ae60b feat(ui): add link to roadmap and icon to EE badge 2024-06-12 02:08:10 +02:00
Marc Klingen 229320a3db nit: copy 2024-06-12 01:37:19 +02:00
Marc Klingen 38b3e8aaa4 chore(auth): add console.warn for sign out when missing db user 2024-06-12 01:06:01 +02:00
265 changed files with 14066 additions and 4132 deletions
+11
View File
@@ -9,6 +9,8 @@
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=
# Next Auth
# NEXTAUTH_URL does not need to be set when deploying on Vercel
@@ -23,6 +25,7 @@ SALT="salt"
# Use CSP headers to enforce HTTPS, optional
LANGFUSE_CSP_ENFORCE_HTTPS="true"
# Docker only, optional
# PORT=3000
# HOSTNAME=localhost
@@ -34,6 +37,9 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
# 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
@@ -43,6 +49,7 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
# AUTH_GOOGLE_CLIENT_ID=
# AUTH_GOOGLE_CLIENT_SECRET=
# AUTH_GOOGLE_ALLOW_ACCOUNT_LINKING=false
# AUTH_GOOGLE_ALLOWED_DOMAINS=langfuse.com,google.com # optional allowlist of workspace domains that can sign in via Google
# AUTH_GITHUB_CLIENT_ID=
# AUTH_GITHUB_CLIENT_SECRET=
# AUTH_GITHUB_ALLOW_ACCOUNT_LINKING=false
@@ -66,6 +73,7 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
# AUTH_CUSTOM_CLIENT_SECRET=
# AUTH_CUSTOM_ISSUER=
# AUTH_CUSTOM_NAME=
# AUTH_CUSTOM_SCOPE="openid email profile" # optional
# AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING=false
# Transactional email, optional
@@ -80,6 +88,7 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
# S3_SECRET_ACCESS_KEY=
# S3_BUCKET_NAME=
# S3_REGION=
# BATCH_EXPORT_DOWNLOAD_LINK_EXPIRATION_HOURS=
# Exports are streamed to S3 in pages to avoid memory issues
# The page size can be adjusted if needed to optimize performance
@@ -122,6 +131,8 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
# NEXT_SENTRY_ORG=
# NEXT_SENTRY_PROJECT=
# SENTRY_AUTH_TOKEN=
# Used to determine the Sentry sample rate
# LANGFUSE_TRACING_SAMPLE_RATE=
# Cloudflare Turnstile
# NEXT_PUBLIC_TURNSTILE_SITE_KEY=
+23
View File
@@ -64,9 +64,11 @@ jobs:
tests-web:
runs-on: ubuntu-latest
name: tests-web (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }})
strategy:
matrix:
node-version: [20]
postgres-version: [12, 15]
steps:
- name: Set Swap Space
uses: pierotofy/set-swap-space@master
@@ -97,6 +99,9 @@ jobs:
run: |
docker compose -f docker-compose.dev.yml up -d
sleep 5 # Wait for PostgreSQL to accept connections
docker compose ps
env:
POSTGRES_VERSION: ${{ matrix.postgres-version }}
- name: Seed DB
run: |
@@ -114,9 +119,11 @@ jobs:
tests-worker:
runs-on: ubuntu-latest
name: tests-worker (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }})
strategy:
matrix:
node-version: [20]
postgres-version: [12, 15]
steps:
- name: Set Swap Space
uses: pierotofy/set-swap-space@master
@@ -147,6 +154,7 @@ jobs:
run: |
docker compose -f docker-compose.dev.yml up -d
sleep 5 # Wait for PostgreSQL to accept connections
docker compose ps
- name: Seed DB
run: |
@@ -184,6 +192,7 @@ jobs:
- 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
@@ -250,6 +259,14 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Extract metadata (tags, labels) for Docker
id: meta-web
uses: docker/metadata-action@v4
@@ -273,6 +290,9 @@ jobs:
push: true
tags: ${{ steps.meta-web.outputs.tags }}
labels: ${{ steps.meta-web.outputs.labels }}
platforms: |
linux/amd64
linux/arm64
- name: Extract metadata (tags, labels) for Docker
id: meta-worker
@@ -297,3 +317,6 @@ jobs:
push: true
tags: ${{ steps.meta-worker.outputs.tags }}
labels: ${{ steps.meta-worker.outputs.labels }}
platforms: |
linux/amd64
linux/arm64
@@ -0,0 +1,29 @@
"on":
push:
branches:
- 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 }}
@@ -0,0 +1,29 @@
"on":
push:
branches:
- main
name: Deploy to web (Staging)
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: "4037"
PORTER_DEPLOYMENT_TARGET_ID: c22d8fa2-f97c-4e1f-9cae-83cc6bfbad35
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: web
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4037 }}
@@ -0,0 +1,29 @@
"on":
push:
branches:
- production
name: Deploy to web (US 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: "4054"
PORTER_DEPLOYMENT_TARGET_ID: cddc0f2a-bb19-401a-9819-29f59f76b101
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_4054 }}
@@ -1,8 +1,8 @@
"on":
push:
branches:
- main
name: Deploy to worker
- production
name: Deploy to worker (EU temp)
jobs:
porter-deploy:
runs-on: ubuntu-latest
@@ -18,12 +18,12 @@ jobs:
timeout-minutes: 30
run: exec porter apply
env:
PORTER_CLUSTER: "3959"
PORTER_DEPLOYMENT_TARGET_ID: d2bb23e4-1d77-48f2-a113-383d076959b3
PORTER_HOST: https://dashboard.getporter.dev
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_3959 }}
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4060 }}
@@ -0,0 +1,29 @@
"on":
push:
branches:
- main
name: Deploy to worker (staging)
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: "4037"
PORTER_DEPLOYMENT_TARGET_ID: c22d8fa2-f97c-4e1f-9cae-83cc6bfbad35
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_4037 }}
@@ -0,0 +1,29 @@
"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 }}
@@ -0,0 +1,29 @@
"on":
push:
branches:
- production
name: Deploy to worker (US 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: "4054"
PORTER_DEPLOYMENT_TARGET_ID: cddc0f2a-bb19-401a-9819-29f59f76b101
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_4054 }}
+1 -1
View File
@@ -55,7 +55,7 @@ A good first step is to search for open [issues](https://github.com/langfuse/lan
> [!NOTE]
> Infrastructure will change in Langfuse version 3.0. More in the [GitHub Discussions](https://github.com/orgs/langfuse/discussions/1902).
> `langfuse/langfuse/worker` is under active development and not recommended for production use in Langfuse 2.x
> `langfuse/langfuse/worker` is under active development and not recommended for production use in Langfuse 2.x.
```mermaid
flowchart TB
+3 -1
View File
@@ -24,6 +24,8 @@
</div>
<span>Langfuse uses <a href="https://github.com/orgs/langfuse/discussions"><strong>Github Discussions</strong></a> for Support and Feature Requests.</span>
<br/>
<span>We're hiring. <a href="https://langfuse.com/careers"><strong>Join us</strong></a> in Backend Engineering, Product Engineering, and Developer Relations.</span>
<br/>
<br/>
<div>
<a href="https://github.com/langfuse/langfuse/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-red.svg?style=flat-square" alt="MIT License"></a>
@@ -157,7 +159,7 @@ This repository is MIT licensed, except for the `ee` folders. See [LICENSE](LICE
### GET API to export your data
[**GET routes**](https://langfuse.com/docs/integrations/api) to use data in downstream applications (e.g. embedded analytics).
[**GET routes**](https://langfuse.com/docs/integrations/api) to use data in downstream applications (e.g. embedded analytics). You can also access them conveniently via the SDKs ([docs](https://langfuse.com/docs/query-traces)).
### Security & Privacy
+4
View File
@@ -5,6 +5,8 @@ services:
build:
dockerfile: ./web/Dockerfile
context: .
args:
- NEXT_PUBLIC_LANGFUSE_CLOUD_REGION=${NEXT_PUBLIC_LANGFUSE_CLOUD_REGION}
depends_on:
- db
- redis
@@ -30,6 +32,8 @@ services:
build:
dockerfile: ./worker/Dockerfile
context: .
args:
- NEXT_PUBLIC_LANGFUSE_CLOUD_REGION=${NEXT_PUBLIC_LANGFUSE_CLOUD_REGION}
depends_on:
- db
- redis
+1 -3
View File
@@ -1,5 +1,3 @@
version: "3.5"
services:
redis:
image: redis:7.2.4
@@ -10,7 +8,7 @@ services:
- 6379:6379
db:
image: postgres
image: postgres:${POSTGRES_VERSION:-latest}
restart: always
command: ["postgres", "-c", "log_statement=all"]
environment:
-2
View File
@@ -1,5 +1,3 @@
version: "3.5"
services:
langfuse-server:
image: langfuse/langfuse:2
+4 -1
View File
@@ -11,7 +11,7 @@ import { encrypt, decrypt } from "@langfuse/shared/encryption";
import { SsoProviderSchema } from "./types";
import { type NextApiRequest, type NextApiResponse } from "next";
import { env } from "../env";
import { CustomSSOProvider } from "@langfuse/shared/src/server/auth";
import { CustomSSOProvider } from "@langfuse/shared/src/server";
// Local cache for SSO configurations
let cachedSsoConfigs: {
@@ -179,6 +179,9 @@ const dbToNextAuthProvider = (provider: SsoProviderSchema): Provider | null => {
id: getAuthProviderIdForSsoConfig(provider), // use the domain as the provider id as we use domain-specific credentials
...provider.authConfig,
clientSecret: decrypt(provider.authConfig.clientSecret),
authorization: {
params: { scope: provider.authConfig.scope ?? "openid email profile" },
},
});
else {
// Type check to ensure we handle all providers
+1
View File
@@ -84,6 +84,7 @@ export const CustomProviderSchema = base.extend({
clientId: z.string(),
clientSecret: z.string(),
issuer: z.string(),
scope: z.string().nullish(),
allowDangerousEmailAccountLinking: z.boolean().optional().default(false),
})
.nullish(),
+119 -3
View File
@@ -17,15 +17,131 @@ types:
id: optional<string>
traceId: string
name: string
value: double
value:
type: CreateScoreValue
docs: The value of the score. Must be passed as string for categorical scores, and numeric for boolean and numeric scores. Boolean score values must equal either 1 or 0 (true or false)
observationId: optional<string>
comment: optional<string>
Score:
dataType:
type: optional<ScoreDataType>
docs: When set, must match the score value's type. If not set, will be inferred from the score value or config
configId:
type: optional<string>
docs: Reference a score config on a score. When set, the score name must equal the config name and scores must comply with the config's range and data type. For categorical scores, the value must map to a config category. Numeric scores might be constrained by the score config's max and min values
examples:
- value:
name: "novelty"
value: 0.9
traceId: "cdef-1234-5678-90ab"
- value:
name: "consistency"
value: 1.2
dataType: "NUMERIC"
traceId: "cdef-1234-5678-90ab"
- value:
name: "accuracy"
value: 0.9
dataType: "NUMERIC"
configId: "9203-4567-89ab-cdef"
traceId: "cdef-1234-5678-90ab"
- value:
name: "toxicity"
value: "not toxic"
traceId: "cdef-1234-5678-90ab"
- value:
name: "correctness"
value: "partially correct"
dataType: "CATEGORICAL"
configId: "1234-5678-90ab-cdef"
traceId: "cdef-1234-5678-90ab"
- value:
name: "hallucination"
value: 0
dataType: "BOOLEAN"
traceId: "cdef-1234-5678-90ab"
- value:
name: "helpfulness"
value: 1
dataType: "BOOLEAN"
configId: "1234-5678-90ab-cdef"
traceId: "cdef-1234-5678-90ab"
NumericScore:
properties:
id: string
traceId: string
name: string
value: double
value:
type: double
docs: The numeric value of the score
source: ScoreSource
observationId: optional<string>
timestamp: datetime
comment: optional<string>
configId:
type: optional<string>
docs: Reference a score config on a score. When set, config and score name must be equal and value must comply to optionally defined numerical range
BooleanScore:
properties:
id: string
traceId: string
name: string
value:
type: double
docs: The numeric value of the score. Equals 1 for "True" and 0 for "False"
stringValue:
type: string
docs: The string representation of the score value. Is inferred from the numeric value and equals "True" or "False"
source: ScoreSource
observationId: optional<string>
timestamp: datetime
comment: optional<string>
configId:
type: optional<string>
docs: Reference a score config on a score. When set, config and score name must be equal
CategoricalScore:
properties:
id: string
traceId: string
name: string
value:
type: optional<double>
docs: Only defined if a config is linked. Represents the numeric category mapping of the stringValue
stringValue:
type: string
docs: The string representation of the score value. If no config is linked, can be any string. Otherwise, must map to a config category
source: ScoreSource
observationId: optional<string>
timestamp: datetime
comment: optional<string>
configId:
type: optional<string>
docs: Reference a score config on a score. When set, config and score name must be equal and stringValue must map to a config category
Score:
discriminant: "dataType"
union:
NUMERIC:
type: NumericScore
docs: "Score with NUMERIC data type"
CATEGORICAL:
type: CategoricalScore
docs: "Score with CATEGORICAL data type"
BOOLEAN:
type: BooleanScore
docs: "Score with BOOLEAN data type"
ScoreSource:
enum:
- ANNOTATION
- API
- EVAL
ScoreDataType:
enum:
- NUMERIC
- CATEGORICAL
- BOOLEAN
CreateScoreValue:
discriminated: false
union:
- string
- double
docs: The value of the score. Must be passed as string for categorical scores, and numeric for boolean and numeric scores
+130 -19
View File
@@ -111,18 +111,103 @@ types:
totalCost:
docs: USD total cost, defaults to input+output
type: optional<double>
Score:
ScoreConfig:
docs: Configuration for a score
properties:
id: string
name: string
createdAt: datetime
updatedAt: datetime
projectId: string
dataType: ScoreDataType
isArchived:
type: boolean
docs: Whether the score config is archived. Defaults to false
minValue:
type: optional<double>
docs: Sets minimum value for numerical scores. If not set, the minimum value defaults to -∞
maxValue:
type: optional<double>
docs: Sets maximum value for numerical scores. If not set, the maximum value defaults to +∞
categories:
type: optional<list<ConfigCategory>>
docs: Configures custom categories for categorical scores
description: optional<string>
ConfigCategory:
properties:
value: double
label: string
NumericScore:
properties:
id: string
traceId: string
name: string
value: double
value:
type: double
docs: The numeric value of the score
source: ScoreSource
observationId: optional<string>
timestamp: datetime
comment: optional<string>
DatasetCore:
configId:
type: optional<string>
docs: Reference a score config on a score. When set, config and score name must be equal and value must comply to optionally defined numerical range
BooleanScore:
properties:
id: string
traceId: string
name: string
value:
type: double
docs: The numeric value of the score. Equals 1 for "True" and 0 for "False"
stringValue:
type: string
docs: The string representation of the score value. Is inferred from the numeric value and equals "True" or "False"
source: ScoreSource
observationId: optional<string>
timestamp: datetime
comment: optional<string>
configId:
type: optional<string>
docs: Reference a score config on a score. When set, config and score name must be equal
CategoricalScore:
properties:
id: string
traceId: string
name: string
value:
type: optional<double>
docs: Only defined if a config is linked. Represents the numeric category mapping of the stringValue
stringValue:
type: string
docs: The string representation of the score value. If no config is linked, can be any string. Otherwise, must map to a config category
source: ScoreSource
observationId: optional<string>
timestamp: datetime
comment: optional<string>
configId:
type: optional<string>
docs: Reference a score config on a score. When set, config and score name must be equal and stringValue must map to a config category
Score:
discriminant: "dataType"
union:
NUMERIC:
type: NumericScore
docs: "Score with NUMERIC data type"
CATEGORICAL:
type: CategoricalScore
docs: "Score with CATEGORICAL data type"
BOOLEAN:
type: BooleanScore
docs: "Score with BOOLEAN data type"
CreateScoreValue:
discriminated: false
union:
- string
- double
docs: The value of the score. Must be passed as string for categorical scores, and numeric for boolean and numeric scores
Dataset:
properties:
id: string
name: string
@@ -131,21 +216,6 @@ types:
projectId: string
createdAt: datetime
updatedAt: datetime
DatasetWithReferences: # GET datasets
extends: DatasetCore
properties:
items:
type: list<string>
docs: list of dataset item ids
runs:
type: list<string>
docs: list of dataset run names
Dataset: # GET datasets/[name]
docs: Dataset including all items
extends: DatasetCore
properties:
items: list<DatasetItem>
runs: list<string>
DatasetItem:
properties:
id: string
@@ -179,7 +249,43 @@ types:
datasetName: string
createdAt: datetime
updatedAt: datetime
DatasetRunWithItems:
extends: DatasetRun
properties:
datasetRunItems: list<DatasetRunItem>
Model:
docs: Model definition used for transforming usage into USD cost and/or tokenization.
properties:
id: string
modelName:
docs: "Name of the model definition. If multiple with the same name exist, they are applied in the following order: (1) custom over built-in, (2) newest according to startTime where model.startTime<observation.startTime"
type: string
matchPattern:
docs: "Regex pattern which matches this model definition to generation.model. Useful in case of fine-tuned models. If you want to exact match, use `(?i)^modelname$`"
type: string
startDate:
docs: Apply only to generations which are newer than this ISO date.
type: optional<date>
unit:
docs: Unit used by this model.
type: ModelUsageUnit
inputPrice:
docs: Price (USD) per input unit
type: optional<double>
outputPrice:
docs: Price (USD) per output unit
type: optional<double>
totalPrice:
docs: Price (USD) per total unit. Cannot be set if input or output price is set.
type: optional<double>
tokenizerId:
docs: Optional. Tokenizer to be applied to observations which match to this model. See docs for more details.
type: optional<string>
tokenizerConfig:
docs: Optional. Configuration for the selected tokenizer. Needs to be JSON. See docs for more details.
type: optional<unknown>
isLangfuseManaged:
type: boolean
# Utilities
ModelUsageUnit:
@@ -212,6 +318,11 @@ types:
- ANNOTATION
- API
- EVAL
ScoreDataType:
enum:
- NUMERIC
- BOOLEAN
- CATEGORICAL
errors:
Error:
+23 -1
View File
@@ -1,6 +1,7 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
imports:
commons: ./commons.yml
pagination: ./utils/pagination.yml
service:
auth: true
base-path: /api/public
@@ -19,6 +20,23 @@ service:
id:
type: string
response: commons.DatasetItem
list:
docs: Get dataset items
method: GET
path: /dataset-items
request:
name: GetDatasetItemsRequest
query-parameters:
datasetName: optional<string>
sourceTraceId: optional<string>
sourceObservationId: optional<string>
page:
type: optional<integer>
docs: page number, starts at 1
limit:
type: optional<integer>
docs: limit of items per page
response: PaginatedDatasetItems
types:
CreateDatasetItemRequest:
@@ -31,7 +49,11 @@ types:
sourceObservationId: optional<string>
id:
type: optional<string>
docs: Dataset items are upserted on their id
docs: Dataset items are upserted on their id. Id needs to be globally unique and cannot be reused across datasets.
status:
type: optional<commons.DatasetStatus>
docs: Defaults to ACTIVE for newly created items
PaginatedDatasetItems:
properties:
data: list<commons.DatasetItem>
meta: pagination.MetaResponse
+33 -9
View File
@@ -9,42 +9,66 @@ service:
list:
method: GET
docs: Get all datasets
path: /datasets
path: /v2/datasets
request:
name: GetDatasetsRequest
query-parameters:
page: optional<integer>
limit: optional<integer>
page:
type: optional<integer>
docs: page number, starts at 1
limit:
type: optional<integer>
docs: limit of items per page
response: PaginatedDatasets
get:
method: GET
docs: Get a dataset and its items
path: /datasets/{datasetName}
docs: Get a dataset
path: /v2/datasets/{datasetName}
path-parameters:
datasetName: string
response: commons.Dataset
create:
method: POST
docs: Create a dataset
path: /datasets
path: /v2/datasets
request: CreateDatasetRequest
response: commons.Dataset
getRuns:
getRun:
method: GET
docs: Get a dataset run and its items
path: /datasets/{datasetName}/runs/{runName}
path-parameters:
datasetName: string
runName: string
response: commons.DatasetRun
response: commons.DatasetRunWithItems
getRuns:
method: GET
docs: Get dataset runs
path: /datasets/{datasetName}/runs
path-parameters:
datasetName: string
request:
name: GetDatasetRunsRequest
query-parameters:
page:
type: optional<integer>
docs: page number, starts at 1
limit:
type: optional<integer>
docs: limit of items per page
response: PaginatedDatasetRuns
types:
PaginatedDatasets:
properties:
data: list<commons.DatasetWithReferences>
data: list<commons.Dataset>
meta: pagination.MetaResponse
CreateDatasetRequest:
properties:
name: string
description: optional<string>
metadata: optional<unknown>
PaginatedDatasetRuns:
properties:
data: list<commons.DatasetRun>
meta: pagination.MetaResponse
+52 -2
View File
@@ -7,7 +7,12 @@ service:
base-path: /api/public
endpoints:
batch:
docs: Batched ingestion for Langfuse Tracing. If you want to use tracing via the API, such as to build your own Langfuse client implementation, this is the only API route you need to implement.
docs: |
Batched ingestion for Langfuse Tracing. If you want to use tracing via the API, such as to build your own Langfuse client implementation, this is the only API route you need to implement.
Notes:
- Batch sizes are limited to 3.5 MB in total. You need to adjust the number of events per batch accordingly.
- The API does not return a 4xx status code for input errors. Instead, it responds with a 207 status code, which includes a list of the encountered errors.
method: POST
path: /ingestion
request:
@@ -180,9 +185,54 @@ types:
id: optional<string>
traceId: string
name: string
value: double
value:
type: commons.CreateScoreValue
docs: The value of the score. Must be passed as string for categorical scores, and numeric for boolean and numeric scores. Boolean score values must equal either 1 or 0 (true or false)
observationId: optional<string>
comment: optional<string>
dataType:
type: optional<commons.ScoreDataType>
docs: When set, must match the score value's type. If not set, will be inferred from the score value or config
configId:
type: optional<string>
docs: Reference a score config on a score. When set, the score name must equal the config name and scores must comply with the config's range and data type. For categorical scores, the value must map to a config category. Numeric scores might be constrained by the score config's max and min values
examples:
- value:
name: "novelty"
value: 0.9
traceId: "cdef-1234-5678-90ab"
- value:
name: "consistency"
value: 1.2
dataType: "NUMERIC"
traceId: "cdef-1234-5678-90ab"
- value:
name: "accuracy"
value: 0.9
dataType: "NUMERIC"
configId: "9203-4567-89ab-cdef"
traceId: "cdef-1234-5678-90ab"
- value:
name: "toxicity"
value: "not toxic"
traceId: "cdef-1234-5678-90ab"
- value:
name: "correctness"
value: "partially correct"
dataType: "CATEGORICAL"
configId: "1234-5678-90ab-cdef"
traceId: "cdef-1234-5678-90ab"
- value:
name: "hallucination"
value: 0
dataType: "BOOLEAN"
traceId: "cdef-1234-5678-90ab"
- value:
name: "helpfulness"
value: 1
dataType: "BOOLEAN"
configId: "1234-5678-90ab-cdef"
traceId: "cdef-1234-5678-90ab"
BaseEvent:
properties:
+76
View File
@@ -0,0 +1,76 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
imports:
commons: ./commons.yml
pagination: ./utils/pagination.yml
service:
auth: true
base-path: /api/public
endpoints:
create:
method: POST
docs: Create a model
path: /models
request: CreateModelRequest
response: commons.Model
list:
method: GET
docs: Get all models
path: /models
request:
name: GetModelsRequest
query-parameters:
page:
type: optional<integer>
docs: page number, starts at 1
limit:
type: optional<integer>
docs: limit of items per page
response: PaginatedModels
get:
method: GET
docs: Get a model
path: /models/{id}
path-parameters:
id: string
response: commons.Model
delete:
method: DELETE
docs: Delete a model. Cannot delete models managed by Langfuse. You can create your own definition with the same modelName to override the definition though.
path: /models/{id}
path-parameters:
id: string
types:
PaginatedModels:
properties:
data: list<commons.Model>
meta: pagination.MetaResponse
CreateModelRequest:
properties:
modelName:
docs: "Name of the model definition. If multiple with the same name exist, they are applied in the following order: (1) custom over built-in, (2) newest according to startTime where model.startTime<observation.startTime"
type: string
matchPattern:
docs: "Regex pattern which matches this model definition to generation.model. Useful in case of fine-tuned models. If you want to exact match, use `(?i)^modelname$`"
type: string
startDate:
docs: Apply only to generations which are newer than this ISO date.
type: optional<date>
unit:
docs: Unit used by this model.
type: commons.ModelUsageUnit
inputPrice:
docs: Price (USD) per input unit
type: optional<double>
outputPrice:
docs: Price (USD) per output unit
type: optional<double>
totalPrice:
docs: Price (USD) per total units. Cannot be set if input or output price is set.
type: optional<double>
tokenizerId:
docs: Optional. Tokenizer to be applied to observations which match to this model. See docs for more details.
type: optional<string>
tokenizerConfig:
docs: Optional. Configuration for the selected tokenizer. Needs to be JSON. See docs for more details.
type: optional<unknown>
+6 -2
View File
@@ -35,8 +35,12 @@ service:
name: optional<string>
label: optional<string>
tag: optional<string>
page: optional<integer>
limit: optional<integer>
page:
type: optional<integer>
docs: page number, starts at 1
limit:
type: optional<integer>
docs: limit of items per page
response: PromptMetaListResponse
@@ -0,0 +1,58 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
imports:
pagination: ./utils/pagination.yml
commons: ./commons.yml
service:
auth: true
base-path: /api/public
endpoints:
create:
docs: Create a score configuration (config). Score configs are used to define the structure of scores
method: POST
path: /score-configs
request: CreateScoreConfigRequest
response: commons.ScoreConfig
get:
docs: Get all score configs
method: GET
path: /score-configs
request:
name: GetScoreConfigsRequest
query-parameters:
page:
type: optional<integer>
docs: Page number, starts at 1.
limit:
type: optional<integer>
docs: Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit
response: ScoreConfigs
get-by-id:
docs: Get a score config
method: GET
path: /score-configs/{configId}
path-parameters:
configId:
type: string
docs: The unique langfuse identifier of a score config
response: commons.ScoreConfig
types:
ScoreConfigs:
properties:
data: list<commons.ScoreConfig>
meta: pagination.MetaResponse
CreateScoreConfigRequest:
properties:
name: string
dataType: commons.ScoreDataType
categories:
type: optional<list<commons.ConfigCategory>>
docs: Configure custom categories for categorical scores. Pass a list of objects with `label` and `value` properties. Categories are autogenerated for boolean configs and cannot be passed
minValue:
type: optional<double>
docs: Configure a minimum value for numerical scores. If not set, the minimum value defaults to -∞
maxValue:
type: optional<double>
docs: Configure a maximum value for numerical scores. If not set, the maximum value defaults to +∞
description:
type: optional<string>
docs: Description is shown across the Langfuse UI and can be used to e.g. explain the config categories in detail, why a numeric range was set, or provide additional context on config name or usage
+59 -3
View File
@@ -25,8 +25,12 @@ service:
limit:
type: optional<integer>
docs: Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit.
userId: optional<string>
name: optional<string>
userId:
type: optional<string>
docs: Retrieve only scores with this userId associated to the trace.
name:
type: optional<string>
docs: Retrieve only scores with this name.
fromTimestamp:
type: optional<datetime>
docs: Retrieve only scores newer than this datetime (ISO 8601).
@@ -42,6 +46,12 @@ service:
scoreIds:
type: optional<string>
docs: Comma-separated list of score IDs to limit the results to.
configId:
type: optional<string>
docs: Retrieve only scores with a specific configId.
dataType:
type: optional<commons.ScoreDataType>
docs: Retrieve only scores with a specific dataType.
response: Scores
get-by-id:
docs: Get a score
@@ -66,9 +76,55 @@ types:
id: optional<string>
traceId: string
name: string
value: double
value:
type: commons.CreateScoreValue
docs: The value of the score. Must be passed as string for categorical scores, and numeric for boolean and numeric scores. Boolean score values must equal either 1 or 0 (true or false)
observationId: optional<string>
comment: optional<string>
dataType:
type: optional<commons.ScoreDataType>
docs: The data type of the score. When passing a configId this field is inferred. Otherwise, this field must be passed or will default to numeric.
configId:
type: optional<string>
docs: Reference a score config on a score. The unique langfuse identifier of a score config. When passing this field, the dataType and stringValue fields are automatically populated.
examples:
- value:
name: "novelty"
value: 0.9
traceId: "cdef-1234-5678-90ab"
- value:
name: "consistency"
value: 1.2
dataType: "NUMERIC"
traceId: "cdef-1234-5678-90ab"
- value:
name: "accuracy"
value: 0.9
dataType: "NUMERIC"
configId: "9203-4567-89ab-cdef"
traceId: "cdef-1234-5678-90ab"
- value:
name: "toxicity"
value: "not toxic"
traceId: "cdef-1234-5678-90ab"
- value:
name: "correctness"
value: "partially correct"
dataType: "CATEGORICAL"
configId: "1234-5678-90ab-cdef"
traceId: "cdef-1234-5678-90ab"
- value:
name: "hallucination"
value: 0
dataType: "BOOLEAN"
traceId: "cdef-1234-5678-90ab"
- value:
name: "helpfulness"
value: 1
dataType: "BOOLEAN"
configId: "1234-5678-90ab-cdef"
traceId: "cdef-1234-5678-90ab"
Scores:
properties:
data: list<commons.Score>
@@ -1,4 +1,13 @@
types:
# to be used as copy/paste template for query strings
# PaginationRequest:
# properties:
# page:
# type: optional<integer>
# docs: page number, starts at 1
# limit:
# type: optional<integer>
# docs: limit of items per page
MetaResponse:
properties:
page:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "2.53.0",
"version": "2.60.0",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
+15 -4
View File
@@ -17,9 +17,9 @@
"import": "./dist/src/db.js",
"require": "./dist/src/db.js"
},
"./src/server/auth": {
"import": "./dist/src/server/auth.js",
"require": "./dist/src/server/auth.js"
"./src/server": {
"import": "./dist/src/server/index.js",
"require": "./dist/src/server/index.js"
},
"./encryption": {
"import": "./dist/src/encryption/index.js",
@@ -44,16 +44,22 @@
"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",
"@aws-sdk/lib-storage": "^3.568.0",
"@aws-sdk/s3-request-presigner": "^3.554.0",
"@langchain/anthropic": "^0.1.21",
"@langchain/core": "^0.1.61",
"@langchain/openai": "^0.0.33",
"@prisma/client": "^5.13.0",
"@react-email/components": "^0.0.19",
"@react-email/render": "^0.0.15",
"@types/bcryptjs": "^2.4.6",
"bcryptjs": "^2.4.3",
"kysely": "^0.27.3",
"langchain": "^0.1.36",
"langchain": "^0.2.6",
"lodash": "^4.17.21",
"next-auth": "^4.24.7",
"nodemailer": "^6.9.13",
"prisma-extension-kysely": "^2.1.0",
"zod": "^3.22.4",
"zod-to-json-schema": "^3.22.5"
@@ -63,6 +69,7 @@
"@repo/typescript-config": "*",
"@types/lodash": "^4.17.5",
"@types/node": "^20.11.29",
"@types/nodemailer": "^6.4.15",
"@types/pg": "^8.11.6",
"@types/uuid": "^9.0.8",
"@typescript-eslint/parser": "^7.12.0",
@@ -80,5 +87,9 @@
"tsc-watch": "^6.2.0",
"typescript": "^5.4.5",
"vitest": "^1.5.3"
},
"peerDependencies": {
"@types/react": "^18.2.79",
"react": "^18.0.0"
}
}
+22 -2
View File
@@ -98,6 +98,21 @@ export type AuditLog = {
before: string | null;
after: string | null;
};
export type BatchExport = {
id: string;
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
project_id: string;
user_id: string;
finished_at: Timestamp | null;
expires_at: Timestamp | null;
name: string;
status: string;
query: unknown;
format: string;
url: string | null;
log: string | null;
};
export type CronJobs = {
name: string;
last_run: Timestamp | null;
@@ -204,7 +219,7 @@ export type LlmApiKeys = {
display_secret_key: string;
secret_key: string;
base_url: string | null;
custom_models: string[];
custom_models: Generated<string[]>;
with_default_models: Generated<boolean>;
project_id: string;
};
@@ -249,6 +264,7 @@ export type Observation = {
updated_at: Generated<Timestamp>;
model: string | null;
internal_model: string | null;
internal_model_id: string | null;
modelParameters: unknown | null;
input: unknown | null;
output: unknown | null;
@@ -259,6 +275,9 @@ export type Observation = {
input_cost: string | null;
output_cost: string | null;
total_cost: string | null;
calculated_input_cost: string | null;
calculated_output_cost: string | null;
calculated_total_cost: string | null;
completion_start_time: Timestamp | null;
prompt_id: string | null;
};
@@ -338,7 +357,7 @@ export type Score = {
timestamp: Generated<Timestamp>;
project_id: string;
name: string;
value: number;
value: number | null;
source: ScoreSource;
author_user_id: string | null;
comment: string | null;
@@ -442,6 +461,7 @@ export type DB = {
Account: Account;
api_keys: ApiKey;
audit_logs: AuditLog;
batch_exports: BatchExport;
cron_jobs: CronJobs;
dataset_items: DatasetItem;
dataset_run_items: DatasetRunItems;
@@ -1,7 +0,0 @@
-- Google Vertex uses @ to separate model name and version
UPDATE "public"."models" SET "match_pattern" = '(?i)^(claude-3-haiku(-|@)?20240307)$' WHERE "id" = 'cltr0w45b000008k1407o9qv1';
UPDATE "public"."models" SET "match_pattern" = '(?i)^(claude-3-opus(-|@)?20240229)$' WHERE "id" = 'cltgy0iuw000008le3vod1hhy';
UPDATE "public"."models" SET "match_pattern" = '(?i)^(claude-3-sonnet(-|@)?20240229)$' WHERE "id" = 'cltgy0pp6000108le56se7bl3';
@@ -1,5 +0,0 @@
-- does not include pricing yet, will be added as soon as it is calculated at ingestion time
INSERT INTO "public"."models" ("id", "model_name", "match_pattern", "unit") VALUES ('clx30djsn0000w9mzebiv41we', 'gemini-1.5-flash', '(?i)^(gemini-1.5-flash)(@[a-zA-Z0-9]+)?$', 'CHARACTERS');
INSERT INTO "public"."models" ("id", "model_name", "match_pattern", "unit") VALUES ('clx30hkrx0000w9mz7lqi0ial', 'gemini-1.5-pro', '(?i)^(gemini-1.5-pro)(@[a-zA-Z0-9]+)?$', 'CHARACTERS');
@@ -0,0 +1,8 @@
-- does not include pricing yet, will be added as soon as it is calculated at ingestion time
-- remove model if added by faulty migration, context: https://github.com/langfuse/langfuse/issues/2266
DELETE FROM models WHERE id in ('clx30djsn0000w9mzebiv41we', 'clx30hkrx0000w9mz7lqi0ial');
INSERT INTO "models" ("id", "model_name", "match_pattern", "unit") VALUES ('clx30djsn0000w9mzebiv41we', 'gemini-1.5-flash', '(?i)^(gemini-1.5-flash)(@[a-zA-Z0-9]+)?$', 'CHARACTERS');
INSERT INTO "models" ("id", "model_name", "match_pattern", "unit") VALUES ('clx30hkrx0000w9mz7lqi0ial', 'gemini-1.5-pro', '(?i)^(gemini-1.5-pro)(@[a-zA-Z0-9]+)?$', 'CHARACTERS');
@@ -0,0 +1,7 @@
-- Google Vertex uses @ to separate model name and version
UPDATE "models" SET "match_pattern" = '(?i)^(claude-3-haiku(-|@)?20240307)$' WHERE "id" = 'cltr0w45b000008k1407o9qv1';
UPDATE "models" SET "match_pattern" = '(?i)^(claude-3-opus(-|@)?20240229)$' WHERE "id" = 'cltgy0iuw000008le3vod1hhy';
UPDATE "models" SET "match_pattern" = '(?i)^(claude-3-sonnet(-|@)?20240229)$' WHERE "id" = 'cltgy0pp6000108le56se7bl3';
@@ -10,7 +10,7 @@ INSERT INTO score_configs (id,
max_value,
description)
SELECT
gen_random_uuid () AS id,
md5(random()::text || clock_timestamp()::text || s.project_id::text)::uuid AS id,
s.project_id,
'manual-score',
'NUMERIC',
@@ -24,6 +24,7 @@ FROM ( SELECT DISTINCT
scores
WHERE
name = 'manual-score'
AND config_id IS NULL
AND source = 'ANNOTATION') s
WHERE
NOT EXISTS (
@@ -47,4 +48,4 @@ FROM
WHERE
scores.project_id = pc.project_id
AND scores.name = 'manual-score';
COMMIT;
COMMIT;
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX CONCURRENTLY "observations_project_id_prompt_id_idx" ON "observations"("project_id", "prompt_id");
@@ -0,0 +1,2 @@
-- DropForeignKey
ALTER TABLE "observations" DROP CONSTRAINT "observations_prompt_id_fkey";
@@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE "batch_exports" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"project_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"finished_at" TIMESTAMP(3),
"expires_at" TIMESTAMP(3),
"name" TEXT NOT NULL,
"status" TEXT NOT NULL,
"query" JSONB NOT NULL,
"format" TEXT NOT NULL,
"url" TEXT,
"log" TEXT,
CONSTRAINT "batch_exports_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "batch_exports_project_id_user_id_idx" ON "batch_exports"("project_id", "user_id");
-- CreateIndex
CREATE INDEX "batch_exports_status_idx" ON "batch_exports"("status");
-- AddForeignKey
ALTER TABLE "batch_exports" ADD CONSTRAINT "batch_exports_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX CONCURRENTLY IF EXISTS "observations_parent_observation_id_idx";
@@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX CONCURRENTLY IF EXISTS "observations_updated_at_idx";
@@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX CONCURRENTLY IF EXISTS "scores_updated_at_idx";
@@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX CONCURRENTLY IF EXISTS "traces_external_id_idx";
@@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX CONCURRENTLY IF EXISTS "traces_release_idx";
@@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX CONCURRENTLY IF EXISTS "traces_updated_at_idx";
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX CONCURRENTLY "traces_project_id_timestamp_idx" ON "traces"("project_id", "timestamp");
@@ -0,0 +1,15 @@
INSERT INTO models (
id,
project_id,
model_name,
match_pattern,
start_date,
input_price,
output_price,
total_price,
unit,
tokenizer_id
)
VALUES
-- add 3.5 sonnet model
('clxt0n0m60000pumz1j5b7zsf', NULL, 'claude-3-5-sonnet-20240620', '(?i)^(claude-3-5-sonnet(-|@)?20240620)$', NULL, 0.000003, 0.000015, NULL, 'TOKENS', 'claude')
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "observations" ADD COLUMN "calculated_input_cost" DECIMAL(65,30),
ADD COLUMN "calculated_output_cost" DECIMAL(65,30),
ADD COLUMN "calculated_total_cost" DECIMAL(65,30),
ADD COLUMN "internal_model_id" TEXT;
@@ -0,0 +1,17 @@
/*
Previously, the pattern did not allow for model specifications like "1106" or any other 4-digit block.
This led to missing models on the generation-update call where the exact model name that was used was provided (including the 4-digit block).
The new pattern allows for:
- "gpt-4-vision-preview" as set on generation-create
- "gpt-4-1106-vision-preview" as set on generation-update
*/
UPDATE
"models"
SET
"match_pattern" = '(?i)^(gpt-4(-\d{4})?-vision-preview)$'
WHERE
"id" = 'clrkvx5gp000108juaogs54ea'
AND "model_name" = 'gpt-4-turbo-vision';
@@ -0,0 +1,9 @@
-- Add AWS Bedrock model names for Anthropic models
UPDATE "models" SET "match_pattern" = '(?i)^(claude-3-haiku-20240307|anthropic\.claude-3-haiku-20240307-v1:0|claude-3-haiku@20240307)$' WHERE "id" = 'cltr0w45b000008k1407o9qv1';
UPDATE "models" SET "match_pattern" = '(?i)^(claude-3-sonnet-20240229|anthropic\.claude-3-sonnet-20240229-v1:0|claude-3-sonnet@20240229)$' WHERE "id" = 'cltgy0pp6000108le56se7bl3';
UPDATE "models" SET "match_pattern" = '(?i)^(claude-3-opus-20240229|anthropic\.claude-3-opus-20240229-v1:0|claude-3-opus@20240229)$' WHERE "id" = 'cltgy0iuw000008le3vod1hhy';
UPDATE "models" SET "match_pattern" = '(?i)^(claude-3-5-sonnet-20240620|anthropic\.claude-3-5-sonnet-20240620-v1:0|claude-3-5-sonnet@20240620)$' WHERE "id" = 'clxt0n0m60000pumz1j5b7zsf';
@@ -0,0 +1,88 @@
DROP VIEW IF EXISTS "observations_view"; -- Drop view as column was added in 20240528214726_add_cursor_new_columns_observations 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.unit,
o.prompt_id,
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
-- 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,2 @@
-- AlterTable
ALTER TABLE "scores" ALTER COLUMN "value" DROP NOT NULL;
+61 -32
View File
@@ -115,6 +115,7 @@ model Project {
PosthogIntegration PosthogIntegration[]
Score Score[]
scoreConfig ScoreConfig[]
BatchExport BatchExport[]
@@map("projects")
}
@@ -149,7 +150,7 @@ model LlmApiKeys {
displaySecretKey String @map("display_secret_key")
secretKey String @map("secret_key")
baseURL String? @map("base_url")
customModels String[] @map("custom_models")
customModels String[] @default([]) @map("custom_models")
withDefaultModels Boolean @default(true) @map("with_default_models")
projectId String @map("project_id")
@@ -241,14 +242,12 @@ model Trace {
JobExecution JobExecution[]
@@index([projectId])
@@index([projectId, timestamp])
@@index([sessionId])
@@index([name])
@@index([userId])
@@index([id, userId])
@@index([externalId])
@@index(timestamp)
@@index(release)
@@index(updatedAt)
@@index(createdAt)
@@index([tags(ops: ArrayOps)], type: Gin)
@@map("traces")
@@ -300,24 +299,33 @@ model Observation {
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
// GENERATION ONLY
model String?
internalModel String? @map("internal_model")
modelParameters Json?
input Json?
output Json?
promptTokens Int @default(0) @map("prompt_tokens")
completionTokens Int @default(0) @map("completion_tokens")
totalTokens Int @default(0) @map("total_tokens")
unit String?
inputCost Decimal? @map("input_cost")
outputCost Decimal? @map("output_cost")
totalCost Decimal? @map("total_cost")
model String?
internalModel String? @map("internal_model")
internalModelId String? @map("internal_model_id")
modelParameters Json?
input Json?
output Json?
promptTokens Int @default(0) @map("prompt_tokens")
completionTokens Int @default(0) @map("completion_tokens")
totalTokens Int @default(0) @map("total_tokens")
unit String?
// User provided cost at ingestion
inputCost Decimal? @map("input_cost")
outputCost Decimal? @map("output_cost")
totalCost Decimal? @map("total_cost")
// Calculated cost
calculatedInputCost Decimal? @map("calculated_input_cost")
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[]
promptId String? @map("prompt_id")
prompt Prompt? @relation(fields: [promptId], onDelete: SetNull, references: [id])
promptId String? @map("prompt_id") // no fk constraint, prompt can be deleted
@@unique([id, projectId])
@@index([projectId, internalModel, startTime, unit])
@@ -328,11 +336,10 @@ model Observation {
@@index([type])
@@index(startTime)
@@index(createdAt)
@@index(updatedAt)
@@index(projectId)
@@index(parentObservationId)
@@index(model)
@@index(internalModel)
@@index([projectId, promptId])
@@index(promptId)
@@index([projectId, startTime, type])
@@map("observations")
@@ -403,14 +410,14 @@ model Score {
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
name String
value Float
value Float? // always defined if data type is NUMERIC or BOOLEAN, optional for CATEGORICAL
source ScoreSource
authorUserId String? @map("author_user_id")
comment String?
traceId String @map("trace_id")
observationId String? @map("observation_id")
configId String? @map("config_id")
stringValue String? @map("string_value")
stringValue String? @map("string_value") // always defined if data type is CATEGORICAL or BOOLEAN, null for NUMERIC
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
dataType ScoreDataType @default(NUMERIC) @map("data_type")
@@ -428,7 +435,6 @@ model Score {
@@index([observationId], type: Hash)
@@index([source])
@@index([createdAt])
@@index([updatedAt])
@@map("scores")
}
@@ -589,15 +595,14 @@ model Prompt {
createdBy String @map("created_by")
prompt Json
name String
version Int
type String @default("text")
isActive Boolean? @map("is_active") // Deprecated. To be removed once 'production' labels work as expected.
config Json @default("{}")
tags String[] @default([])
labels String[] @default([])
Observation Observation[]
prompt Json
name String
version Int
type String @default("text")
isActive Boolean? @map("is_active") // Deprecated. To be removed once 'production' labels work as expected.
config Json @default("{}")
tags String[] @default([])
labels String[] @default([])
@@unique([projectId, name, version])
@@index([projectId, name, version])
@@ -782,3 +787,27 @@ model PosthogIntegration {
@@index([projectId])
@@map("posthog_integrations")
}
model BatchExport {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
userId String @map("user_id")
finishedAt DateTime? @map("finished_at")
expiresAt DateTime? @map("expires_at")
name String
status String
query Json
format String
url String?
log String?
@@index([projectId, userId])
@@index([status])
@@map("batch_exports")
}
+95 -35
View File
@@ -12,11 +12,16 @@ import { parseArgs } from "node:util";
import { chunk } from "lodash";
import { v4 } from "uuid";
import { ModelUsageUnit } from "../src";
import { getDisplaySecretKey, hashSecretKey } from "../src/server/auth";
import { getDisplaySecretKey, hashSecretKey } from "../src/server";
import { encrypt } from "../src/encryption";
const LOAD_TRACE_VOLUME = 10_000;
type ConfigCategory = {
label: string;
value: number;
};
const options = {
environment: { type: "string" },
} as const;
@@ -377,11 +382,10 @@ async function uploadObjects(
});
for (let i = 0; i < promises.length; i++) {
process.stdout.clearLine(0);
process.stdout.cursorTo(0);
process.stdout.write(
`Seeding of Sessions ${(i / promises.length) * 100}% complete`
);
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
console.log(
`Seeding of Sessions ${((i + 1) / promises.length) * 100}% complete`
);
await promises[i];
}
@@ -395,11 +399,10 @@ async function uploadObjects(
);
});
for (let i = 0; i < promises.length; i++) {
process.stdout.clearLine(0);
process.stdout.cursorTo(0);
process.stdout.write(
`Seeding of Traces ${(i / promises.length) * 100}% complete`
);
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
console.log(
`Seeding of Traces ${((i + 1) / promises.length) * 100}% complete`
);
await promises[i];
}
@@ -413,11 +416,10 @@ async function uploadObjects(
});
for (let i = 0; i < promises.length; i++) {
process.stdout.clearLine(0);
process.stdout.cursorTo(0);
process.stdout.write(
`Seeding of Observations ${(i / promises.length) * 100}% complete`
);
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
console.log(
`Seeding of Observations ${((i + 1) / promises.length) * 100}% complete`
);
await promises[i];
}
@@ -431,11 +433,10 @@ async function uploadObjects(
});
for (let i = 0; i < promises.length; i++) {
process.stdout.clearLine(0);
process.stdout.cursorTo(0);
process.stdout.write(
`Seeding of Events ${(i / promises.length) * 100}% complete`
);
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
console.log(
`Seeding of Events ${((i + 1) / promises.length) * 100}% complete`
);
await promises[i];
}
@@ -448,11 +449,10 @@ async function uploadObjects(
);
});
for (let i = 0; i < promises.length; i++) {
process.stdout.clearLine(0);
process.stdout.cursorTo(0);
process.stdout.write(
`Seeding of Scores ${(i / promises.length) * 100}% complete`
);
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
console.log(
`Seeding of Scores ${((i + 1) / promises.length) * 100}% complete`
);
await promises[i];
}
}
@@ -464,7 +464,15 @@ function createObjects(
project1: Project,
project2: Project,
promptIds: Map<string, string[]>,
configIdsAndNames: Map<string, { name: string; id: string }[]>
configParams: Map<
string,
{
name: string;
id: string;
dataType: ScoreDataType;
categories: ConfigCategory[] | null;
}[]
>
) {
const traces: Prisma.TraceCreateManyInput[] = [];
const observations: Prisma.ObservationCreateManyInput[] = [];
@@ -523,13 +531,34 @@ function createObjects(
traces.push(trace);
const configArray = configIdsAndNames.get(projectId) ?? [];
const configArray = configParams.get(projectId) ?? [];
const randomIndex = Math.floor(Math.random() * 3);
const config =
configArray.length >= randomIndex - 1 && configArray[randomIndex];
const { name: annotationScoreName, id: configId } = config || {
const {
name: annotationScoreName,
id: configId,
dataType,
categories,
} = config || {
name: "manual-score",
id: undefined,
dataType: ScoreDataType.NUMERIC,
categories: null,
};
const value = Math.floor(Math.random() * 2);
const scoreNumericAndStringValue = {
...(dataType === ScoreDataType.NUMERIC && { value }),
...(dataType === ScoreDataType.CATEGORICAL && {
value,
stringValue: categories?.find((category) => category.value === value)
?.label,
}),
...(dataType === ScoreDataType.BOOLEAN && {
value,
stringValue: value === 1 ? "True" : "False",
}),
};
const traceScores = [
@@ -538,12 +567,12 @@ function createObjects(
{
traceId: trace.id,
name: annotationScoreName,
value: Math.floor(Math.random() * 3) - 1,
timestamp: traceTs,
source: ScoreSource.ANNOTATION,
projectId,
authorUserId: `user-${i}`,
dataType: ScoreDataType.NUMERIC,
dataType,
...scoreNumericAndStringValue,
...(configId ? { configId } : {}),
},
]
@@ -561,6 +590,20 @@ function createObjects(
},
]
: []),
...(Math.random() < 0.8
? [
{
traceId: trace.id,
name: "Completeness",
timestamp: traceTs,
source: ScoreSource.API,
projectId,
dataType: ScoreDataType.CATEGORICAL,
stringValue:
Math.floor(Math.random() * 2) === 1 ? "Fully" : "Partially",
},
]
: []),
];
scores.push(...traceScores);
@@ -981,8 +1024,15 @@ async function generatePrompts(project: Project) {
}
async function generateConfigsForProject(projects: Project[]) {
const projectIdsToConfigs: Map<string, { name: string; id: string }[]> =
new Map();
const projectIdsToConfigs: Map<
string,
{
name: string;
id: string;
dataType: ScoreDataType;
categories: ConfigCategory[] | null;
}[]
> = new Map();
await Promise.all(
projects.map(async (project) => {
@@ -994,7 +1044,12 @@ async function generateConfigsForProject(projects: Project[]) {
}
async function generateConfigs(project: Project) {
const configNameAndId: { name: string; id: string }[] = [];
const configNameAndId: {
name: string;
id: string;
dataType: ScoreDataType;
categories: ConfigCategory[] | null;
}[] = [];
const configs = [
{
@@ -1051,7 +1106,12 @@ async function generateConfigs(project: Project) {
id: config.id,
},
});
configNameAndId.push({ name: config.name, id: config.id });
configNameAndId.push({
name: config.name,
id: config.id,
dataType: config.dataType,
categories: config.categories ?? null,
});
}
return configNameAndId;
+7
View File
@@ -0,0 +1,7 @@
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = '_prisma_migrations') THEN
DELETE FROM _prisma_migrations
WHERE migration_name IN ('20240606090858_pricings_add_latest_gemini_models', '20240530212419_model_price_anthropic_via_google_vertex', '20240604133340_backfill_manual_scores');
END IF;
END $$;
@@ -0,0 +1,7 @@
import { BaseError } from "./BaseError";
export class InternalServerError extends BaseError {
constructor(description = "Internal Server Error") {
super("InternalServerError", 500, description, true);
}
}
@@ -0,0 +1,7 @@
import { BaseError } from "./BaseError";
export class InvalidRequestError extends BaseError {
constructor(description = "Invalid Request Error") {
super("InvalidRequestError", 400, description, true);
}
}
@@ -1,7 +0,0 @@
import { BaseError } from "./BaseError";
export class ValidationError extends BaseError {
constructor(description = "Validation Error") {
super("ValidationError", 400, description, true);
}
}
+2 -1
View File
@@ -1,7 +1,8 @@
export { BaseError } from "./BaseError";
export { LangfuseNotFoundError } from "./NotFoundError";
export { ValidationError } from "./ValidationError";
export { InvalidRequestError } from "./InvalidRequestError";
export { UnauthorizedError } from "./UnauthorizedError";
export { ForbiddenError } from "./ForbiddenError";
export { MethodNotAllowedError } from "./MethodNotAllowedError";
export { ApiError } from "./ApiError";
export { InternalServerError } from "./InternalServerError";
@@ -0,0 +1,59 @@
import z from "zod";
import { type ScoreDataType } from "../../db";
const NUMERIC: ScoreDataType = "NUMERIC";
const CATEGORICAL: ScoreDataType = "CATEGORICAL";
const BOOLEAN: ScoreDataType = "BOOLEAN";
export const availableDataTypes = [NUMERIC, CATEGORICAL, BOOLEAN] as const;
const NumericData = z.object({
value: z.number(),
stringValue: z.undefined().nullish(),
dataType: z.literal("NUMERIC"),
});
const CategoricalData = z.object({
value: z.number().optional().nullish(),
stringValue: z.string(),
dataType: z.literal("CATEGORICAL"),
});
const BooleanData = z.object({
value: z.number(),
stringValue: z.string(),
dataType: z.literal("BOOLEAN"),
});
const CreateAnnotationScoreBase = z.object({
name: z.string(),
projectId: z.string(),
traceId: z.string(),
configId: z.string().optional(),
observationId: z.string().optional(),
comment: z.string().optional().nullish(),
});
const UpdateAnnotationScoreBase = CreateAnnotationScoreBase.extend({
id: z.string(),
});
/**
* CreateAnnotationScoreData is only used for annotation scores created via the UI.
* For langfuse score types please refer to `web/src/features/public-api/types/scores.ts`
*/
export const CreateAnnotationScoreData = z.discriminatedUnion("dataType", [
CreateAnnotationScoreBase.merge(NumericData),
CreateAnnotationScoreBase.merge(CategoricalData),
CreateAnnotationScoreBase.merge(BooleanData),
]);
/**
* UpdateAnnotationScoreData is only used for annotation scores updated via the UI
* For langfuse score types please refer to `web/src/features/public-api/types/scores.ts`
*/
export const UpdateAnnotationScoreData = z.discriminatedUnion("dataType", [
UpdateAnnotationScoreBase.merge(NumericData),
UpdateAnnotationScoreBase.merge(CategoricalData),
UpdateAnnotationScoreBase.merge(BooleanData),
]);
@@ -0,0 +1,73 @@
import z from "zod";
import { BatchExport } from "@prisma/client";
import { singleFilter } from "../../interfaces/filters";
import { orderBy } from "../../interfaces/orderBy";
export enum BatchExportStatus {
QUEUED = "QUEUED",
PROCESSING = "PROCESSING",
COMPLETED = "COMPLETED",
FAILED = "FAILED",
}
export enum BatchExportFileFormat {
JSON = "JSON",
CSV = "CSV",
}
export enum BatchExportTableName {
Sessions = "sessions",
}
export const exportOptions: Record<
BatchExportFileFormat,
{
label: string;
extension: string;
fileType: string;
}
> = {
CSV: { label: "CSV", extension: "csv", fileType: "text/csv" },
JSON: { label: "JSON", extension: "json", fileType: "application/json" },
} as const;
export const BatchExportQuerySchema = z.object({
tableName: z.nativeEnum(BatchExportTableName),
filter: z.array(singleFilter).nullable(),
orderBy,
limit: z.number().optional(),
page: z.number().optional(),
});
export type BatchExportQueryType = z.infer<typeof BatchExportQuerySchema>;
export const CreateBatchExportSchema = z.object({
projectId: z.string(),
name: z.string(),
query: BatchExportQuerySchema,
format: z.nativeEnum(BatchExportFileFormat),
});
export const BatchExportSchema = z.object({
id: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
projectId: z.string(),
userId: z.string(),
finishedAt: z.date().nullable(),
expiresAt: z.date().nullable(),
name: z.string(),
status: z.nativeEnum(BatchExportStatus),
query: BatchExportQuerySchema,
format: z.nativeEnum(BatchExportFileFormat),
url: z.string().nullable(),
log: z.string().nullable(),
});
// Ensure that zod type matches the Prisma type
export type BatchExportType =
z.infer<typeof BatchExportSchema> extends BatchExport
? z.infer<typeof BatchExportSchema>
: never;
@@ -145,15 +145,75 @@ export const UpdateGenerationBody = UpdateSpanBody.extend({
return false;
});
export const ScoreBody = z.object({
const BaseScoreBody = z.object({
id: z.string().nullish(),
name: NonEmptyString,
value: z.number(),
traceId: z.string(),
observationId: z.string().nullish(),
comment: z.string().nullish(),
});
/**
* 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}`,
});
}
}
}
});
// LEGACY, only required for backwards compatibility
export const LegacySpanPostSchema = z.object({
id: z.string().nullish(),
+1 -4
View File
@@ -1,8 +1,5 @@
import { Prisma } from "@prisma/client";
import {
ColumnDefinition,
type TableNames,
} from "./interfaces/tableDefinition";
import { ColumnDefinition, type TableNames } from "./tableDefinitions";
import { FilterState } from "./types";
import { filterOperators } from "./interfaces/filters";
+7 -2
View File
@@ -1,11 +1,12 @@
export * from "./constants";
export * from "./queries";
export * from "./queues";
export * from "./interfaces/exportTypes";
export * from "./interfaces/filters";
export * from "./interfaces/orderBy";
export * from "./interfaces/tableDefinition";
export * from "./tableDefinitions";
export * from "./types";
export * from "./filterToPrisma";
export * from "./orderByToPrisma";
export * from "./tracesTable";
export * from "./server/auth";
export * from "./observationsTable";
@@ -20,6 +21,10 @@ export * from "./server/llm/fetchLLMCompletion";
// evals
export * from "./features/evals/types";
export * from "./features/batchExport/types";
// annotation
export * from "./features/annotation/types";
// export db types only
export * from "@prisma/client";
@@ -1,19 +0,0 @@
export const exportFileFormats = ["CSV", "JSON", "OPENAI-JSONL"] as const;
export type ExportFileFormats = (typeof exportFileFormats)[number];
export const exportOptions: Record<
ExportFileFormats,
{
label: string;
extension: string;
fileType: string;
}
> = {
CSV: { label: "CSV", extension: "csv", fileType: "text/csv" },
JSON: { label: "JSON", extension: "json", fileType: "application/json" },
"OPENAI-JSONL": {
label: "OpenAI JSONL (fine-tuning)",
extension: "jsonl",
fileType: "application/json",
},
} as const;
@@ -6,3 +6,5 @@ export const orderBy = z
order: z.enum(["ASC", "DESC"]),
})
.nullable();
export type OrderByState = z.infer<typeof orderBy>;
+1 -1
View File
@@ -2,7 +2,7 @@ import { ObservationLevel } from "@prisma/client";
import {
type OptionsDefinition,
type ColumnDefinition,
} from "./interfaces/tableDefinition";
} from "./tableDefinitions/types";
// to be used server side
export const observationsTableCols: ColumnDefinition[] = [
@@ -1,8 +1,10 @@
import { type OrderByState } from "@/src/features/orderBy/types";
import { type ColumnDefinition } from "@langfuse/shared";
import { Prisma } from "@langfuse/shared";
import { z } from "zod";
import { Prisma } from "@prisma/client";
import type { ColumnDefinition } from "./tableDefinitions/types";
import type { OrderByState } from "./interfaces/orderBy";
/**
* Convert orderBy to SQL ORDER BY clause
* @param orderBy orderBy state
@@ -11,7 +13,7 @@ import { z } from "zod";
*/
export function orderByToPrismaSql(
orderBy: OrderByState,
tableColumns: ColumnDefinition[],
tableColumns: ColumnDefinition[]
): Prisma.Sql {
if (!orderBy) {
return Prisma.sql`ORDER BY t.timestamp DESC NULLS LAST`;
@@ -20,7 +22,7 @@ export function orderByToPrismaSql(
const col = tableColumns.find(
// TODO: Only use id instead of name.
// It's less error-prone & decouples data fetching from the human-readable UI labels
(c) => c.name === orderBy.column || c.id === orderBy.column,
(c) => c.name === orderBy.column || c.id === orderBy.column
);
if (!col) {
@@ -38,6 +40,6 @@ export function orderByToPrismaSql(
// Both column and order are safe, can use raw SQL
return Prisma.raw(
`ORDER BY ${col.internal} ${order.data} ${orderBy.order === "DESC" ? "NULLS LAST" : "NULLS FIRST"}`,
`ORDER BY ${col.internal} ${order.data} ${orderBy.order === "DESC" ? "NULLS LAST" : "NULLS FIRST"}`
);
}
@@ -0,0 +1,88 @@
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { tableColumnsToSqlFilterAndPrefix } from "../filterToPrisma";
import { singleFilter } from "../interfaces/filters";
import { orderBy } from "../interfaces/orderBy";
import { orderByToPrismaSql } from "../orderByToPrisma";
import { sessionsViewCols } from "../tableDefinitions/index";
const GetSessionTableSQLParamsSchema = z.object({
projectId: z.string(),
filter: z.array(singleFilter).nullable(),
orderBy: orderBy,
page: z.number(),
limit: z.number(),
});
type GetSessionTableSQLParams = z.infer<typeof GetSessionTableSQLParamsSchema>;
export const getSessionTableSQL = (
params: GetSessionTableSQLParams
): Prisma.Sql => {
const { projectId, filter, orderBy, page, limit } =
GetSessionTableSQLParamsSchema.parse(params);
const filterCondition = tableColumnsToSqlFilterAndPrefix(
filter ?? [],
sessionsViewCols,
"sessions"
);
const orderByCondition = orderByToPrismaSql(orderBy, sessionsViewCols);
const sql = Prisma.sql`
SELECT
s.id,
s. "created_at" AS "createdAt",
s.bookmarked,
s.public,
t. "userIds",
t. "countTraces",
o. "sessionDuration",
o. "totalCost" AS "totalCost",
o. "inputCost" AS "inputCost",
o. "outputCost" AS "outputCost",
o. "promptTokens" AS "promptTokens",
o. "completionTokens" AS "completionTokens",
o. "totalTokens" AS "totalTokens",
(count(*) OVER ())::int AS "totalCount"
FROM
trace_sessions AS s
LEFT JOIN LATERAL (
SELECT
t.session_id,
MAX(t. "timestamp") AS "max_timestamp",
MIN(t. "timestamp") AS "min_timestamp",
array_agg(t.id) AS "traceIds",
array_agg(DISTINCT t.user_id) AS "userIds",
count(t.id)::int AS "countTraces"
FROM
traces t
WHERE
t.project_id = ${projectId}
AND t.session_id = s.id
GROUP BY
t.session_id) AS t ON TRUE
LEFT JOIN LATERAL (
SELECT
EXTRACT(EPOCH FROM COALESCE(MAX(o. "end_time"), MAX(o. "start_time"), t. "max_timestamp")) - EXTRACT(EPOCH FROM COALESCE(MIN(o. "start_time"), t. "min_timestamp"))::double precision AS "sessionDuration",
SUM(COALESCE(o. "calculated_input_cost", 0)) AS "inputCost",
SUM(COALESCE(o. "calculated_output_cost", 0)) AS "outputCost",
SUM(COALESCE(o. "calculated_total_cost", 0)) AS "totalCost",
SUM(o.prompt_tokens) AS "promptTokens",
SUM(o.completion_tokens) AS "completionTokens",
SUM(o.total_tokens) AS "totalTokens"
FROM
observations_view o
WHERE
o.project_id = ${projectId}
AND o.trace_id = ANY (t. "traceIds")) AS o ON TRUE
WHERE
s. "project_id" = ${projectId}
${filterCondition}
${orderByCondition}
LIMIT ${limit}
OFFSET ${page * limit}
`;
return sql;
};
+1
View File
@@ -0,0 +1 @@
export { getSessionTableSQL } from "./getSessionTableSQL";
+43 -4
View File
@@ -1,36 +1,75 @@
import { z } from "zod";
export const TraceUpsertEvent = z.object({
export enum EventName {
TraceUpsert = "TraceUpsert",
BatchExport = "BatchExport",
EvaluationExecution = "EvaluationExecution",
}
export const BatchExportJobSchema = z.object({
projectId: z.string(),
batchExportId: z.string(),
});
export const TraceUpsertEventSchema = z.object({
projectId: z.string(),
traceId: z.string(),
});
export const EvalExecutionEvent = z.object({
projectId: z.string(),
jobExecutionId: z.string(),
});
export type BatchExportJobType = z.infer<typeof BatchExportJobSchema>;
export type TraceUpsertEventType = z.infer<typeof TraceUpsertEventSchema>;
export type EvalExecutionEventType = z.infer<typeof EvalExecutionEvent>;
export const EventBodySchema = z.union([
z.object({
name: z.literal(EventName.TraceUpsert),
payload: z.array(TraceUpsertEventSchema),
}),
z.object({
name: z.literal(EventName.EvaluationExecution),
payload: EvalExecutionEvent,
}),
z.object({
name: z.literal(EventName.BatchExport),
payload: BatchExportJobSchema,
}),
]);
export type EventBodyType = z.infer<typeof EventBodySchema>;
export enum QueueName {
TraceUpsert = "trace-upsert", // Ingestion pipeline adds events on each Trace upsert
EvaluationExecution = "evaluation-execution-queue", // Worker executes Evals
BatchExport = "batch-export-queue",
RepeatQueue = "repeat-queue",
}
export enum QueueJobs {
TraceUpsert = "trace-upsert",
EvaluationExecution = "evaluation-execution-job",
BatchExportJob = "batch-export-job",
EnqueueBatchExportJobs = "enqueue-batch-export-jobs",
}
export type TQueueJobTypes = {
[QueueName.TraceUpsert]: {
timestamp: Date;
id: string;
payload: z.infer<typeof TraceUpsertEvent>;
payload: TraceUpsertEventType;
name: QueueJobs.TraceUpsert;
};
[QueueName.EvaluationExecution]: {
timestamp: Date;
id: string;
payload: z.infer<typeof EvalExecutionEvent>;
payload: EvalExecutionEventType;
name: QueueJobs.EvaluationExecution;
};
[QueueName.BatchExport]: {
timestamp: Date;
id: string;
payload: BatchExportJobType;
name: QueueJobs.BatchExportJob;
};
};
+1 -1
View File
@@ -65,7 +65,7 @@ export function CustomSSOProvider<P extends CustomSSOUser>(
name: "CustomSSOProvider",
type: "oauth",
wellKnown: `${options.issuer}/.well-known/openid-configuration`,
authorization: { params: { scope: "openid email profile" } },
authorization: { params: { scope: "openid email profile" } }, // overridden by options.authorization to be able to set custom scopes, deep merged with this default
checks: ["pkce", "state"],
idToken: true,
profile(profile) {
+9
View File
@@ -0,0 +1,9 @@
export * from "./services/S3StorageService";
export * from "./services/email/projectInvitation/sendProjectInvitationEmail";
export * from "./services/email/batchExportSuccess/sendBatchExportSuccessEmail";
export * from "./services/email/passwordReset/sendResetPasswordVerificationRequest";
export * from "./auth";
export * from "./llm/fetchLLMCompletion";
export * from "./llm/types";
export * from "./utils/DatabaseReadStream";
export * from "./utils/transforms";
+1
View File
@@ -65,6 +65,7 @@ export const openAIModels = [
export type OpenAIModel = (typeof openAIModels)[number];
export const anthropicModels = [
"claude-3-5-sonnet-20240620",
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307",
@@ -1,5 +1,4 @@
import type { Readable } from "stream";
import { env } from "@/src/env.mjs";
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
@@ -8,44 +7,52 @@ type UploadFile = {
fileName: string;
fileType: string;
data: Readable | string;
expiresInSeconds: number;
};
class S3StorageService {
export class S3StorageService {
private client: S3Client;
private bucketName: string;
constructor() {
if (!S3StorageService.getIsS3StorageConfigured(env)) {
throw new Error("S3 bucket is not configured");
}
constructor(params: {
accessKeyId: string;
secretAccessKey: string;
bucketName: string;
endpoint: string;
region: string;
}) {
const { accessKeyId, secretAccessKey, bucketName, endpoint, region } =
params;
this.client = new S3Client({
credentials: {
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
accessKeyId,
secretAccessKey,
},
endpoint: env.S3_ENDPOINT,
region: env.S3_REGION,
endpoint,
region,
});
this.bucketName = bucketName;
}
public async uploadFile({
fileName,
fileType,
data,
expiresInSeconds,
}: UploadFile): Promise<{ signedUrl: string }> {
try {
await new Upload({
client: this.client,
params: {
Bucket: env.S3_BUCKET_NAME,
Bucket: this.bucketName,
Key: fileName,
Body: data,
ContentType: fileType,
},
}).done();
const expiresInOneHour = 60 * 60;
const signedUrl = await this.getSignedUrl(fileName, expiresInOneHour);
const signedUrl = await this.getSignedUrl(fileName, expiresInSeconds);
return { signedUrl };
} catch (err) {
@@ -57,42 +64,20 @@ class S3StorageService {
private async getSignedUrl(
fileName: string,
ttlSeconds: number,
ttlSeconds: number
): Promise<string> {
try {
return await getSignedUrl(
this.client,
new GetObjectCommand({
Bucket: env.S3_BUCKET_NAME,
Bucket: this.bucketName,
Key: fileName,
ResponseContentDisposition: `attachment; filename="${fileName}"`,
}),
{ expiresIn: ttlSeconds },
{ expiresIn: ttlSeconds }
);
} catch (err) {
throw Error("Failed to generate signed URL");
}
}
static getIsS3StorageConfigured(
currentEnv: Env,
): currentEnv is S3ConfiguredEnv {
return Boolean(
currentEnv.S3_BUCKET_NAME &&
currentEnv.S3_ACCESS_KEY_ID &&
currentEnv.S3_SECRET_ACCESS_KEY &&
currentEnv.S3_ENDPOINT &&
currentEnv.S3_REGION,
);
}
}
export { S3StorageService };
type Env = typeof env;
type S3ConfiguredEnv = Env & {
S3_ACCESS_KEY_ID: string;
S3_SECRET_ACCESS_KEY: string;
S3_ENDPOINT: string;
S3_REGION: string;
};
@@ -0,0 +1,83 @@
import * as React from "react";
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Img,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
interface BatchExportSuccessTemplateProps {
userName: string;
receiverEmail: string;
downloadLink: string;
batchExportName: string;
expiresInHours: number;
}
export const BatchExportSuccessEmailTemplate = ({
receiverEmail,
downloadLink,
userName,
batchExportName,
expiresInHours,
}: BatchExportSuccessTemplateProps) => {
const previewText = `Download your data export from Langfuse`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-background font-sans">
<Container className="mx-auto my-10 w-[465px] rounded border border-solid border-[#eaeaea] p-5">
<Section className="mt-8">
<Img
src="https://static.langfuse.com/langfuse_logo_transactional_email.png"
width="40"
height="40"
alt="Langfuse"
className="mx-auto my-0"
/>
</Section>
<Heading className="mx-0 my-[30px] p-0 text-center text-2xl font-normal text-black">
Your data export is ready
</Heading>
<Text className="text-sm leading-6 text-black">
Hello <strong>{userName}</strong>
</Text>
<Text className="text-sm leading-6 text-black">
Your data export{" "}
<span className="font-mono">{batchExportName}</span> is ready to
download. The download link will expire in{" "}
<strong>{expiresInHours} hours</strong>.
</Text>
<Section className="mb-4 mt-8 text-center">
<Button
className="rounded bg-black px-5 py-3 text-center text-xs font-semibold text-white no-underline"
href={downloadLink}
>
Download Export
</Button>
</Section>
<Hr className="mx-0 my-[26px] w-full border border-solid border-[#eaeaea]" />
<Text className="text-xs leading-6 text-[#666666]">
This email was intended for{" "}
<span className="text-black">{receiverEmail}</span>. If you were
not expecting this email, please delete it.
</Text>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default BatchExportSuccessEmailTemplate;
@@ -0,0 +1,56 @@
import { createTransport } from "nodemailer";
import { parseConnectionUrl } from "nodemailer/lib/shared/index.js";
import { render } from "@react-email/render";
import { BatchExportSuccessEmailTemplate } from "./BatchExportSuccessEmailTemplate";
type SendBatchExportSuccessParams = {
env: Partial<
Record<"EMAIL_FROM_ADDRESS" | "SMTP_CONNECTION_URL", string | undefined>
>;
receiverEmail: string;
downloadLink: string;
userName: string;
batchExportName: string;
expiresInHours: number;
};
export const sendBatchExportSuccessEmail = async ({
env,
receiverEmail,
downloadLink,
userName,
batchExportName,
expiresInHours,
}: SendBatchExportSuccessParams) => {
if (!env.EMAIL_FROM_ADDRESS || !env.SMTP_CONNECTION_URL) {
console.error("Missing environment variables for sending email.");
return;
}
try {
const mailer = createTransport(parseConnectionUrl(env.SMTP_CONNECTION_URL));
const htmlTemplate = render(
BatchExportSuccessEmailTemplate({
receiverEmail,
downloadLink,
userName,
batchExportName,
expiresInHours,
})
);
await mailer.sendMail({
to: receiverEmail,
from: {
address: env.EMAIL_FROM_ADDRESS,
name: "Langfuse",
},
subject: "Your data export is ready",
html: htmlTemplate,
});
} catch (error) {
console.error(error);
}
};
@@ -0,0 +1,88 @@
/*
* To be used in the `sendVerificationRequest` function of the `email` provider of NextAuth.js.
*/
import * as React from "react";
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Img,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
import { createTransport } from "nodemailer";
import { render } from "@react-email/render";
import { type SendVerificationRequestParams } from "next-auth/providers/email";
interface ResetPasswordTemplateProps {
url: string;
}
const ResetPasswordTemplate = ({ url }: ResetPasswordTemplateProps) => {
const previewText = "Reset your Langfuse password";
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-background font-sans">
<Container className="mx-auto my-10 w-[465px] rounded border border-solid border-[#eaeaea] p-5">
<Section className="mt-8">
<Img
src="https://static.langfuse.com/langfuse_logo_transactional_email.png"
width="40"
height="40"
alt="Langfuse"
className="mx-auto my-0"
/>
</Section>
<Heading className="mx-0 my-[30px] p-0 text-center text-xl font-normal text-black">
Forgot your Langfuse password?
<br />
It happens to the best of us.
</Heading>
<Section className="mb-8 mt-8 text-center">
<Button
className="rounded bg-black px-5 py-3 text-center text-xs font-semibold text-white no-underline"
href={url}
>
Reset your password
</Button>
</Section>
<Text className="text-center text-xs leading-6 text-[#666666]">
If you do not want to change your password or didn&apos;t request
a reset, you can ignore and delete this email.
</Text>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export async function sendResetPasswordVerificationRequest(
params: SendVerificationRequestParams
) {
const { identifier, url, provider } = params;
const transport = createTransport(provider.server);
const htmlTemplate = render(<ResetPasswordTemplate url={url} />);
const result = await transport.sendMail({
to: identifier,
from: provider.from,
subject: `Forgot your password?`,
text: `To reset your Langfuse password, please confirm your email:\n${url}\n\nIf you do not want to change your password or didn't request a reset, you can ignore and delete this email.`,
html: htmlTemplate,
});
const failed = result.rejected.concat(result.pending).filter(Boolean);
if (failed.length) {
throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`);
}
}
export default ResetPasswordTemplate;
@@ -15,14 +15,13 @@ import {
Text,
} from "@react-email/components";
import { env } from "@/src/env.mjs";
interface ProjectInvitationTemplateProps {
invitedByUsername: string;
invitedByUserEmail: string;
projectName: string;
recieverEmail: string;
receiverEmail: string;
inviteLink: string;
emailFromAddress: string;
langfuseCloudRegion?: string;
}
@@ -30,8 +29,9 @@ export const ProjectInvitationTemplate = ({
invitedByUsername,
invitedByUserEmail,
projectName,
recieverEmail,
receiverEmail,
inviteLink,
emailFromAddress,
langfuseCloudRegion,
}: ProjectInvitationTemplateProps) => {
const previewText = `Join ${invitedByUsername} on Langfuse`;
@@ -72,7 +72,7 @@ export const ProjectInvitationTemplate = ({
</Text>
<Section className="mb-4 mt-8 text-center">
<Button
className="rounded bg-black px-5 py-3 text-center text-xs font-semibold text-background no-underline"
className="rounded bg-black px-5 py-3 text-center text-xs font-semibold text-white no-underline"
href={inviteLink}
>
Accept Invitation
@@ -90,10 +90,10 @@ export const ProjectInvitationTemplate = ({
<Hr className="mx-0 my-[26px] w-full border border-solid border-[#eaeaea]" />
<Text className="text-xs leading-6 text-[#666666]">
This invitation was intended for{" "}
<span className="text-black">{recieverEmail}</span>. This invite
<span className="text-black">{receiverEmail}</span>. This invite
was sent from{" "}
<span className="text-black">{env.EMAIL_FROM_ADDRESS}</span>. If
you were not expecting this invitation, you can ignore this email.
<span className="text-black">{emailFromAddress}</span>. If you
were not expecting this invitation, you can ignore this email.
</Text>
</Container>
</Body>
@@ -0,0 +1,85 @@
import { createTransport } from "nodemailer";
import { parseConnectionUrl } from "nodemailer/lib/shared/index.js";
import { render } from "@react-email/render";
import ProjectInvitationTemplate from "./ProjectInvitationEmailTemplate";
const langfuseUrls = {
US: "https://us.cloud.langfuse.com",
EU: "https://cloud.langfuse.com",
STAGING: "https://staging.langfuse.com",
};
type SendProjectInvitationParams = {
env: Partial<
Record<
| "EMAIL_FROM_ADDRESS"
| "SMTP_CONNECTION_URL"
| "NEXT_PUBLIC_LANGFUSE_CLOUD_REGION"
| "NEXTAUTH_URL",
string | undefined
>
>;
to: string;
inviterName: string;
inviterEmail: string;
projectName: string;
};
export const sendProjectInvitationEmail = async ({
env,
to,
inviterName,
inviterEmail,
projectName,
}: SendProjectInvitationParams) => {
if (!env.EMAIL_FROM_ADDRESS || !env.SMTP_CONNECTION_URL) {
console.error(
"Missing environment variables for sending project invitation email."
);
return;
}
const getAuthURL = () =>
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "US" ||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "EU" ||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "STAGING"
? langfuseUrls[env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION]
: env.NEXTAUTH_URL;
const authUrl = getAuthURL();
if (!authUrl) {
console.error(
"Missing NEXTAUTH_URL or NEXT_PUBLIC_LANGFUSE_CLOUD_REGION environment variable."
);
return;
}
try {
const mailer = createTransport(parseConnectionUrl(env.SMTP_CONNECTION_URL));
const htmlTemplate = render(
ProjectInvitationTemplate({
invitedByUsername: inviterName,
invitedByUserEmail: inviterEmail,
projectName: projectName,
receiverEmail: to,
inviteLink: authUrl,
emailFromAddress: env.EMAIL_FROM_ADDRESS,
langfuseCloudRegion: env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION,
})
);
await mailer.sendMail({
to,
from: {
address: env.EMAIL_FROM_ADDRESS,
name: "Langfuse",
},
subject: `${inviterName} invited you to join "${projectName}"`,
html: htmlTemplate,
});
} catch (error) {
console.error(error);
}
};
@@ -26,9 +26,10 @@ export class DatabaseReadStream<EntityType> extends Readable {
// the delegate function takes care of querying the database in a paginated manner
private queryDelegate: (
pageSize: number,
offset: number,
offset: number
) => Promise<Array<EntityType>>,
private pageSize: number,
private maxRecords?: number
) {
super({ objectMode: true }); // Set object mode to true to allow pushing objects to the stream rather than strings or buffers
@@ -43,10 +44,19 @@ export class DatabaseReadStream<EntityType> extends Readable {
this.isReading = true;
try {
// Stop reading if the maximum number of records has been reached
if (this.maxRecords && this.offset >= this.maxRecords) {
this.hasNextPage = false;
this.push(null); // Signal end of stream
return;
}
const rows = await this.queryDelegate(this.pageSize, this.offset);
if (rows.length > 0) {
rows.forEach((row) => this.push(row));
this.offset += rows.length;
this.offset += this.pageSize;
} else {
this.hasNextPage = false;
this.push(null); // Signal end of stream
@@ -0,0 +1,13 @@
import { Transform } from "stream";
import { BatchExportFileFormat } from "../../../features/batchExport/types";
import { transformStreamToCsv } from "./transformStreamToCsv";
import { transformStreamToJson } from "./transformStreamToJson";
export const streamTransformations: Record<
BatchExportFileFormat,
() => Transform
> = {
CSV: transformStreamToCsv,
JSON: transformStreamToJson,
};
@@ -0,0 +1,5 @@
export const stringify = (data: any): string => {
return JSON.stringify(data, (key, value) =>
typeof value === "bigint" ? Number.parseInt(value.toString()) : value
);
};
@@ -0,0 +1,39 @@
import { Transform, type TransformCallback } from "stream";
import { stringify } from "./stringify";
export function transformStreamToCsv(): Transform {
let isFirstChunk = true;
let headers: string[] = [];
return new Transform({
objectMode: true,
transform(
row: Record<string, any>,
encoding: BufferEncoding,
callback: TransformCallback
): void {
if (isFirstChunk) {
// Extract headers from the first object
headers = Object.keys(row);
this.push(headers.join(",") + "\n");
isFirstChunk = false;
}
// Convert the object to a CSV line and push it
const csvRow = headers.map((header) => {
const field = row[header];
const str = stringify(field);
if (str.startsWith('"') && str.endsWith('"')) {
return str;
} else {
return `"${str?.replace(/"/g, '""') ?? ""}"`;
}
});
this.push(csvRow.join(",") + "\n");
callback();
},
});
}
@@ -0,0 +1,39 @@
import { Transform, type TransformCallback } from "stream";
import { stringify } from "./stringify";
export function transformStreamToJson(): Transform {
let isFirstElement = true;
return new Transform({
objectMode: true,
transform(
row: any,
encoding: BufferEncoding,
callback: TransformCallback
): void {
if (isFirstElement) {
this.push("["); // Push the opening bracket for the first element
isFirstElement = false; // Reset the flag after the first element
} else {
this.push(","); // For subsequent elements, prepend a comma
}
this.push(stringify(row)); // Push the current row as a JSON string
callback();
},
// 'final' is called when there is no more data to be consumed, but before the stream is finished.
final(callback: TransformCallback): void {
if (isFirstElement) {
// If no rows were processed, the opening bracket has not been pushed yet.
this.push("[]"); // Push an empty array to ensure valid JSON.
} else {
this.push("]"); // Close JSON array
}
callback();
},
});
}
@@ -0,0 +1,2 @@
export * from "./sessionsView";
export * from "./types";
@@ -1,7 +1,7 @@
import {
type OptionsDefinition,
type ColumnDefinition,
} from "@langfuse/shared";
} from "../tableDefinitions/types";
export const sessionsViewCols: ColumnDefinition[] = [
{ name: "⭐️", id: "bookmarked", type: "boolean", internal: "s.bookmarked" },
@@ -85,7 +85,7 @@ export type SessionOptions = {
};
export function sessionsTableColsWithOptions(
options?: SessionOptions,
options?: SessionOptions
): ColumnDefinition[] {
return sessionsViewCols.map((col) => {
if (col.id === "userIds") {
+25
View File
@@ -58,7 +58,32 @@ export const optionalPaginationZod = {
.optional(),
};
export const queryStringZod = z
.string()
.transform((val) => decodeURIComponent(val));
export const paginationMetaResponseZod = z.object({
page: z.number().int().positive(),
limit: z.number().int().positive(),
totalItems: z.number().int().nonnegative(),
totalPages: z.number().int().nonnegative(),
});
export const noHtmlRegex = /<[^>]*>/;
export const noHtmlCheck = (value: string) => !noHtmlRegex.test(value);
export const NonEmptyString = z.string().min(1);
/**
* Validates an object against a Zod schema and helps with IDE type warnings.
*
* @param schema - The Zod schema to validate against.
* @param object - The object to be validated.
* @returns The parsed object if validation is successful.
*/
export const validateZodSchema = <T extends z.ZodTypeAny>(
schema: T,
object: z.infer<T>
): z.infer<T> => {
return schema.parse(object);
};
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "@repo/typescript-config/base.json",
"compilerOptions": {
"jsx": "react",
"moduleResolution": "NodeNext",
"module": "NodeNext",
"lib": ["ES2020"],
+1808 -1289
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -9,7 +9,9 @@
"start": {
"dependsOn": ["^start"]
},
"db:migrate": {},
"db:migrate": {
"cache": false
},
"db:deploy": {},
"db:reset": {},
"db:push": {},
+15 -5
View File
@@ -1,10 +1,10 @@
FROM node:20-alpine3.20 AS alpine
FROM --platform=${BUILDPLATFORM:-linux/amd64} node:20-alpine3.20 AS alpine
# It's important to update the index before installing packages to ensure you're getting the latest versions.
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk update && apk upgrade --no-cache libcrypto3 libssl3 libc6-compat busybox ssl_client
FROM alpine AS base
FROM --platform=${BUILDPLATFORM:-linux/amd64} alpine AS base
RUN npm install turbo@^1.13.3 --global
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
@@ -12,7 +12,7 @@ RUN corepack enable
RUN corepack prepare pnpm@8.15.5 --activate
FROM base AS pruner
FROM --platform=${BUILDPLATFORM:-linux/amd64} base AS pruner
WORKDIR /app
@@ -20,7 +20,7 @@ COPY . .
RUN turbo prune --scope=web --docker
FROM base AS builder
FROM --platform=${BUILDPLATFORM:-linux/amd64} base AS builder
WORKDIR /app
@@ -33,6 +33,15 @@ RUN pnpm install --frozen-lockfile
ENV DOCKER_BUILD 1
# pass public variables in build step
ARG NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
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/ .
@@ -48,7 +57,7 @@ ENV NEXT_TELEMETRY_DISABLED 1
RUN turbo run build --filter=web
# Production image, copy all the files and run next
FROM base AS runner
FROM --platform=${TARGETPLATFORM:-linux/amd64} base AS runner
RUN apk add --no-cache dumb-init
@@ -78,6 +87,7 @@ COPY --from=builder --chown=nextjs:nodejs /app/web/public ./web/public
COPY --from=builder --chown=nextjs:nodejs /app/packages/shared/prisma ./packages/shared/prisma
COPY --chown=nextjs:nodejs ./web/entrypoint.sh ./web/entrypoint.sh
COPY --chown=nextjs:nodejs ./packages/shared/scripts/cleanup.sql ./packages/shared/scripts/cleanup.sql
RUN chmod +x ./web/entrypoint.sh
USER nextjs
+8 -2
View File
@@ -1,5 +1,6 @@
#!/bin/sh
# Run cleanup script before running migrations
# Check if DATABASE_URL is not set
if [ -z "$DATABASE_URL" ]; then
# Check if all required variables are provided
@@ -18,8 +19,13 @@ if [ -z "$DIRECT_URL" ]; then
export DIRECT_URL=$DATABASE_URL
fi
# Apply migrations
prisma migrate deploy --schema=./packages/shared/prisma/schema.prisma
# Always execute the scripts, except when disabled.
if [ "$LANGFUSE_AUTO_POSTGRES_MIGRATION_DISABLED" != "true" ]; then
prisma db execute --url "$DIRECT_URL" --file "./packages/shared/scripts/cleanup.sql"
# Apply migrations
prisma migrate deploy --schema=./packages/shared/prisma/schema.prisma
fi
status=$?
# If migration fails (returns non-zero exit status), exit script with that status
+39 -37
View File
@@ -11,8 +11,8 @@ import { env } from "./src/env.mjs";
* img-src https to allow loading images from SSO providers
*/
const cspHeader = `
default-src 'self' https://ph.langfuse.com https://*.posthog.com wss://*.crisp.chat https://*.crisp.chat;
script-src 'self' 'unsafe-eval' https://*.crisp.chat https://challenges.cloudflare.com https://ph.langfuse.com https://static.cloudflareinsights.com https://*.stripe.com;
default-src 'self' https://*.langfuse.com https://*.posthog.com https://*.sentry.io wss://*.crisp.chat https://*.crisp.chat;
script-src 'self' 'unsafe-eval' https://*.langfuse.com https://*.crisp.chat https://challenges.cloudflare.com https://*.sentry.io https://ph.langfuse.com https://static.cloudflareinsights.com https://*.stripe.com;
style-src 'self' 'unsafe-inline' https://*.crisp.chat;
img-src 'self' https: blob: data:;
font-src 'self' https://*.crisp.chat;
@@ -29,6 +29,9 @@ const cspHeader = `
const nextConfig = {
transpilePackages: ["@langfuse/shared"],
reactStrictMode: true,
experimental: {
instrumentationHook: true,
},
/**
* If you have `experimental: { appDir: true }` set, then you must comment the below `i18n` config
@@ -77,22 +80,22 @@ const nextConfig = {
// Required to check authentication status from langfuse.com
...(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined
? [
{
source: "/api/auth/session",
headers: [
{
key: "Access-Control-Allow-Origin",
value: "https://langfuse.com",
},
{ key: "Access-Control-Allow-Credentials", value: "true" },
{ key: "Access-Control-Allow-Methods", value: "GET,POST" },
{
key: "Access-Control-Allow-Headers",
value: "Content-Type, Authorization",
},
],
},
]
{
source: "/api/auth/session",
headers: [
{
key: "Access-Control-Allow-Origin",
value: "https://langfuse.com",
},
{ key: "Access-Control-Allow-Credentials", value: "true" },
{ key: "Access-Control-Allow-Methods", value: "GET,POST" },
{
key: "Access-Control-Allow-Headers",
value: "Content-Type, Authorization",
},
],
},
]
: []),
];
},
@@ -106,26 +109,9 @@ const nextConfig = {
return config;
},
sentry: {
// See the sections below for information on the following options:
// 'Configure Source Maps':
// - disableServerWebpackPlugin
// - disableClientWebpackPlugin
// - hideSourceMaps
hideSourceMaps: true,
// - widenClientFileUpload
// 'Configure Legacy Browser Support':
// - transpileClientSDK
// 'Configure Serverside Auto-instrumentation':
// - autoInstrumentServerFunctions
// - excludeServerRoutes
// 'Configure Tunneling':
// - tunnelRoute
tunnelRoute: "/api/monitoring-tunnel",
},
};
const sentryWebpackPluginOptions = {
const sentryOptions = {
// Additional config options for the Sentry Webpack plugin. Keep in mind that
// the following options are set automatically, and overriding them is not
// recommended:
@@ -139,6 +125,22 @@ const sentryWebpackPluginOptions = {
// For all available options, see:
// https://github.com/getsentry/sentry-webpack-plugin#options.
// See the sections below for information on the following options:
// 'Configure Source Maps':
// - disableServerWebpackPlugin
// - disableClientWebpackPlugin
// - hideSourceMaps
hideSourceMaps: true,
// - widenClientFileUpload
// 'Configure Legacy Browser Support':
// - transpileClientSDK
// 'Configure Serverside Auto-instrumentation':
// - autoInstrumentServerFunctions
// - excludeServerRoutes
// 'Configure Tunneling':
// - tunnelRoute
tunnelRoute: "/api/monitoring-tunnel",
};
export default withSentryConfig(nextConfig, sentryWebpackPluginOptions);
export default withSentryConfig(nextConfig, sentryOptions);
+8 -14
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "2.53.0",
"version": "2.60.0",
"private": true,
"license": "MIT",
"engines": {
@@ -17,13 +17,11 @@
"test": "dotenv -e ../.env -- jest --runInBand",
"test:watch": "dotenv -e ../.env -- jest --watch --runInBand",
"test:e2e": "dotenv -e ../.env -- playwright test",
"models:migrate": "dotenv -e ../.env -- tsx scripts/model-match.ts"
"models:migrate": "dotenv -e ../.env -- tsx scripts/model-match.ts",
"generationCost:backfill": "dotenv -e ../.env -- tsx scripts/observations-backfill-calculated-cost.ts"
},
"dependencies": {
"@anthropic-ai/tokenizer": "^0.0.4",
"@aws-sdk/client-s3": "^3.550.0",
"@aws-sdk/lib-storage": "^3.568.0",
"@aws-sdk/s3-request-presigner": "^3.554.0",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lint": "^6.8.0",
"@headlessui/react": "1.7.18",
@@ -59,15 +57,13 @@
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-toggle-group": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@react-email/components": "^0.0.19",
"@react-email/render": "^0.0.15",
"@remixicon/react": "^4.2.0",
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@sentry/nextjs": "^8.8.0",
"@sentry/node": "^8.8.0",
"@sentry/profiling-node": "^8.8.0",
"@sentry/types": "^8.8.0",
"@sentry/nextjs": "^8.13.0",
"@sentry/node": "^8.13.0",
"@sentry/profiling-node": "^8.13.0",
"@sentry/types": "^8.13.0",
"@t3-oss/env-nextjs": "^0.10.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/react-query": "^4.36.1",
@@ -93,14 +89,13 @@
"exponential-backoff": "^3.1.1",
"js-tiktoken": "^1.0.12",
"kysely": "^0.27.3",
"langchain": "^0.1.36",
"langchain": "^0.2.6",
"lodash": "^4.17.21",
"lucide-react": "^0.364.0",
"next": "^14.2.3",
"next-auth": "^4.24.7",
"next-query-params": "^5.0.0",
"next-themes": "^0.3.0",
"nodemailer": "^6.9.13",
"posthog-js": "^1.122.0",
"posthog-node": "^3.6.3",
"prisma": "^5.13.0",
@@ -134,7 +129,6 @@
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.5",
"@types/node": "20.10.5",
"@types/nodemailer": "^6.4.15",
"@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25",
"@types/uuid": "^9.0.8",
+176 -4
View File
@@ -59,21 +59,39 @@ components:
type: string
traceId:
type: string
example: cdef-1234-5678-90ab
name:
type: string
example: novelty
value:
type: number
format: double
$ref: '#/components/schemas/CreateScoreValue'
description: >-
The value of the score. Must be passed as string for categorical
scores, and numeric for boolean and numeric scores. Boolean score
values must equal either 1 or 0 (true or false)
observationId:
type: string
comment:
type: string
dataType:
$ref: '#/components/schemas/ScoreDataType'
description: >-
When set, must match the score value's type. If not set, will be
inferred from the score value or config
configId:
type: string
description: >-
Reference a score config on a score. When set, the score name must
equal the config name and scores must comply with the config's range
and data type. For categorical scores, the value must map to a
config category. Numeric scores might be constrained by the score
config's max and min values
required:
- traceId
- name
- value
Score:
title: Score
NumericScore:
title: NumericScore
type: object
properties:
id:
@@ -85,6 +103,9 @@ components:
value:
type: number
format: double
description: The numeric value of the score
source:
$ref: '#/components/schemas/ScoreSource'
observationId:
type: string
timestamp:
@@ -92,12 +113,163 @@ components:
format: date-time
comment:
type: string
configId:
type: string
description: >-
Reference a score config on a score. When set, config and score name
must be equal and value must comply to optionally defined numerical
range
required:
- id
- traceId
- name
- value
- source
- timestamp
BooleanScore:
title: BooleanScore
type: object
properties:
id:
type: string
traceId:
type: string
name:
type: string
value:
type: number
format: double
description: >-
The numeric value of the score. Equals 1 for "True" and 0 for
"False"
stringValue:
type: string
description: >-
The string representation of the score value. Is inferred from the
numeric value and equals "True" or "False"
source:
$ref: '#/components/schemas/ScoreSource'
observationId:
type: string
timestamp:
type: string
format: date-time
comment:
type: string
configId:
type: string
description: >-
Reference a score config on a score. When set, config and score name
must be equal
required:
- id
- traceId
- name
- value
- stringValue
- source
- timestamp
CategoricalScore:
title: CategoricalScore
type: object
properties:
id:
type: string
traceId:
type: string
name:
type: string
value:
type: number
format: double
description: >-
Only defined if a config is linked. Represents the numeric category
mapping of the stringValue
stringValue:
type: string
description: >-
The string representation of the score value. If no config is
linked, can be any string. Otherwise, must map to a config category
source:
$ref: '#/components/schemas/ScoreSource'
observationId:
type: string
timestamp:
type: string
format: date-time
comment:
type: string
configId:
type: string
description: >-
Reference a score config on a score. When set, config and score name
must be equal and stringValue must map to a config category
required:
- id
- traceId
- name
- stringValue
- source
- timestamp
Score:
title: Score
oneOf:
- type: object
allOf:
- type: object
properties:
dataType:
type: string
enum:
- NUMERIC
- $ref: '#/components/schemas/NumericScore'
required:
- dataType
- type: object
allOf:
- type: object
properties:
dataType:
type: string
enum:
- CATEGORICAL
- $ref: '#/components/schemas/CategoricalScore'
required:
- dataType
- type: object
allOf:
- type: object
properties:
dataType:
type: string
enum:
- BOOLEAN
- $ref: '#/components/schemas/BooleanScore'
required:
- dataType
ScoreSource:
title: ScoreSource
type: string
enum:
- ANNOTATION
- API
- EVAL
ScoreDataType:
title: ScoreDataType
type: string
enum:
- NUMERIC
- CATEGORICAL
- BOOLEAN
CreateScoreValue:
title: CreateScoreValue
oneOf:
- type: string
- type: number
format: double
description: >-
The value of the score. Must be passed as string for categorical scores,
and numeric for boolean and numeric scores
securitySchemes:
BearerAuth:
type: http
File diff suppressed because it is too large Load Diff
+370 -14
View File
@@ -106,6 +106,57 @@
"body": null
},
"response": []
},
{
"_type": "endpoint",
"name": "List",
"request": {
"description": "Get dataset items",
"url": {
"raw": "{{baseUrl}}/api/public/dataset-items?datasetName=&sourceTraceId=&sourceObservationId=&page=&limit=",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"dataset-items"
],
"query": [
{
"key": "datasetName",
"value": "",
"description": null
},
{
"key": "sourceTraceId",
"value": "",
"description": null
},
{
"key": "sourceObservationId",
"value": "",
"description": null
},
{
"key": "page",
"value": "",
"description": "page number, starts at 1"
},
{
"key": "limit",
"value": "",
"description": "limit of items per page"
}
],
"variable": []
},
"header": [],
"method": "GET",
"auth": null,
"body": null
},
"response": []
}
]
},
@@ -160,25 +211,26 @@
"request": {
"description": "Get all datasets",
"url": {
"raw": "{{baseUrl}}/api/public/datasets?page=&limit=",
"raw": "{{baseUrl}}/api/public/v2/datasets?page=&limit=",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"v2",
"datasets"
],
"query": [
{
"key": "page",
"value": "",
"description": null
"description": "page number, starts at 1"
},
{
"key": "limit",
"value": "",
"description": null
"description": "limit of items per page"
}
],
"variable": []
@@ -194,15 +246,16 @@
"_type": "endpoint",
"name": "Get",
"request": {
"description": "Get a dataset and its items",
"description": "Get a dataset",
"url": {
"raw": "{{baseUrl}}/api/public/datasets/:datasetName",
"raw": "{{baseUrl}}/api/public/v2/datasets/:datasetName",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"v2",
"datasets",
":datasetName"
],
@@ -228,13 +281,14 @@
"request": {
"description": "Create a dataset",
"url": {
"raw": "{{baseUrl}}/api/public/datasets",
"raw": "{{baseUrl}}/api/public/v2/datasets",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"v2",
"datasets"
],
"query": [],
@@ -257,7 +311,7 @@
},
{
"_type": "endpoint",
"name": "Get Runs",
"name": "Get Run",
"request": {
"description": "Get a dataset run and its items",
"url": {
@@ -293,6 +347,50 @@
"body": null
},
"response": []
},
{
"_type": "endpoint",
"name": "Get Runs",
"request": {
"description": "Get dataset runs",
"url": {
"raw": "{{baseUrl}}/api/public/datasets/:datasetName/runs?page=&limit=",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"datasets",
":datasetName",
"runs"
],
"query": [
{
"key": "page",
"value": "",
"description": "page number, starts at 1"
},
{
"key": "limit",
"value": "",
"description": "limit of items per page"
}
],
"variable": [
{
"key": "datasetName",
"value": "",
"description": null
}
]
},
"header": [],
"method": "GET",
"auth": null,
"body": null
},
"response": []
}
]
},
@@ -337,7 +435,7 @@
"_type": "endpoint",
"name": "Batch",
"request": {
"description": "Batched ingestion for Langfuse Tracing. If you want to use tracing via the API, such as to build your own Langfuse client implementation, this is the only API route you need to implement.",
"description": "Batched ingestion for Langfuse Tracing. If you want to use tracing via the API, such as to build your own Langfuse client implementation, this is the only API route you need to implement.\n\nNotes:\n\n- Batch sizes are limited to 3.5 MB in total. You need to adjust the number of events per batch accordingly.\n- The API does not return a 4xx status code for input errors. Instead, it responds with a 207 status code, which includes a list of the encountered errors.",
"url": {
"raw": "{{baseUrl}}/api/public/ingestion",
"host": [
@@ -437,6 +535,146 @@
}
]
},
{
"_type": "container",
"description": null,
"name": "Models",
"item": [
{
"_type": "endpoint",
"name": "Create",
"request": {
"description": "Create a model",
"url": {
"raw": "{{baseUrl}}/api/public/models",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"models"
],
"query": [],
"variable": []
},
"header": [],
"method": "POST",
"auth": null,
"body": {
"mode": "raw",
"raw": "{\n \"modelName\": \"example\",\n \"matchPattern\": \"example\",\n \"startDate\": \"1994-11-05\",\n \"unit\": \"CHARACTERS\",\n \"inputPrice\": 0,\n \"outputPrice\": 0,\n \"totalPrice\": 0,\n \"tokenizerId\": \"example\",\n \"tokenizerConfig\": \"UNKNOWN\"\n}",
"options": {
"raw": {
"language": "json"
}
}
}
},
"response": []
},
{
"_type": "endpoint",
"name": "List",
"request": {
"description": "Get all models",
"url": {
"raw": "{{baseUrl}}/api/public/models?page=&limit=",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"models"
],
"query": [
{
"key": "page",
"value": "",
"description": "page number, starts at 1"
},
{
"key": "limit",
"value": "",
"description": "limit of items per page"
}
],
"variable": []
},
"header": [],
"method": "GET",
"auth": null,
"body": null
},
"response": []
},
{
"_type": "endpoint",
"name": "Get",
"request": {
"description": "Get a model",
"url": {
"raw": "{{baseUrl}}/api/public/models/:id",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"models",
":id"
],
"query": [],
"variable": [
{
"key": "id",
"value": "",
"description": null
}
]
},
"header": [],
"method": "GET",
"auth": null,
"body": null
},
"response": []
},
{
"_type": "endpoint",
"name": "Delete",
"request": {
"description": "Delete a model. Cannot delete models managed by Langfuse. You can create your own definition with the same modelName to override the definition though.",
"url": {
"raw": "{{baseUrl}}/api/public/models/:id",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"models",
":id"
],
"query": [],
"variable": [
{
"key": "id",
"value": "",
"description": null
}
]
},
"header": [],
"method": "DELETE",
"auth": null,
"body": null
},
"response": []
}
]
},
{
"_type": "container",
"description": null,
@@ -658,12 +896,12 @@
{
"key": "page",
"value": "",
"description": null
"description": "page number, starts at 1"
},
{
"key": "limit",
"value": "",
"description": null
"description": "limit of items per page"
}
],
"variable": []
@@ -711,6 +949,114 @@
}
]
},
{
"_type": "container",
"description": null,
"name": "Score Configs",
"item": [
{
"_type": "endpoint",
"name": "Create",
"request": {
"description": "Create a score configuration (config). Score configs are used to define the structure of scores",
"url": {
"raw": "{{baseUrl}}/api/public/score-configs",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"score-configs"
],
"query": [],
"variable": []
},
"header": [],
"method": "POST",
"auth": null,
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"example\",\n \"dataType\": \"NUMERIC\",\n \"categories\": [\n {\n \"value\": 0,\n \"label\": \"example\"\n }\n ],\n \"minValue\": 0,\n \"maxValue\": 0,\n \"description\": \"example\"\n}",
"options": {
"raw": {
"language": "json"
}
}
}
},
"response": []
},
{
"_type": "endpoint",
"name": "Get",
"request": {
"description": "Get all score configs",
"url": {
"raw": "{{baseUrl}}/api/public/score-configs?page=&limit=",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"score-configs"
],
"query": [
{
"key": "page",
"value": "",
"description": "Page number, starts at 1."
},
{
"key": "limit",
"value": "",
"description": "Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit"
}
],
"variable": []
},
"header": [],
"method": "GET",
"auth": null,
"body": null
},
"response": []
},
{
"_type": "endpoint",
"name": "Get By Id",
"request": {
"description": "Get a score config",
"url": {
"raw": "{{baseUrl}}/api/public/score-configs/:configId",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"score-configs",
":configId"
],
"query": [],
"variable": [
{
"key": "configId",
"value": "",
"description": "The unique langfuse identifier of a score config"
}
]
},
"header": [],
"method": "GET",
"auth": null,
"body": null
},
"response": []
}
]
},
{
"_type": "container",
"description": null,
@@ -739,7 +1085,7 @@
"auth": null,
"body": {
"mode": "raw",
"raw": "{\n \"id\": \"example\",\n \"traceId\": \"example\",\n \"name\": \"example\",\n \"value\": 0,\n \"observationId\": \"example\",\n \"comment\": \"example\"\n}",
"raw": "{\n \"name\": \"novelty\",\n \"value\": 0.9,\n \"traceId\": \"cdef-1234-5678-90ab\"\n}",
"options": {
"raw": {
"language": "json"
@@ -755,7 +1101,7 @@
"request": {
"description": "Get a list of scores",
"url": {
"raw": "{{baseUrl}}/api/public/scores?page=&limit=&userId=&name=&fromTimestamp=&source=&operator=&value=&scoreIds=",
"raw": "{{baseUrl}}/api/public/scores?page=&limit=&userId=&name=&fromTimestamp=&source=&operator=&value=&scoreIds=&configId=&dataType=",
"host": [
"{{baseUrl}}"
],
@@ -778,12 +1124,12 @@
{
"key": "userId",
"value": "",
"description": null
"description": "Retrieve only scores with this userId associated to the trace."
},
{
"key": "name",
"value": "",
"description": null
"description": "Retrieve only scores with this name."
},
{
"key": "fromTimestamp",
@@ -809,6 +1155,16 @@
"key": "scoreIds",
"value": "",
"description": "Comma-separated list of score IDs to limit the results to."
},
{
"key": "configId",
"value": "",
"description": "Retrieve only scores with a specific configId."
},
{
"key": "dataType",
"value": "",
"description": "Retrieve only scores with a specific dataType."
}
],
"variable": []
@@ -0,0 +1,249 @@
import "dotenv/config";
import { z } from "zod";
import { prisma, Prisma } from "@langfuse/shared/src/db";
const BackfillCalculatedGenerationArgsSchema = z
.object({
batchSize: z.coerce.number().optional().default(5_000),
maxRowsToProcess: z.coerce.number().optional().default(Infinity), // Default to process all rows
maxDate: z.coerce.date().optional().default(new Date()), // Default to today
})
.strict();
const backfillCalculatedGenerationCost = async () => {
let previousTimeout;
try {
const args = parseArgs(process.argv.slice(2));
const { batchSize, maxRowsToProcess, maxDate } = args;
log("Starting backfillCalculatedGenerationCost with params", args);
// Set the statement timeout
const newTimeout = "19min";
previousTimeout = await updateStatementTimeout(newTimeout, previousTimeout);
// Drop column if it exists and add temporary column
await addTemporaryColumnIfNotExists();
let currentDateCutoff = maxDate.toISOString();
let totalRowsProcessed = 0;
log("Starting batch update loop...");
// Step 3: Batch update in a loop
while (true) {
log(`Starting batch update for generations before: ${currentDateCutoff}`);
const startDate = Date.now();
const batchUpdate = await prisma.$queryRaw<
{ start_time: Date }[]
>(Prisma.sql`
WITH batch AS (
SELECT o.id,
o.start_time,
o.prompt_tokens,
o.completion_tokens,
o.total_tokens,
o.input_cost,
o.output_cost,
o.total_cost,
m.id AS model_id,
m.input_price,
m.output_price,
m.total_price
FROM observations o
LEFT JOIN LATERAL (
SELECT models.id,
models.input_price,
models.output_price,
models.total_price
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 = models.unit
ORDER BY models.project_id, models.start_date DESC NULLS LAST
LIMIT 1
) m ON true
WHERE
start_time <= ${currentDateCutoff}::TIMESTAMP WITH TIME ZONE AT TIME ZONE 'UTC'
AND (internal_model IS NOT NULL
OR input_cost IS NOT NULL
OR output_cost IS NOT NULL
OR total_cost IS NOT NULL)
ORDER BY
start_time DESC
LIMIT ${batchSize}
),
updated_batch AS (
UPDATE observations o
SET calculated_input_cost =
CASE
WHEN batch.input_cost IS NULL AND batch.output_cost IS NULL AND batch.total_cost IS NULL
THEN batch.prompt_tokens::numeric * batch.input_price
ELSE batch.input_cost
END,
calculated_output_cost =
CASE
WHEN batch.input_cost IS NULL AND batch.output_cost IS NULL AND batch.total_cost IS NULL
THEN batch.completion_tokens::numeric * batch.output_price
ELSE batch.output_cost
END,
calculated_total_cost =
CASE
WHEN batch.input_cost IS NULL AND batch.output_cost IS NULL AND batch.total_cost IS NULL
THEN
CASE
WHEN batch.total_price IS NOT NULL AND batch.total_tokens IS NOT NULL THEN batch.total_price * batch.total_tokens::numeric
ELSE batch.prompt_tokens::numeric * batch.input_price + batch.completion_tokens::numeric * batch.output_price
END
ELSE batch.total_cost
END,
internal_model_id = batch.model_id,
tmp_has_calculated_cost = TRUE
FROM batch
WHERE o.id = batch.id
RETURNING o.id
)
-- Get the last id of the updated batch
SELECT start_time FROM batch LIMIT 1 OFFSET ${batchSize - 1};
`);
log(`Batch update completed in ${Date.now() - startDate} ms`);
if (!batchUpdate[0]?.start_time) {
log(
`No more rows to process, breaking loop after ${totalRowsProcessed.toLocaleString()} rows processed.`,
);
break;
}
currentDateCutoff = batchUpdate[0]?.start_time.toISOString();
totalRowsProcessed += batchSize;
log(
`Total rows processed after increment: ${totalRowsProcessed.toLocaleString()} rows`,
);
if (maxRowsToProcess && totalRowsProcessed >= maxRowsToProcess) {
log(
`Max rows to process reached: ${maxRowsToProcess.toLocaleString()}, breaking loop.`,
);
break;
}
}
log("✅ Finished batch update loop.");
// Drop the temporary column
log("Dropping temporary column...");
await prisma.$executeRaw`ALTER TABLE observations DROP COLUMN IF EXISTS tmp_has_calculated_cost;`;
log("✅ Dropped temporary column");
log("✅ Finished backfillCalculatedGenerationCost");
} catch (err) {
console.error("Error executing script", err);
} finally {
// Reset the statement timeout to two minutes
await prisma.$executeRawUnsafe(
`SET statement_timeout = '${previousTimeout}';`,
);
log(
`Reset statement_timeout to ${previousTimeout}. Current statement_timeout: ${JSON.stringify(
await prisma.$queryRaw(Prisma.sql`SHOW statement_timeout;`),
)}`,
);
// Disconnect from the database
await prisma.$disconnect();
log("Disconnected from the database.");
}
};
function parseArgs(args: string[]) {
try {
const namedArgs: Record<string, string | boolean> = {};
for (let i = 0; i < args.length; i++) {
if (args[i].startsWith("--")) {
const key = args[i].slice(2);
const value =
args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : true;
namedArgs[key] = value;
if (value !== true) i++; // Skip the next argument if it was used as a value
}
}
return BackfillCalculatedGenerationArgsSchema.parse(namedArgs);
} catch (error) {
if (error instanceof z.ZodError) {
console.error("Validation error:", error.errors);
} else {
console.error("An unexpected error occurred:", error);
}
process.exit(1);
}
}
async function addTemporaryColumnIfNotExists() {
const columnExists = await prisma.$queryRaw<{ column_exists: boolean }[]>(
Prisma.sql`
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'observations'
AND column_name = 'tmp_has_calculated_cost'
) AS column_exists;
`,
);
if (!columnExists[0]?.column_exists) {
await prisma.$executeRaw`ALTER TABLE observations ADD COLUMN tmp_has_calculated_cost BOOLEAN DEFAULT FALSE;`;
log("✅ Added temporary column tmp_has_calculated_cost");
} else {
log(
"⚠️ Temporary column tmp_has_calculated_cost already exists. Continuing...",
);
}
}
type StatementTimeout = {
statement_timeout: string;
};
async function updateStatementTimeout(
newTimeout: string,
previousTimeout: any,
) {
const [{ statement_timeout: previousTimeoutRead }] = await prisma.$queryRaw<
StatementTimeout[]
>(Prisma.sql`SHOW statement_timeout;`);
log(`Current statement_timeout read from DB: ${previousTimeoutRead}`);
if (!previousTimeoutRead || previousTimeoutRead === newTimeout) {
// If the statement_timeout is already set to 19 minutes, assume it was set by this script and reset it to 2 minutes
previousTimeout = "2min";
} else {
previousTimeout = previousTimeoutRead;
}
log(`Setting statement_timeout to ${newTimeout} minutes...`);
await prisma.$executeRawUnsafe(`SET statement_timeout = '${newTimeout}';`);
log(
`Updated statement_timeout. Current statement_timeout: ${JSON.stringify(
await prisma.$queryRaw(Prisma.sql`SHOW statement_timeout;`),
)}`,
);
return previousTimeout;
}
function log(message: string, ...args: any[]) {
console.log(new Date().toISOString(), " - ", message, ...args);
}
// Execute the script
backfillCalculatedGenerationCost();
+1 -1
View File
@@ -1,7 +1,7 @@
import {
getDisplaySecretKey,
hashSecretKey,
} from "@langfuse/shared/src/server/auth";
} from "@langfuse/shared/src/server";
import { verifyAuthHeaderAndReturnScope } from "@/src/features/public-api/server/apiAuth";
import { prisma } from "@langfuse/shared/src/db";
+525 -101
View File
@@ -1,8 +1,26 @@
/** @jest-environment node */
import { prisma } from "@langfuse/shared/src/db";
import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
import {
makeAPICall,
makeZodVerifiedAPICall,
pruneDatabase,
} from "@/src/__tests__/test-utils";
import { v4 } from "uuid";
import {
GetDatasetItemV1Response,
GetDatasetItemsV1Response,
GetDatasetRunV1Response,
GetDatasetRunsV1Response,
GetDatasetV1Response,
GetDatasetV2Response,
GetDatasetsV1Response,
GetDatasetsV2Response,
PostDatasetItemsV1Response,
PostDatasetRunItemsV1Response,
PostDatasetsV1Response,
PostDatasetsV2Response,
} from "@/src/features/public-api/types/datasets";
describe("/api/public/datasets and /api/public/dataset-items API Endpoints", () => {
const traceId = v4();
@@ -49,11 +67,24 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
});
afterEach(async () => await pruneDatabase());
it("should create and get a dataset, include special characters", async () => {
await makeAPICall("POST", "/api/public/datasets", {
it("should create and get a dataset (v1), include special characters", async () => {
const createRes = await makeZodVerifiedAPICall(
PostDatasetsV1Response,
"POST",
"/api/public/datasets",
{
name: "dataset + name",
description: "dataset-description",
metadata: { foo: "bar" },
},
);
expect(createRes.status).toBe(200);
expect(createRes.body).toMatchObject({
name: "dataset + name",
description: "dataset-description",
metadata: { foo: "bar" },
items: [],
runs: [],
});
const dbDataset = await prisma.dataset.findMany({
@@ -61,62 +92,131 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
name: "dataset + name",
},
});
expect(dbDataset.length).toBeGreaterThan(0);
const getDataset = await makeAPICall(
// get dataset (v1) excluding items and runs
const getDatasetV1 = await makeAPICall(
"GET",
`/api/public/datasets/${encodeURIComponent("dataset + name")}`,
);
expect(getDataset.status).toBe(200);
expect(getDataset.body).toMatchObject({
expect(getDatasetV1.status).toBe(200);
expect(getDatasetV1.body).toMatchObject({
name: "dataset + name",
description: "dataset-description",
metadata: { foo: "bar" },
items: [],
runs: [],
});
});
it("GET datasets", async () => {
await makeAPICall("POST", "/api/public/datasets", {
name: "dataset-name-1",
it("should create and get a dataset (v2), include special characters", async () => {
const createRes = await makeZodVerifiedAPICall(
PostDatasetsV2Response,
"POST",
"/api/public/v2/datasets",
{
name: "dataset + name + v2",
description: "dataset-description",
metadata: { foo: "bar" },
},
);
expect(createRes.status).toBe(200);
expect(createRes.body).toMatchObject({
name: "dataset + name + v2",
description: "dataset-description",
metadata: { foo: "bar" },
});
await makeAPICall("POST", "/api/public/datasets", {
name: "dataset-name-2",
const dbDataset = await prisma.dataset.findMany({
where: {
name: "dataset + name + v2",
},
});
expect(dbDataset.length).toBeGreaterThan(0);
// get dataset (v2) excluding items and runs
const getDatasetV2 = await makeZodVerifiedAPICall(
GetDatasetV2Response,
"GET",
`/api/public/v2/datasets/${encodeURIComponent("dataset + name + v2")}`,
);
expect(getDatasetV2.status).toBe(200);
expect(getDatasetV2.body).toMatchObject({
name: "dataset + name + v2",
description: "dataset-description",
metadata: { foo: "bar" },
});
expect(getDatasetV2.body).not.toHaveProperty("items");
expect(getDatasetV2.body).not.toHaveProperty("runs");
});
it("GET datasets (v1 & v2)", async () => {
// v1 post
await makeZodVerifiedAPICall(
PostDatasetsV1Response,
"POST",
"/api/public/datasets",
{
name: "dataset-name-1",
description: "dataset-description-1",
metadata: { key: "value" },
},
);
// v2 post
await makeZodVerifiedAPICall(
PostDatasetsV2Response,
"POST",
"/api/public/v2/datasets",
{
name: "dataset-name-2",
},
);
const datasetItemId = v4();
const createItemRes = await makeAPICall<{
datasetName: string; // field that can break if the API changes as it is not a db column
}>("POST", "/api/public/dataset-items", {
datasetName: "dataset-name-2",
input: { key: "value" },
expectedOutput: { key: "value" },
metadata: { key: "value-dataset-item" },
id: datasetItemId,
});
const createItemRes = await makeZodVerifiedAPICall(
PostDatasetItemsV1Response,
"POST",
"/api/public/dataset-items",
{
datasetName: "dataset-name-2",
input: { key: "value" },
expectedOutput: { key: "value" },
metadata: { key: "value-dataset-item" },
id: datasetItemId,
},
);
expect(createItemRes.status).toBe(200);
expect(createItemRes.body).toMatchObject({
datasetName: "dataset-name-2", // not included in db table
});
await makeAPICall("POST", "/api/public/dataset-run-items", {
datasetItemId: datasetItemId,
observationId: observationId,
runName: "test-run",
metadata: { key: "value" },
});
await makeZodVerifiedAPICall(
PostDatasetRunItemsV1Response,
"POST",
"/api/public/dataset-run-items",
{
datasetItemId: datasetItemId,
observationId: observationId,
runName: "test-run",
metadata: { key: "value" },
},
);
const getDatasets = await makeAPICall("GET", `/api/public/datasets`);
const getDatasetsV1 = await makeZodVerifiedAPICall(
GetDatasetsV1Response,
"GET",
`/api/public/datasets`,
);
expect(getDatasets.status).toBe(200);
expect(getDatasets.body).toMatchObject({
expect(getDatasetsV1.status).toBe(200);
expect(getDatasetsV1.body).toMatchObject({
data: expect.arrayContaining([
expect.objectContaining({
name: "dataset-name-1",
description: "dataset-description-1",
metadata: { key: "value" },
items: [],
runs: [],
}),
@@ -131,87 +231,257 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
page: 1,
}),
});
const getDatasetsV2 = await makeZodVerifiedAPICall(
GetDatasetsV2Response,
"GET",
`/api/public/v2/datasets`,
);
expect(getDatasetsV2.status).toBe(200);
expect(getDatasetsV2.body).toMatchObject({
data: expect.arrayContaining([
expect.objectContaining({
name: "dataset-name-1",
description: "dataset-description-1",
metadata: { key: "value" },
}),
expect.objectContaining({
name: "dataset-name-2",
description: null,
metadata: null,
}),
]),
meta: expect.objectContaining({
totalItems: 2,
page: 1,
}),
});
});
it("should create and get a dataset item (via datasets and individually)", async () => {
await makeAPICall("POST", "/api/public/datasets", {
name: "dataset-name",
});
await makeAPICall("POST", "/api/public/dataset-items", {
datasetName: "dataset-name",
input: { key: "value" },
expectedOutput: { key: "value" },
metadata: { key: "value-dataset-item" },
sourceTraceId: traceId,
sourceObservationId: observationId,
});
const dbDatasetItem = await prisma.datasetItem.findFirst({
it("should create and get a dataset items (via datasets (v1), individually, and as a list)", async () => {
await makeZodVerifiedAPICall(
PostDatasetsV1Response,
"POST",
"/api/public/datasets",
{
name: "dataset-name",
},
);
for (let i = 0; i < 5; i++) {
await makeZodVerifiedAPICall(
PostDatasetItemsV1Response,
"POST",
"/api/public/dataset-items",
{
datasetName: "dataset-name",
input: { key: "value" },
expectedOutput: { key: "value" },
metadata: { key: "value-dataset-item" },
sourceTraceId: i % 2 === 0 ? traceId : undefined,
sourceObservationId: i % 2 === 0 ? observationId : undefined,
},
);
}
const dbDatasetItems = await prisma.datasetItem.findMany({
where: {
dataset: {
name: "dataset-name",
},
},
orderBy: {
createdAt: "desc",
},
});
expect(dbDatasetItems.length).toBe(5);
const dbDatasetItemsApiResponseFormat = dbDatasetItems.map((item) => ({
...item,
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
datasetName: "dataset-name",
}));
expect(dbDatasetItem).not.toBeNull();
// add another dataset to test the list endpoint
await makeZodVerifiedAPICall(
PostDatasetsV1Response,
"POST",
"/api/public/datasets",
{
name: "dataset-name-other",
},
);
await makeZodVerifiedAPICall(
PostDatasetItemsV1Response,
"POST",
"/api/public/dataset-items",
{
datasetName: "dataset-name-other",
input: { key: "value" },
expectedOutput: { key: "value" },
},
);
const dbDatasetItemsOther = await prisma.datasetItem.findMany({
where: {
dataset: {
name: "dataset-name-other",
},
},
orderBy: {
createdAt: "desc",
},
});
expect(dbDatasetItemsOther.length).toBe(1);
const dbDatasetItemsOtherApiResponseFormat = dbDatasetItemsOther.map(
(item) => ({
...item,
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
datasetName: "dataset-name-other",
}),
);
const dbDatasetItemsAllApiResponseFormat = [
...dbDatasetItemsApiResponseFormat,
...dbDatasetItemsOtherApiResponseFormat,
].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); // createdAt desc
const getDataset = await makeAPICall(
// Get dataset (v1) includes list of items
const getDataset = await makeZodVerifiedAPICall(
GetDatasetV1Response,
"GET",
`/api/public/datasets/dataset-name`,
);
expect(getDataset.status).toBe(200);
expect(getDataset.body).toMatchObject({
name: "dataset-name",
items: [
{
id: dbDatasetItem!.id,
input: { key: "value" },
expectedOutput: { key: "value" },
metadata: { key: "value-dataset-item" },
datasetName: "dataset-name", // not included in db table
},
],
items: dbDatasetItemsApiResponseFormat,
});
const getDatasetItem = await makeAPICall(
// Get List
const getDatasetItemsAll = await makeZodVerifiedAPICall(
GetDatasetItemsV1Response,
"GET",
`/api/public/dataset-items/${dbDatasetItem!.id}`,
`/api/public/dataset-items`,
);
expect(getDatasetItemsAll.status).toBe(200);
expect(getDatasetItemsAll.body).toMatchObject({
data: dbDatasetItemsAllApiResponseFormat,
meta: expect.objectContaining({
totalItems: 6,
page: 1,
}),
});
// Get List, check pagination
const getDatasetItemsAllPage2 = await makeZodVerifiedAPICall(
GetDatasetItemsV1Response,
"GET",
`/api/public/dataset-items?page=2&limit=1`,
);
expect(getDatasetItemsAllPage2.status).toBe(200);
expect(getDatasetItemsAllPage2.body).toMatchObject({
data: dbDatasetItemsAllApiResponseFormat.slice(1, 2),
meta: expect.objectContaining({
totalItems: 6,
page: 2,
totalPages: 6,
limit: 1,
}),
});
// Get filtered list by datasetName
const getDatasetItems = await makeZodVerifiedAPICall(
GetDatasetItemsV1Response,
"GET",
`/api/public/dataset-items?datasetName=dataset-name`,
);
expect(getDatasetItems.status).toBe(200);
expect(getDatasetItems.body).toMatchObject({
data: dbDatasetItemsApiResponseFormat,
meta: expect.objectContaining({
totalItems: 5,
page: 1,
}),
});
// Get filtered list by sourceTraceId
const getDatasetItemsTrace = await makeZodVerifiedAPICall(
GetDatasetItemsV1Response,
"GET",
`/api/public/dataset-items?sourceTraceId=${traceId}`,
);
expect(getDatasetItemsTrace.status).toBe(200);
expect(getDatasetItemsTrace.body).toMatchObject({
data: dbDatasetItemsApiResponseFormat.filter(
(item) => item.sourceTraceId === traceId,
),
meta: expect.objectContaining({
totalItems: 3,
page: 1,
}),
});
// Get filtered list by sourceObservationId
const getDatasetItemsObservation = await makeZodVerifiedAPICall(
GetDatasetItemsV1Response,
"GET",
`/api/public/dataset-items?sourceObservationId=${observationId}`,
);
expect(getDatasetItemsObservation.status).toBe(200);
expect(getDatasetItemsObservation.body).toMatchObject({
data: dbDatasetItemsApiResponseFormat.filter(
(item) => item.sourceObservationId === observationId,
),
meta: expect.objectContaining({
totalItems: 3,
page: 1,
}),
});
// Get single item
const singleItem = dbDatasetItemsApiResponseFormat[0];
const getDatasetItem = await makeZodVerifiedAPICall(
GetDatasetItemV1Response,
"GET",
`/api/public/dataset-items/${singleItem.id}`,
);
expect(getDatasetItem.status).toBe(200);
expect(getDatasetItem.body).toMatchObject({
id: dbDatasetItem!.id,
input: { key: "value" },
expectedOutput: { key: "value" },
metadata: { key: "value-dataset-item" },
datasetName: "dataset-name", // not included in db table
sourceTraceId: traceId,
sourceObservationId: observationId,
});
expect(getDatasetItem.body).toMatchObject(singleItem);
});
it("should upsert a dataset item", async () => {
await makeAPICall("POST", "/api/public/datasets", {
name: "dataset-name",
});
await makeZodVerifiedAPICall(
PostDatasetsV1Response,
"POST",
"/api/public/datasets",
{
name: "dataset-name",
},
);
const item1 = await makeAPICall("POST", "/api/public/dataset-items", {
id: "dataset-item-id",
datasetName: "dataset-name",
input: { key: "value" },
metadata: { key: "value-dataset-item" },
});
const item1 = await makeZodVerifiedAPICall(
PostDatasetItemsV1Response,
"POST",
"/api/public/dataset-items",
{
id: "dataset-item-id",
datasetName: "dataset-name",
input: { key: "value" },
metadata: { key: "value-dataset-item" },
},
);
expect(item1.status).toBe(200);
expect(item1.body).toMatchObject({
id: "dataset-item-id",
});
const item2 = await makeAPICall("POST", "/api/public/dataset-items", {
id: "dataset-item-id",
datasetName: "dataset-name",
input: { key: "value2" },
metadata: ["hello-world"],
status: "ARCHIVED",
});
const item2 = await makeZodVerifiedAPICall(
PostDatasetItemsV1Response,
"POST",
"/api/public/dataset-items",
{
id: "dataset-item-id",
datasetName: "dataset-name",
input: { key: "value2" },
metadata: ["hello-world"],
status: "ARCHIVED",
},
);
expect(item2.status).toBe(200);
expect(item2.body).toMatchObject({
id: "dataset-item-id",
@@ -230,7 +500,8 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
});
it("should create and get a dataset run, include special characters", async () => {
const dataset = await makeAPICall<{ id: string }>(
const dataset = await makeZodVerifiedAPICall(
PostDatasetsV1Response,
"POST",
"/api/public/datasets",
{
@@ -241,12 +512,17 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
expect(dataset.body).toMatchObject({
name: "dataset name",
});
await makeAPICall("POST", "/api/public/dataset-items", {
datasetName: "dataset name",
id: "dataset-item-id",
input: { key: "value" },
expectedOutput: { key: "value" },
});
await makeZodVerifiedAPICall(
PostDatasetItemsV1Response,
"POST",
"/api/public/dataset-items",
{
datasetName: "dataset name",
id: "dataset-item-id",
input: { key: "value" },
expectedOutput: { key: "value" },
},
);
const traceId = v4();
const observationId = v4();
const response = await makeAPICall("POST", "/api/public/ingestion", {
@@ -285,7 +561,8 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
});
expect(response.status).toBe(207);
const runItemObservation = await makeAPICall(
const runItemObservation = await makeZodVerifiedAPICall(
PostDatasetRunItemsV1Response,
"POST",
"/api/public/dataset-run-items",
{
@@ -315,9 +592,9 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
traceId: traceId,
});
const getRunAPI = await makeAPICall(
const getRunAPI = await makeZodVerifiedAPICall(
GetDatasetRunV1Response,
"GET",
`/api/public/datasets/${encodeURIComponent("dataset name")}/runs/${encodeURIComponent("run + only + observation")}`,
);
expect(getRunAPI.status).toBe(200);
@@ -337,14 +614,17 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
]),
});
const runItemTrace = await makeAPICall<{
datasetRunName: string; // field that can break if the API changes as it is not a db column
}>("POST", "/api/public/dataset-run-items", {
datasetItemId: "dataset-item-id",
traceId: traceId,
runName: "run-only-trace",
metadata: { key: "value" },
});
const runItemTrace = await makeZodVerifiedAPICall(
PostDatasetRunItemsV1Response,
"POST",
"/api/public/dataset-run-items",
{
datasetItemId: "dataset-item-id",
traceId: traceId,
runName: "run-only-trace",
metadata: { key: "value" },
},
);
expect(runItemTrace.status).toBe(200);
expect(runItemTrace.body).toMatchObject({
@@ -369,7 +649,8 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
observationId: null,
});
const runItemBoth = await makeAPICall(
const runItemBoth = await makeZodVerifiedAPICall(
PostDatasetRunItemsV1Response,
"POST",
"/api/public/dataset-run-items",
{
@@ -399,6 +680,149 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
});
});
it("GET /api/public/datasets/{datasetName}/runs", async () => {
// create multiple runs
await makeZodVerifiedAPICall(
PostDatasetsV1Response,
"POST",
"/api/public/datasets",
{
name: "dataset-name",
},
);
await makeZodVerifiedAPICall(
PostDatasetItemsV1Response,
"POST",
"/api/public/dataset-items",
{
datasetName: "dataset-name",
id: "dataset-item-id",
input: { key: "value" },
expectedOutput: { key: "value" },
},
);
const traceId = v4();
const observationId = v4();
const response = await makeAPICall("POST", "/api/public/ingestion", {
batch: [
{
id: v4(),
type: "trace-create",
timestamp: new Date().toISOString(),
body: {
id: traceId,
name: "trace-name",
userId: "user-1",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
},
},
{
id: v4(),
type: "observation-create",
timestamp: new Date().toISOString(),
body: {
id: observationId,
traceId: traceId,
type: "GENERATION",
name: "generation-name",
startTime: "2021-01-01T00:00:00.000Z",
endTime: "2021-01-01T00:00:00.000Z",
modelParameters: { key: "value" },
input: { key: "value" },
metadata: { key: "value" },
version: "2.0.0",
},
},
],
});
expect(response.status).toBe(207);
await makeZodVerifiedAPICall(
PostDatasetRunItemsV1Response,
"POST",
"/api/public/dataset-run-items",
{
datasetItemId: "dataset-item-id",
traceId: traceId,
observationId: observationId,
runName: "run-1",
},
);
await makeZodVerifiedAPICall(
PostDatasetRunItemsV1Response,
"POST",
"/api/public/dataset-run-items",
{
datasetItemId: "dataset-item-id",
traceId: traceId,
observationId: observationId,
runName: "run-2",
},
);
await makeZodVerifiedAPICall(
PostDatasetRunItemsV1Response,
"POST",
"/api/public/dataset-run-items",
{
datasetItemId: "dataset-item-id",
traceId: traceId,
observationId: observationId,
runName: "run-3",
},
);
// check runs in db
const dbRuns = await prisma.datasetRuns.findMany({
where: {
dataset: { name: "dataset-name" },
},
orderBy: {
createdAt: "desc",
},
});
expect(dbRuns.length).toBe(3);
const dbRunsApiResponseFormat = dbRuns.map((run) => ({
...run,
createdAt: run.createdAt.toISOString(),
updatedAt: run.updatedAt.toISOString(),
datasetName: "dataset-name",
}));
// test get runs
const getRuns = await makeZodVerifiedAPICall(
GetDatasetRunsV1Response,
"GET",
`/api/public/datasets/dataset-name/runs`,
);
expect(getRuns.status).toBe(200);
expect(getRuns.body).toMatchObject({
data: dbRunsApiResponseFormat,
meta: expect.objectContaining({
totalItems: 3,
page: 1,
}),
});
// test runs with pagination
const getRunsPage2 = await makeZodVerifiedAPICall(
GetDatasetRunsV1Response,
"GET",
`/api/public/datasets/dataset-name/runs?page=2&limit=1`,
);
expect(getRunsPage2.status).toBe(200);
expect(getRunsPage2.body).toMatchObject({
data: dbRunsApiResponseFormat.slice(1, 2),
meta: expect.objectContaining({
totalItems: 3,
page: 2,
totalPages: 3,
limit: 1,
}),
});
});
it("dataset-run-items should fail when neither trace nor observation provided", async () => {
const response = await makeAPICall(
"POST",

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