Compare commits

...
190 Commits
Author SHA1 Message Date
Marc Klingen 05a533dd7e chore: release v2.80.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (node20, pg12) (push) Waiting to run
CI/CD / tests-web (node20, pg15) (push) Waiting to run
CI/CD / tests-worker (node20, pg12) (push) Waiting to run
CI/CD / tests-worker (node20, pg15) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-09-19 16:37:23 +02:00
Steffen SchmitzandGitHub f3d15bb36e feat: optimize daily metrics query (#3371) 2024-09-19 14:10:10 +00:00
Hassieb PakzadandGitHub 24c321f632 fix(dataset-table): cost and latency queries (#3411) 2024-09-19 12:14:42 +00:00
Steffen SchmitzandGitHub 0e9cf548b2 chore: ecs production deploy automation (#3408) 2024-09-19 12:42:21 +02:00
Marc KlingenandGitHub 7158a41fe9 feat(ee): add additional logo (#3404) 2024-09-19 11:45:53 +02:00
Max DeichmannandGitHub bc384a525a chore: adjust sentry sample rate (#3403)
adjust autoscaling eval periods
2024-09-18 22:40:22 +00:00
Max DeichmannandGitHub 3f70828df6 chore: fix dev server speed (#3388) 2024-09-18 17:10:50 +00:00
marliessophieandGitHub 0988635427 fix: prompt tag menu disappears when out of focus (#3394) 2024-09-18 18:39:55 +02:00
Max DeichmannandGitHub 41ed6fed5e fix: allow to eval with azure models (#3387) 2024-09-17 16:54:22 +00:00
Hassieb PakzadandGitHub eb40659e44 fix(playground): save to prompt list (#3384) 2024-09-17 13:54:39 +00:00
Steffen SchmitzandGitHub a22bcb1188 build: add deploy config for eu (#3366) 2024-09-17 10:04:46 +00:00
Marc KlingenandGitHub 392fbe6c8c chore(ui): improve posthog api key error message (#3373)
* chore(ui): improve posthog api key error message

* Empty commit
2024-09-16 21:58:06 +00:00
marliessophieandGitHub 48d5a8f5a1 fix: cutoff filter for sessions export (#3370) 2024-09-16 15:11:32 +00:00
marliessophieandGitHub 44428870f7 style: adjust success toast for dark mode (#3362) 2024-09-16 07:46:10 +00:00
Hassieb PakzadandGitHub 724a4f5d9a fix(ui/prompts): variable parsing for chat prompts (#3360) 2024-09-15 13:55:01 +00:00
Marc KlingenandGitHub 2358264b66 fix(auth): unknown argument refresh_token_expires_in with Github connection (#3356)
fix(auth): unknown argument `refresh_token_expires_in` with Github connection

Fixes langfuse/langfuse#3355
2024-09-13 19:10:44 +00:00
Marc Klingen 65e3b4c5ca chore: release v2.79.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (node20, pg12) (push) Waiting to run
CI/CD / tests-web (node20, pg15) (push) Waiting to run
CI/CD / tests-worker (node20, pg12) (push) Waiting to run
CI/CD / tests-worker (node20, pg15) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-09-13 20:37:58 +02:00
62c413faf5 fix(ui): ensure table footer is fixed on the bottom of the page (#3354)
* style(tables): ensure footer is fixed on the bottom

* fix

---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-09-13 20:13:36 +02:00
Marc KlingenandGitHub 8ea14ac537 feat(cloud): alert when free plan limit is approaching or reached (#3350)
* feat(cloud): alert when free plan limit is approaching or reached

* check entitlement
2024-09-13 20:08:32 +02:00
marliessophieandGitHub c32b84663c feat(ui): export traces (#3332) 2024-09-13 17:51:22 +00:00
Marc KlingenandGitHub 23ab79ba42 fix: custom base path nullable (#3353) 2024-09-13 17:17:13 +00:00
Hassieb PakzadandGitHub 6ffec3337f feat(playground): add support for OpenAI o1 beta models (#3349) 2024-09-13 18:47:57 +02:00
steffen911 34a2a9cddf chore: remove pipeline concurrency limiter 2024-09-13 16:24:24 +02:00
Steffen SchmitzandGitHub 0854429850 feat: add env variables to toggle queue consumers (#3339) 2024-09-13 14:16:48 +00:00
Steffen SchmitzandGitHub 05fd690486 chore: deploy to staging ECS environment (#3327) 2024-09-13 13:49:27 +00:00
marliessophieandGitHub bc3f39272b style(evals): apply sticky headers to eval logs and eval config tables (#3347) 2024-09-13 13:27:22 +00:00
Marc KlingenandGitHub 96ace71f9a feat(self-host): support for custom base path via NEXT_PUBLIC_BASE_PATH (#3346) 2024-09-13 13:02:36 +00:00
marliessophieandGitHub 70415dcab5 fix: Time to first token filter in Generations table (#3345) 2024-09-13 11:53:33 +00:00
Hassieb PakzadandGitHub 6a18f249aa feat(cost-tracking): add OpenAI o1 models (#3343) 2024-09-13 12:00:10 +02:00
Marc KlingenandGitHub cc60d506a7 feat(cloud): improve upgrade ui (#3331)
* add link to billing settings to project settings

* add plan label to org switcher

* add upgrade menu item on free cloud plans
2024-09-12 16:49:49 +00:00
Steffen SchmitzandGitHub a31a7f238e chore: remove db query from healthcheck (#3330) 2024-09-12 17:24:40 +02:00
Marc KlingenandGitHub 41ebc77f86 feat(cloud): improve crisp integration (#3329)
* chore: update crisp sdk

* feat(cloud): improve crisp integration
2024-09-12 14:33:51 +00:00
marliessophieandGitHub cdc41cf573 chore(ui_markdown): whitelist images with valid ipv6 or ipv4 hostname (#3318) 2024-09-12 08:04:44 +00:00
Max DeichmannandGitHub c86e32f756 chore: improve client side sentry (#3312) 2024-09-12 09:19:07 +02:00
Max DeichmannandGitHub 85576c162a chore: downgrade codegen (#3319) 2024-09-11 21:58:05 +02:00
Max DeichmannandGitHub 93ad1e6880 perf: get observation I/O only if required (#3316) 2024-09-11 18:47:31 +00:00
marliessophieandGitHub 97b17fc8bb style(ui): fix hover effect on groups in column reordering (#3317) 2024-09-11 18:22:23 +00:00
marliessophieandGitHub 04e2d9249f fix(ui): populate default prompt data for chat prompts with custom role (#3295) 2024-09-11 12:36:59 +00:00
marliessophieandGitHub cb94a54f0c feat(ui): type to find column name in filter builder (#3290) 2024-09-11 08:31:18 +00:00
marliessophieandGitHub 7c90bbed0c fix(ui): persist dateRange filter state of dashboard (#3298) 2024-09-11 08:11:21 +00:00
Max DeichmannandGitHub f133526ba6 chore: create dependabot group for express (#3303)
fix
2024-09-11 09:24:45 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
dc014de54c chore(deps-dev): bump kysely-codegen from 0.11.0 to 0.16.6 (#3301)
Bumps [kysely-codegen](https://github.com/RobinBlomberg/kysely-codegen) from 0.11.0 to 0.16.6.
- [Release notes](https://github.com/RobinBlomberg/kysely-codegen/releases)
- [Commits](https://github.com/RobinBlomberg/kysely-codegen/commits)

---
updated-dependencies:
- dependency-name: kysely-codegen
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-11 09:12:04 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c6beec1ad5 chore(deps): bump @aws-sdk/lib-storage from 3.614.0 to 3.645.0 (#3235)
Bumps [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) from 3.614.0 to 3.645.0.
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.645.0/lib/lib-storage)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-10 23:17:43 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a810f7750f chore(deps): bump posthog-js from 1.154.6 to 1.161.3 (#3297)
Bumps [posthog-js](https://github.com/PostHog/posthog-js) from 1.154.6 to 1.161.3.
- [Release notes](https://github.com/PostHog/posthog-js/releases)
- [Changelog](https://github.com/PostHog/posthog-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/PostHog/posthog-js/compare/v1.154.6...v1.161.3)

---
updated-dependencies:
- dependency-name: posthog-js
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-10 20:02:47 +02:00
marliessophieandGitHub 2b34c82e98 feat(ui): render 0 latency as such in trace timeline view (#3288) 2024-09-10 13:10:24 +00:00
marliessophieandGitHub fe283284ef feat(ui): move columns via drag and drop (#3266) 2024-09-10 12:40:59 +00:00
Steffen SchmitzandGitHub 307f3d18ce chore: add trace info to web logs and context to queue events (#3282) 2024-09-10 12:26:07 +00:00
Hassieb PakzadandGitHub 46fff7148e fix(ui/prompts): parse prompt variables correctly (#3286) 2024-09-10 12:05:52 +02:00
Steffen SchmitzandGitHub 9cce111be6 chore: add worker entrypoint to handle alternative database config (#3283) 2024-09-10 10:58:50 +02:00
Max DeichmannandGitHub dbd7f810c1 chore: remove sentry tunnel (#3274) 2024-09-09 17:22:53 +02:00
Max DeichmannandGitHub abc3ea2b5a chore: remove explicit profiling settings worker (#3272)
fix
2024-09-09 14:44:51 +00:00
Steffen SchmitzandGitHub 3d8a4f9d75 chore: downgrade winston instrumentation and fix worker import (#3268) 2024-09-09 13:35:05 +02:00
Max DeichmannandGitHub 7b4bef7c2e fix: revert async zod (#3264) 2024-09-07 09:24:59 +02:00
Marc KlingenandGitHub b10353115b fix(ui): x axis of timeseries charts on dashboard for some time ranges (#3261) 2024-09-06 20:30:58 +00:00
Marc KlingenandGitHub f3235ceccb fix(ui): overflow of long strings in sessions view (#3258) 2024-09-06 17:20:11 +00:00
Marc KlingenandGitHub 92455b4e5a fix(ui): reset feedback form after submit (#3257)
fix(ui): reset feedback form after successfully sending feedback
2024-09-06 16:49:49 +00:00
Max DeichmannandGitHub 217b92d548 perf: parse zod in ingestion pipeline async (#3253) 2024-09-06 16:10:23 +02:00
Marc KlingenandGitHub c6ddd3822f feat(users): add search to users table (#3251) 2024-09-06 01:14:31 +00:00
Marc KlingenandGitHub 67d6c32ec5 chore: Update README.md 2024-09-06 01:50:29 +02:00
Marc KlingenandGitHub d89379e693 chore: Update README.md 2024-09-06 01:48:47 +02:00
Marc KlingenandGitHub 4b8d3a764e chore: Update README.md 2024-09-06 01:26:09 +02:00
Marc Klingen 5757fa6e38 chore: update readme 2024-09-06 00:58:47 +02:00
Marc KlingenandGitHub 34c3e5af08 chore: Update README.md 2024-09-06 00:56:16 +02:00
Steffen SchmitzandGitHub c68ec2fca0 chore: move additional logs to debug level (#3250) 2024-09-05 18:44:18 +00:00
Max DeichmannandGitHub 03dda153e8 chore: remove log line (#3249) 2024-09-05 16:28:33 +00:00
Marc Klingen 63641521d8 chore: add langfuse.com and langfuse.dev to csp headers 2024-09-05 16:13:37 +02:00
Marc Klingen 29d11642c4 chore: update csp headers to include ph.langfuse.dev 2024-09-05 15:52:29 +02:00
Steffen SchmitzandGitHub fb98a404d2 chore: adjust log level for queue infos (#3248) 2024-09-05 14:14:56 +02:00
Max DeichmannandGitHub 032fce56c4 chore: upgrade fern (#3247) 2024-09-05 11:30:10 +00:00
Max DeichmannandGitHub 518b8df2da fix: also track langfuse SDK req headers with underscores on span (#2972) 2024-09-05 11:00:13 +00:00
Max DeichmannandGitHub 70761f7ab0 chore: improve log level (#3244) 2024-09-05 09:01:22 +00:00
Max DeichmannandGitHub a85d4ccf07 chore: improve zod monitoring in ingestion pipeline (#3239) 2024-09-05 08:47:17 +00:00
Steffen SchmitzandGitHub 1b42eae7dd chore: move createAuthedApiRoute logging into metadata (#3238) 2024-09-05 09:09:07 +02:00
Marc Klingen 898cca56f9 chore: release v2.78.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (node20, pg12) (push) Waiting to run
CI/CD / tests-web (node20, pg15) (push) Waiting to run
CI/CD / tests-worker (node20, pg12) (push) Waiting to run
CI/CD / tests-worker (node20, pg15) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-09-04 17:29:47 +02:00
Steffen SchmitzandGitHub 83e052e769 feat: migrate web api to new logger (#3230) 2024-09-04 17:02:26 +02:00
Steffen SchmitzandGitHub b5f19771fa feat: migrate shared package to new logger (#3229) 2024-09-04 16:43:36 +02:00
Marc KlingenandGitHub 644390183b fix: rename LANGFUSE_PROVISION_ to LANGFUSE_INIT_ ahead of changelog release (#3228) 2024-09-04 15:06:47 +02:00
Steffen SchmitzandGitHub 4d8e9003fe feat: add structured logging option (#3221) 2024-09-04 14:53:42 +02:00
Marc KlingenandGitHub 651c46a270 docs: add provisioning envs to .env.prod.example (#3227) 2024-09-04 14:24:53 +02:00
Max DeichmannandGitHub 6b127bced0 chore: add instrumentation to ingestion zod parsing (#3224) 2024-09-04 10:08:05 +00:00
Steffen SchmitzandGitHub e3a837b561 feat: add new /ready endpoint to separate liveness from readiness (#3222) 2024-09-04 11:53:41 +02:00
steffen911 11c04f02c8 chore: release v2.77.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (node20, pg12) (push) Waiting to run
CI/CD / tests-web (node20, pg15) (push) Waiting to run
CI/CD / tests-worker (node20, pg12) (push) Waiting to run
CI/CD / tests-worker (node20, pg15) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-09-04 10:50:09 +02:00
Steffen SchmitzandGitHub 536aa138bc fix: reconnect workers to redis on errors (#3211) 2024-09-04 10:22:59 +02:00
Marc KlingenandGitHub 61b596c46a fix(docker): prevent web build oom by allocating more memory to build step in Dockerfile (#3217) 2024-09-04 00:18:15 +02:00
Marc KlingenandGitHub ddf43c0836 feat(self-host): provision instance with default org/project/user/keys via LANGFUSE_PROVISION_* envs (#3216) 2024-09-03 23:50:20 +02:00
Marc KlingenandGitHub d5ab03892a chore(ee): add EE to VersionLabel (#3214) 2024-09-03 14:03:57 +00:00
918b6643d2 chore(release): nextjs client side refresh after redeployment (#3116)
Co-authored-by: Marc Klingen <git@marcklingen.com>
Co-authored-by: Max Deichmann <m.deichmann@tum.de>
2024-09-03 12:42:57 +02:00
marliessophieandGitHub 937b4c0f0f feat(ui): deletion of dataset runs (#3209) 2024-09-03 12:26:02 +02:00
Max Deichmann 9b7f046e71 chore: release v2.76.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (node20, pg12) (push) Waiting to run
CI/CD / tests-web (node20, pg15) (push) Waiting to run
CI/CD / tests-worker (node20, pg12) (push) Waiting to run
CI/CD / tests-worker (node20, pg15) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-09-03 10:48:10 +02:00
Steffen SchmitzandGitHub fe63704508 feat: fallback to synchronous ingestion if queue unavailable (#3196) 2024-09-03 10:42:59 +02:00
marliessophieandGitHub c39279f677 style(prompts): actions on hover (#3135) 2024-09-03 08:12:39 +00:00
Marc KlingenandGitHub 4206f5025d perf(cloud): pull usage data shown in settings from stripe if available (#3206) 2024-09-02 23:51:13 +00:00
marliessophieandGitHub 0260a4e861 style(dataset_items): make table vs item detail resizeable (#3139) 2024-09-02 08:01:43 +00:00
Max DeichmannandGitHub 244adf0dc3 feat: disable legacy ingestion queue if required (#3183) 2024-09-01 14:24:25 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
dc6b98d009 chore(deps-dev): bump tsx from 4.18.0 to 4.19.0 (#3172)
Bumps [tsx](https://github.com/privatenumber/tsx) from 4.18.0 to 4.19.0.
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.18.0...v4.19.0)

---
updated-dependencies:
- dependency-name: tsx
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-01 10:07:44 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b20fc5a20b chore(deps-dev): bump tailwindcss from 3.4.4 to 3.4.10 (#3173)
Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss) from 3.4.4 to 3.4.10.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/v3.4.10/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/compare/v3.4.4...v3.4.10)

---
updated-dependencies:
- dependency-name: tailwindcss
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-31 22:36:42 +02:00
Max DeichmannandGitHub f59ad5fd60 chore: upload sentry sourcemaps (#3179) 2024-08-31 18:50:48 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
06ef41e372 chore(deps): bump pino-pretty from 10.3.1 to 11.2.2 (#3170)
Bumps [pino-pretty](https://github.com/pinojs/pino-pretty) from 10.3.1 to 11.2.2.
- [Release notes](https://github.com/pinojs/pino-pretty/releases)
- [Commits](https://github.com/pinojs/pino-pretty/compare/v10.3.1...v11.2.2)

---
updated-dependencies:
- dependency-name: pino-pretty
  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>
2024-08-31 17:04:49 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e215a2db99 chore(deps-dev): bump @types/lodash from 4.17.5 to 4.17.7 in the lodash group (#3169)
chore(deps-dev): bump @types/lodash in the lodash group

Bumps the lodash group with 1 update: [@types/lodash](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/lodash).


Updates `@types/lodash` from 4.17.5 to 4.17.7
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/lodash)

---
updated-dependencies:
- dependency-name: "@types/lodash"
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: lodash
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-31 17:00:36 +02:00
Max DeichmannandGitHub 5e247e88c9 fix: do nothing if invalidating org or project without api keys (#3167)
* also run on main branch

* also run on main branch

* also run on main branch

* also run on main branch

* also run on main branch
2024-08-30 22:57:41 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8e1e27c868 chore(deps-dev): bump msw from 2.3.1 to 2.4.1 (#3150)
Bumps [msw](https://github.com/mswjs/msw) from 2.3.1 to 2.4.1.
- [Release notes](https://github.com/mswjs/msw/releases)
- [Changelog](https://github.com/mswjs/msw/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mswjs/msw/compare/v2.3.1...v2.4.1)

---
updated-dependencies:
- dependency-name: msw
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-30 21:52:41 +02:00
dependabot[bot]andGitHub 4648a5be8b chore(deps): bump lucide-react from 0.429.0 to 0.436.0 (#3154) 2024-08-30 21:02:28 +02:00
Marc Klingen 4677cb10a9 chore: release v2.75.2
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (node20, pg12) (push) Waiting to run
CI/CD / tests-web (node20, pg15) (push) Waiting to run
CI/CD / tests-worker (node20, pg12) (push) Waiting to run
CI/CD / tests-worker (node20, pg15) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-08-30 20:38:04 +02:00
Marc KlingenandGitHub 7ed03a0946 fix(playground): allow for creation of non-openai llm api keys (#3162) 2024-08-30 20:36:55 +02:00
Marc Klingen bbe40a0ba8 chore: release v2.75.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 / e2e-server-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-08-30 20:25:38 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2cf9a75fa5 chore(deps): bump @uiw/codemirror-theme-github from 4.21.25 to 4.23.0 (#3151)
Bumps [@uiw/codemirror-theme-github](https://github.com/uiwjs/react-codemirror) from 4.21.25 to 4.23.0.
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.21.25...v4.23.0)

---
updated-dependencies:
- dependency-name: "@uiw/codemirror-theme-github"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-30 17:59:28 +02:00
Max DeichmannandGitHub 38acccbfac fix: make redis upsert queue backwards compatible (#3165) 2024-08-30 14:31:23 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8327863176 chore(deps): bump the radix-ui group with 18 updates (#3159)
Bumps the radix-ui group with 18 updates:

| Package | From | To |
| --- | --- | --- |
| [@radix-ui/react-accordion](https://github.com/radix-ui/primitives) | `1.1.2` | `1.2.0` |
| [@radix-ui/react-alert-dialog](https://github.com/radix-ui/primitives) | `1.0.5` | `1.1.1` |
| [@radix-ui/react-avatar](https://github.com/radix-ui/primitives) | `1.0.4` | `1.1.0` |
| [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives) | `1.0.4` | `1.1.1` |
| [@radix-ui/react-dialog](https://github.com/radix-ui/primitives) | `1.0.5` | `1.1.1` |
| [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives) | `2.0.6` | `2.1.1` |
| [@radix-ui/react-hover-card](https://github.com/radix-ui/primitives) | `1.0.7` | `1.1.1` |
| [@radix-ui/react-label](https://github.com/radix-ui/primitives) | `2.0.2` | `2.1.0` |
| [@radix-ui/react-popover](https://github.com/radix-ui/primitives) | `1.0.7` | `1.1.1` |
| [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives) | `1.0.5` | `1.1.0` |
| [@radix-ui/react-select](https://github.com/radix-ui/primitives) | `2.0.0` | `2.1.1` |
| [@radix-ui/react-separator](https://github.com/radix-ui/primitives) | `1.0.3` | `1.1.0` |
| [@radix-ui/react-slider](https://github.com/radix-ui/primitives) | `1.1.2` | `1.2.0` |
| [@radix-ui/react-slot](https://github.com/radix-ui/primitives) | `1.0.2` | `1.1.0` |
| [@radix-ui/react-switch](https://github.com/radix-ui/primitives) | `1.0.3` | `1.1.0` |
| [@radix-ui/react-tabs](https://github.com/radix-ui/primitives) | `1.0.4` | `1.1.0` |
| [@radix-ui/react-toggle](https://github.com/radix-ui/primitives) | `1.0.3` | `1.1.0` |
| [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives) | `1.0.7` | `1.1.2` |


Updates `@radix-ui/react-accordion` from 1.1.2 to 1.2.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-alert-dialog` from 1.0.5 to 1.1.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-avatar` from 1.0.4 to 1.1.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-checkbox` from 1.0.4 to 1.1.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-dialog` from 1.0.5 to 1.1.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-dropdown-menu` from 2.0.6 to 2.1.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-hover-card` from 1.0.7 to 1.1.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-label` from 2.0.2 to 2.1.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-popover` from 1.0.7 to 1.1.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-scroll-area` from 1.0.5 to 1.1.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-select` from 2.0.0 to 2.1.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-separator` from 1.0.3 to 1.1.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-slider` from 1.1.2 to 1.2.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-slot` from 1.0.2 to 1.1.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-switch` from 1.0.3 to 1.1.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-tabs` from 1.0.4 to 1.1.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-toggle` from 1.0.3 to 1.1.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-tooltip` from 1.0.7 to 1.1.2
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-accordion"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-alert-dialog"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-avatar"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-checkbox"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-dialog"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-dropdown-menu"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-hover-card"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-label"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-popover"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-scroll-area"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-select"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-separator"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-slider"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-slot"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-switch"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-tabs"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-toggle"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-tooltip"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-30 16:04:22 +02:00
Max DeichmannandGitHub 6045324d2b fix: fix rate limits for orgs with stripe details (#3164) 2024-08-30 13:53:47 +00:00
Marc KlingenandGitHub 87923e5d6c fix(ui): add default llm base urls for reference in llm api key form (#3163)
* ui nit

* fix(ui): add default llm base urls for reference in llm api key form
2024-08-30 13:28:07 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f584de7577 chore(deps): bump zod-to-json-schema from 3.22.5 to 3.23.2 (#3161)
Bumps [zod-to-json-schema](https://github.com/StefanTerdell/zod-to-json-schema) from 3.22.5 to 3.23.2.
- [Release notes](https://github.com/StefanTerdell/zod-to-json-schema/releases)
- [Changelog](https://github.com/StefanTerdell/zod-to-json-schema/blob/master/changelog.md)
- [Commits](https://github.com/StefanTerdell/zod-to-json-schema/commits)

---
updated-dependencies:
- dependency-name: zod-to-json-schema
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-30 13:55:34 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d01c403f62 chore(deps): bump kysely from 0.27.3 to 0.27.4 (#3160)
Bumps [kysely](https://github.com/kysely-org/kysely) from 0.27.3 to 0.27.4.
- [Release notes](https://github.com/kysely-org/kysely/releases)
- [Commits](https://github.com/kysely-org/kysely/compare/0.27.3...0.27.4)

---
updated-dependencies:
- dependency-name: kysely
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-30 13:38:28 +02:00
Max DeichmannandGitHub a001e6072c chore: run snyk on pr pushes only (#3158) 2024-08-30 12:17:12 +02:00
Max DeichmannandGitHub d9a3f977bc chore: radix dependabot group (#3157) 2024-08-30 11:57:58 +02:00
Max DeichmannandGitHub 8fae4f3051 feat: add custom rate limits for organisations (#2976) 2024-08-30 11:57:31 +02:00
Max DeichmannandGitHub c36b1ff51d chore: dependabot gorup for lodash (#3155)
smaller fixes
2024-08-30 11:17:51 +02:00
Max DeichmannandGitHub d3cf604f4f chore: increase dependabot limit (#3148) 2024-08-30 10:51:41 +02:00
dependabot[bot]andGitHub cc886917b6 chore(deps): bump @radix-ui/react-toggle-group from 1.0.4 to 1.1.0 (#3146) 2024-08-30 08:51:05 +02:00
Marc KlingenandGitHub b924bf9e09 feat(auth): improve password reset error messages (#3143) 2024-08-29 22:02:57 +00:00
Marc Klingen 895825dd1c chore: release v2.75.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (node20, pg12) (push) Waiting to run
CI/CD / tests-web (node20, pg15) (push) Waiting to run
CI/CD / tests-worker (node20, pg12) (push) Waiting to run
CI/CD / tests-worker (node20, pg15) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-08-29 18:52:21 +02:00
Max DeichmannandGitHub 6ceccb649f chore: fix worker tests (#3133) 2024-08-29 16:29:56 +02:00
Max DeichmannandGitHub 911021ac5d chore: ensure clickhouse is healthy before migrating (#3128) 2024-08-29 13:58:02 +00:00
marliessophieandGitHub f39778ef9f chore(ui): render cloudflare errors in more user readable format (#3118) 2024-08-29 10:26:55 +00:00
Marc KlingenandGitHub 4d00e39c6b fix(ui): reset to first table page when number of total pages is reduced (#3122) 2024-08-28 21:58:14 +00:00
Marc Klingen 7af49fd649 chore(ui): adjust padding in settings 2024-08-28 23:30:16 +02:00
Max DeichmannandGitHub 662f1917b6 fix: create fast hashed secret key upon key creation (#3117) 2024-08-28 20:14:12 +02:00
Marc KlingenandGitHub d8f047f2a1 chore(ui): improve trpc error types (#3097) 2024-08-28 19:03:27 +02:00
69815a51b9 feat: add trace tags to generations, sessions and score table (#3109)
* feat: Add query to retrieve tags for a specific project

* chore(api): Add "tags" column to scores table definition

* add nullable to score table config

* chore(api): Parallelize filter options queries

* chore(api): Update datetime filter function name to use raw SQL

* chore(api): Update datetime filter function name to use raw SQL

* chore: Update datetime filter function name to use raw SQL

* chore: Update scores router to include timestamp filter in query

* chore: Update datetime filter function name to use raw SQL

* chore: Update datetime filter function name to use raw SQL

* chore: Update datetime filter function name to use raw SQL

* refactor: Use datetimeFilterToPrisma function for start time filter

* refactor: Use datetimeFilterToPrisma function for start time filter

* refactor: Use datetimeFilterToPrisma function for timestamp filter

* refactor: Update datetime filter function name to use raw SQL

* refactor: Add viewOnly prop to TagList component

* refactor: Add viewOnly prop to TagButton component

* feat: Add trace tags to GenerationsTable

* refactor: Add traceTags column to ScoresTable

* feat: Add traceTags column to SessionsTable

* refactor: Add traceTags column to ScoresTable

* feat: Add traceTags to session metrics

* feat: Add traceTags to getAllGenerationsSqlQuery

* revert rename

* push

* add row height to sessions

* wrap if non-s row height

---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-08-28 19:01:49 +02:00
Marc KlingenandGitHub 2bb419213a feat(ui): reduce font size and white-space to shrink UI to fit more content (#3001) 2024-08-28 19:00:55 +02:00
Richard KrümmelandGitHub a330035c40 feat: add tags filter to scores table (#3071) 2024-08-28 17:11:25 +02:00
Max DeichmannandGitHub dd4434fe25 chore: set keepalive on web docker container (#3113) 2024-08-28 15:00:34 +00:00
Max DeichmannandGitHub f898f57bff chore: upgrade dd-trace (#3114)
fix
2024-08-28 14:15:32 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
eda52c7b77 chore(deps-dev): bump prettier-plugin-tailwindcss from 0.5.14 to 0.6.6 (#3103)
Bumps [prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss) from 0.5.14 to 0.6.6.
- [Release notes](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.5.14...v0.6.6)

---
updated-dependencies:
- dependency-name: prettier-plugin-tailwindcss
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-28 16:01:30 +02:00
marliessophieandGitHub e5c7686bc7 fix(comments): reference object check for comment creation (#3108) 2024-08-28 08:57:48 +00:00
marliessophieandGitHub 251be1a240 fix(comments): reintroduce old procedure name for backwards compatibility (#3107) 2024-08-28 08:15:42 +00:00
marliessophieandGitHub 66c810e4a7 chore(comments): fetch comment counts by type in trace tree (#3098) 2024-08-27 16:30:02 +00:00
marliessophieandGitHub c3c8a741e4 chore(ui): style TRPC error toast to differentiate between errors and warnings (#3095) 2024-08-27 17:28:32 +02:00
Marc KlingenandGitHub 81c898b4ed feat(ui): jump to page x in table via input in pagination (#3090) 2024-08-27 17:04:46 +02:00
834d0bcc7a feat: add tag filter to generations table (#3022)
* feat(api): add tag filtering to generation filter options query

* chore: add Trace Tags column to observations table

* feat(api): optimize filterOptionsQuery by removing count property

* chore(api): optimize filterOptionsQuery for better performance

* feat: add tags filter to sessions table (#3067)



---------

Co-authored-by: Marc Klingen <git@marcklingen.com>

* refactor: use DISTINCT keyword in filterOptionsQuery

---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-08-27 15:19:10 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
40953d7732 chore(deps): bump pino-http from 9.0.0 to 10.2.0 (#3091)
Bumps [pino-http](https://github.com/pinojs/pino-http) from 9.0.0 to 10.2.0.
- [Release notes](https://github.com/pinojs/pino-http/releases)
- [Commits](https://github.com/pinojs/pino-http/compare/v9.0.0...v10.2.0)

---
updated-dependencies:
- dependency-name: pino-http
  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>
2024-08-27 15:05:05 +02:00
Max DeichmannandGitHub d471be8783 fix: fix async instrumentation (#3094) 2024-08-27 12:03:47 +00:00
marliessophieandGitHub 0ca38d8070 chore(evals): display eval config sampling rate in UI (#3093) 2024-08-27 11:13:19 +00:00
Max DeichmannandGitHub 12458e3d71 chore: add instrumentation to auth apis (#3069) 2024-08-27 09:44:32 +00:00
Max DeichmannandGitHub 51575b5491 chore: fix dependabot observability group (#3086)
fix
2024-08-27 10:47:30 +02:00
Max DeichmannandGitHub a9c11f3323 chore: create dependabot observability group (#3085) 2024-08-27 10:42:42 +02:00
Max DeichmannandGitHub 066140c903 fix: fix grouping of 3 months dashboard (#3080) 2024-08-27 08:40:32 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0b7de157ad chore(deps): bump tailwind-merge from 2.2.2 to 2.5.2 (#3060)
Bumps [tailwind-merge](https://github.com/dcastil/tailwind-merge) from 2.2.2 to 2.5.2.
- [Release notes](https://github.com/dcastil/tailwind-merge/releases)
- [Commits](https://github.com/dcastil/tailwind-merge/compare/v2.2.2...v2.5.2)

---
updated-dependencies:
- dependency-name: tailwind-merge
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-27 10:20:55 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7dcc718c77 chore(deps-dev): bump tsx from 4.7.2 to 4.18.0 (#3066)
Bumps [tsx](https://github.com/privatenumber/tsx) from 4.7.2 to 4.18.0.
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.7.2...v4.18.0)

---
updated-dependencies:
- dependency-name: tsx
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 22:08:43 +02:00
marliessophieandGitHub e098618614 chore(filters): improve filter table type safety (#3078) 2024-08-26 14:45:28 +00:00
marliessophieandGitHub 3e8ba54aa0 chore(tables): persist date range in session storage (#3076) 2024-08-26 15:44:05 +02:00
Marc KlingenandGitHub ea36cd58a4 chore(ui): disable jump to last page while count pages is loading (#3077) 2024-08-26 14:38:05 +02:00
Marc KlingenandGitHub c7b726a73a perf(ui): split totalCount from scores.all to reduce loading time of scores table (#3075) 2024-08-26 14:11:10 +02:00
Marc KlingenandGitHub 112e7a2dcf perf(ui): split totalCount from sessions.all to reduce loading time of sessions table (#3073) 2024-08-26 14:03:14 +02:00
Marc Klingen c1d98aba6a fix(ui): remove artificial wait time used in testing 2024-08-26 13:41:31 +02:00
Marc KlingenandGitHub 75fd1cbd3f perf(ui): split totalCount from generations.all to reduce loading time of generations table (#3072) 2024-08-26 13:40:29 +02:00
Marc KlingenandGitHub 75ec734a45 perf(ui): split total count query on trace table to reduce loading time (#3070) 2024-08-26 13:24:54 +02:00
marliessophieandGitHub 98e951423c chore(filters): persist table and dashboard filter state in session storage (#3036) 2024-08-26 11:05:58 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8e1dc3ee99 chore(deps): bump core-js from 3.36.1 to 3.38.1 (#3058)
Bumps [core-js](https://github.com/zloirock/core-js/tree/HEAD/packages/core-js) from 3.36.1 to 3.38.1.
- [Release notes](https://github.com/zloirock/core-js/releases)
- [Changelog](https://github.com/zloirock/core-js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zloirock/core-js/commits/v3.38.1/packages/core-js)

---
updated-dependencies:
- dependency-name: core-js
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 12:35:47 +02:00
Marc KlingenandGitHub 7668995ec9 perf(ui): increase speed of tables, no nulls first/last modifier when sorting by non-nullable attributes (#3068) 2024-08-26 11:30:31 +02:00
marliessophieandGitHub 06233f878c chore(comments): disable comments count on public traces (#3065) 2024-08-26 09:13:58 +00:00
Max DeichmannandGitHub dae3d4e4e2 fix: remove dd profiling presets (#3063)
fix
2024-08-24 17:36:50 +02:00
Max DeichmannandGitHub 04dca2ad6b perf: use dedicated redis clients for bullmq (#3052) 2024-08-23 16:54:39 +00:00
Marc KlingenandGitHub 48c61ad77f perf(ui): load metrics in trace table independently to reduce initial table load time (#3051) 2024-08-23 17:39:32 +02:00
Marc KlingenandGitHub 34140302a6 perf(ui): load metrics in session table independently to reduce initial table load time (#3048) 2024-08-23 13:28:32 +02:00
Marc KlingenandGitHub 93cc36ecdc fix(cloud): catch crisp chat exception (#3042) 2024-08-23 13:22:36 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ace30782a0 chore(deps): bump @radix-ui/react-collapsible from 1.0.3 to 1.1.0 (#3031)
Bumps [@radix-ui/react-collapsible](https://github.com/radix-ui/primitives) from 1.0.3 to 1.1.0.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-collapsible"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-23 13:04:49 +02:00
Marc KlingenandGitHub 3c94a0cda9 perf(ui): parallelize db query for sessions.totalCount (#3044) 2024-08-23 09:58:58 +00:00
Max DeichmannandGitHub b0fea44556 feat: sentry capture console.error in frontend (#3043)
fix
2024-08-23 08:30:42 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ed91330c37 chore(deps): bump lucide-react from 0.364.0 to 0.429.0 (#3037)
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.364.0 to 0.429.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/0.429.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-23 10:16:58 +02:00
Max DeichmannandGitHub b8ed9d681b fix: free up tokenizer at worker shutdown (#3041) 2024-08-23 08:02:57 +00:00
Max DeichmannandGitHub ea2e1b4873 fix: security policy for posthog (#3040)
fix
2024-08-23 07:32:55 +00:00
Max DeichmannandGitHub 3ddef14e75 fix: add sentry frontend support (#3012) 2024-08-23 06:41:27 +00:00
Marc KlingenandGitHub 4658942701 perf(ui): parallelize db queries in traces.all (#3038) 2024-08-23 01:32:33 +02:00
Marc KlingenandGitHub 51be8f8fb3 perf(ui): parallelize users, sessions, models, score config, scores queries in trpc routes (#3039)
* prompts

* models

* score config

* scores

* sessions
2024-08-22 23:26:59 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6ede3b7690 chore(deps): bump the next group with 2 updates (#3030)
Bumps the next group with 2 updates: [next](https://github.com/vercel/next.js) and [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next).


Updates `next` from 14.2.3 to 14.2.6
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v14.2.3...v14.2.6)

Updates `eslint-config-next` from 14.2.3 to 14.2.6
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v14.2.6/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: next
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: next
- dependency-name: eslint-config-next
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: next
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-22 22:51:08 +00:00
Max DeichmannandGitHub dee36bbe29 chore: remove instrumentation from prisma dependabot group (#3033) 2024-08-23 00:31:28 +02:00
Max DeichmannandGitHub f71cf2f894 fix: remove dependabot patch group (#3027) 2024-08-22 23:52:35 +02:00
Marc KlingenandGitHub 1a707fe147 fix(ui): layout entitlements (#3026)
* fix(ui): layout entitlements

* push
2024-08-22 21:51:09 +00:00
Marc KlingenandGitHub 8da1640777 perf(ui): parallelize db queries in users.all route (#3018) 2024-08-22 23:24:14 +02:00
Max DeichmannandGitHub 1e56499f00 chore: upgrade bull (#3025)
fix
2024-08-22 21:23:18 +00:00
Max DeichmannandGitHub 2524fb9648 fix: otel error formatting (#3024) 2024-08-22 19:29:31 +00:00
marliessophieandGitHub 91cd86a286 chore(ui): Add free text option to dynamically populated filter options (#3020) 2024-08-22 18:23:42 +02:00
Lucas MagnumandGitHub e6b9d23615 fix(auth): Cannot read properties of undefined, GoogleProfile.hd (#3019) 2024-08-22 17:36:20 +02:00
Marc KlingenandGitHub 8b76feeffa perf(ui): parallelize db queries in filterOptions routes (#3016) 2024-08-22 15:27:35 +00:00
88a53dc72d perf(tables): drop filter option counts to reduce latency (#3013)
* perf(filters): rm counts from prompt filter options

* perf: drop counts from generations filter options

* perf: drop counts from trace filter options

---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-08-22 17:06:15 +02:00
Marc KlingenandGitHub 649a9af91c perf(ui): simplify default traces.byId route to not load observations and scores for table use cases (#3010) 2024-08-22 16:19:02 +02:00
Marc KlingenandGitHub 876917d112 perf(ui): remove unused scores from observations.byId (#3011) 2024-08-22 16:17:51 +02:00
Max DeichmannandGitHub bb6bc79bdc fix: use serverComponentsExternalPackages for dd and otel (#3009) 2024-08-22 13:21:14 +00:00
Marc KlingenandGitHub 7aff2b2e61 perf(ui): parallelize db queries in byId trpc routes (#3006) 2024-08-22 14:45:36 +02:00
Max DeichmannandGitHub 8f742f660a fix: await closure of bull queues (#3007)
fix
2024-08-22 11:15:54 +00:00
330 changed files with 10415 additions and 5453 deletions
+3
View File
@@ -51,3 +51,6 @@ REDIS_AUTH="myredissecret"
LANGFUSE_WORKER_PASSWORD=mybasicauthsecret
# openssl rand -hex 32 used only here
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
# speeds up local development by not executing init scripts on server startup
NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
+27 -4
View File
@@ -28,6 +28,9 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
# Use CSP headers to enforce HTTPS, optional
# LANGFUSE_CSP_ENFORCE_HTTPS="true"
# Configure base path for self-hosting, optional
# Note: You need to build the docker image with the base path set and cannot use the pre-built docker image if you set this.
# NEXT_PUBLIC_BASE_PATH="/app"
# Docker only, optional
# PORT=3000
@@ -39,6 +42,10 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
# LANGFUSE_DEFAULT_PROJECT_ID=
# LANGFUSE_DEFAULT_PROJECT_ROLE=
# Logging, optional
# LANGFUSE_LOG_LEVEL=info
# LANGFUSE_LOG_FORMAT=text
# Enable experimental features, optional
# LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=true
@@ -97,6 +104,17 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
# The page size can be adjusted if needed to optimize performance
# DB_EXPORT_PAGE_SIZE=1000
# Automated provisioning of default resources
# LANGFUSE_INIT_ORG_ID=org-id
# LANGFUSE_INIT_ORG_NAME=org-name
# LANGFUSE_INIT_PROJECT_ID=project-id
# LANGFUSE_INIT_PROJECT_NAME=project-name
# LANGFUSE_INIT_PROJECT_PUBLIC_KEY=pk-1234567890
# LANGFUSE_INIT_PROJECT_SECRET_KEY=sk-1234567890
# LANGFUSE_INIT_USER_EMAIL=user@example.com
# LANGFUSE_INIT_USER_NAME=User Name
# LANGFUSE_INIT_USER_PASSWORD=password
### START Enterprise Edition Configuration
@@ -109,6 +127,8 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
# LANGFUSE_UI_DOCUMENTATION_HREF=https://docs.example.com
# LANGFUSE_UI_SUPPORT_HREF=https://support.example.com
# LANGFUSE_UI_FEEDBACK_HREF=https://feedback.example.com
# LANGFUSE_UI_LOGO_LIGHT_MODE_HREF=https://static.langfuse.com/langfuse-dev/example-logo-light-mode.png
# LANGFUSE_UI_LOGO_DARK_MODE_HREF=https://static.langfuse.com/langfuse-dev/example-logo-dark-mode.png
### END Enterprise Edition Configuration
@@ -139,13 +159,12 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
# NEXT_PUBLIC_POSTHOG_KEY=
# Sentry
# NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE
# NEXT_PUBLIC_SENTRY_DSN=
# NEXT_SENTRY_ORG=
# NEXT_SENTRY_PROJECT=
# SENTRY_AUTH_TOKEN=
# Used to determine the Sentry sample rate
# LANGFUSE_TRACING_SAMPLE_RATE=
# LANGFUSE_WORKER_BETTERSTACK_TOKEN=
# SENTRY_CSP_REPORT_URI=
# Cloudflare Turnstile
@@ -167,6 +186,7 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
# REDIS_PORT=
# REDIS_AUTH=
# REDIS_CONNECTION_STRING=
# REDIS_ENABLE_AUTO_PIPELINING=
# Cache configuration
# LANGFUSE_CACHE_API_KEY_ENABLED=
@@ -174,6 +194,9 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
# LANGFUSE_CACHE_PROMPT_ENABLED=
# LANGFUSE_CACHE_PROMPT_TTL_SECONDS=
# Rate limiting
# LANGFUSE_RATE_LIMITS_ENABLED=
# Stripe
# STRIPE_SECRET_KEY=
# STRIPE_WEBHOOK_SIGNING_SECRET=
@@ -197,8 +220,8 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_BATCH_SIZE=
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=
# LANGFUSE_INGESTION_CLICKHOUSE_MAX_ATTEMPTS=
# LANGFUSE_LOG_LEVEL=
# LANGFUSE_LEGACY_INGESTION_WORKER_CONCURRENCY=
# LANGFUSE_ASYNC_INGESTION_PROCESSING="true"
# QUEUE_CONSUMER_LEGACY_INGESTION_QUEUE_IS_ENABLED="true"
## END Langfuse V3 Ingestion
+20 -7
View File
@@ -8,6 +8,7 @@ updates:
- package-ecosystem: npm
directory: "/" # Location of package manifests
rebase-strategy: "disabled" # use dependabot-rebase-stale
open-pull-requests-limit: 10
schedule:
interval: "daily"
versioning-strategy: "increase"
@@ -19,17 +20,29 @@ updates:
- dependency-name: "@types/node"
- dependency-name: "@trpc/*"
groups:
sentry:
patterns:
- "@sentry/*"
prisma:
patterns:
- "prisma"
- "@prisma/*"
- "@prisma/client"
next:
patterns:
- "eslint-config-next"
- "next"
patches:
update-types:
- "patch"
lodash:
patterns:
- "lodash"
- "@types/lodash"
express:
patterns:
- "express"
- "@types/express"
observability:
patterns:
- "dd-trace"
- "@opentelemetry/*"
- "@appsignal/opentelemetry-instrumentation-bullmq"
- "@prisma/instrumentation"
- "@sentry/*"
radix-ui:
patterns:
- "@radix-ui/*"
+71
View File
@@ -0,0 +1,71 @@
name: WorkflowCall - Deploy to ECS Service
on:
workflow_call:
inputs:
environment:
type: string
description: Deployment environment
required: true
service:
type: string
description: Name of the service to be deployed, e.g. web-ingestion, web, or worker.
required: true
jobs:
ecs-deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- name: Get app name
uses: winterjung/split@v2
id: split
with:
msg: ${{ inputs.service }}
separator: '-'
- name: Checkout code
uses: actions/checkout@v4
- name: Authenticate with AWS
# GitHub/AWS recommend to use OIDC here: https://github.com/aws-actions/configure-aws-credentials?tab=readme-ov-file#oidc
# Probably more painful to configure, but would remove all long-lived credentials.
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION }}
- name: Login to AWS ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build, tag, and push Docker image
env:
REGISTRY: ${{ steps.login-ecr.outputs.registry }}
REPOSITORY: ${{ steps.split.outputs._0 }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build \
-t $REGISTRY/$REPOSITORY:$IMAGE_TAG \
-f ./${{ steps.split.outputs._0 }}/Dockerfile \
--build-arg NEXT_PUBLIC_LANGFUSE_CLOUD_REGION=${{ vars.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION }} \
--build-arg NEXT_LANGFUSE_TRACING_SAMPLE_RATE=${{ vars.NEXT_LANGFUSE_TRACING_SAMPLE_RATE }} \
--build-arg NEXT_PUBLIC_SENTRY_ENVIRONMENT=${{ vars.NEXT_PUBLIC_SENTRY_ENVIRONMENT }} \
--build-arg NEXT_PUBLIC_DEMO_ORG_ID=${{ vars.NEXT_PUBLIC_DEMO_ORG_ID }} \
--build-arg NEXT_PUBLIC_DEMO_PROJECT_ID=${{ vars.NEXT_PUBLIC_DEMO_PROJECT_ID }} \
--build-arg NEXT_PUBLIC_SENTRY_DSN=${{ vars.NEXT_PUBLIC_SENTRY_DSN }} \
--build-arg NEXT_PUBLIC_BUILD_ID=${{ github.sha }} \
--build-arg NEXT_PUBLIC_POSTHOG_KEY=${{ vars.NEXT_PUBLIC_POSTHOG_KEY }} \
--build-arg NEXT_PUBLIC_POSTHOG_HOST=${{ vars.NEXT_PUBLIC_POSTHOG_HOST }} \
--build-arg NEXT_PUBLIC_CRISP_WEBSITE_ID=${{ vars.NEXT_PUBLIC_CRISP_WEBSITE_ID }} \
.
docker push $REGISTRY/$REPOSITORY:$IMAGE_TAG
- name: Render AWS ECS Task Definition
id: render-task-definition
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
container-name: ${{ inputs.service }}
image: ${{ steps.login-ecr.outputs.registry }}/${{ steps.split.outputs._0 }}:${{ github.sha }}
task-definition-family: ${{ inputs.environment }}-${{ inputs.service }}
- name: Update AWS ECS Service
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: ${{ steps.render-task-definition.outputs.task-definition }}
service: ${{ inputs.environment }}-${{ inputs.service }}
cluster: ${{ inputs.environment }}-cluster
wait-for-service-stability: true
+105
View File
@@ -0,0 +1,105 @@
on:
push:
branches:
- main
- production
workflow_dispatch:
inputs:
service:
description: 'Service to be deployed'
type: choice
options:
- all
- web
- web-ingestion
- worker
required: true
environment:
description: 'Environment to deploy to'
type: choice
options:
- staging
- prod-eu-temp
- prod-us
required: true
concurrency:
# Support concurrent `push` and `workflow_dispatch`` actions
group: deploy-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
name: Deploy to ECS
jobs:
affected-services:
runs-on: ubuntu-latest
outputs:
services: ${{ steps.affected-services.outputs.result }}
steps:
- name: Get affected services
id: affected-services
uses: actions/github-script@v7
with:
script: |
if (context.eventName === "workflow_dispatch") {
if (context.payload.inputs.service === "all") {
return `["web", "web-ingestion", "worker"]`
}
return `["${context.payload.inputs.service}"]`
}
if (context.eventName === "push") {
return `["web", "web-ingestion", "worker"]`
}
return "[]"
result-encoding: string
- name: Print services to build
uses: actions/github-script@v7
env:
services: ${{ steps.affected-services.outputs.result }}
with:
result-encoding: string
script: |
console.log('Services', `${process.env.services}` ?? 'n/a');
affected-environments:
runs-on: ubuntu-latest
outputs:
environments: ${{ steps.affected-environments.outputs.result }}
steps:
- name: Get affected environments
id: affected-environments
uses: actions/github-script@v7
with:
script: |
if (context.eventName === "workflow_dispatch") {
return `["${context.payload.inputs.environment}"]`
}
if (context.eventName === "push") {
if (context.ref === "refs/heads/main") {
return `["staging"]`
}
if (context.ref === "refs/heads/production") {
return `["prod-eu-temp", "prod-us"]`
}
}
return "[]"
result-encoding: string
- name: Print environments to build
uses: actions/github-script@v7
env:
environments: ${{ steps.affected-environments.outputs.result }}
with:
result-encoding: string
script: |
console.log('Environments', `${process.env.environments}` ?? 'n/a');
ecs-deploy:
uses: ./.github/workflows/_deploy_ecs_service.yml
needs: [affected-services, affected-environments]
secrets: inherit
strategy:
matrix:
service: ${{ fromJson(needs.affected-services.outputs.services) }}
environment: ${{ fromJson(needs.affected-environments.outputs.environments) }}
with:
service: ${{ matrix.service }}
environment: ${{ matrix.environment }}
+32 -4
View File
@@ -40,6 +40,9 @@ jobs:
- name: Checkout
uses: actions/checkout@v3
- name: Set NEXT_PUBLIC_BUILD_ID
run: echo "NEXT_PUBLIC_BUILD_ID=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Build and run both images from compose
run: |
docker compose -f docker-compose.build.yml up -d
@@ -94,7 +97,7 @@ jobs:
- name: Load default env
run: |
cp .env.dev.example .env
grep -v -e '^REDIS_HOST=' .env.dev.example > .env
grep -v -e '^REDIS_HOST=' -e '^NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT=' .env.dev.example > .env
- name: Run + migrate
run: |
@@ -107,13 +110,26 @@ jobs:
- name: Seed DB
run: |
pnpm run db:migrate
pnpm run db:seed
- name: Build
run: pnpm run build
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
- name: Start Langfuse
run: (pnpm run start&)
env:
LANGFUSE_INIT_ORG_ID: "seed-org-id"
LANGFUSE_INIT_ORG_NAME: "Seed Org"
LANGFUSE_INIT_PROJECT_ID: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a"
LANGFUSE_INIT_PROJECT_NAME: "Seed Project"
LANGFUSE_INIT_PROJECT_PUBLIC_KEY: "pk-lf-1234567890"
LANGFUSE_INIT_PROJECT_SECRET_KEY: "sk-lf-1234567890"
LANGFUSE_INIT_USER_EMAIL: "demo@langfuse.com"
LANGFUSE_INIT_USER_NAME: "Demo User"
LANGFUSE_INIT_USER_PASSWORD: "password"
- name: run tests
run: pnpm --filter=web run test
@@ -136,6 +152,7 @@ jobs:
- uses: pnpm/action-setup@v3
with:
version: 9.5.0
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
@@ -165,18 +182,26 @@ jobs:
sleep 5 # Wait for PostgreSQL to accept connections
docker compose ps
- name: Ensure no unhealthy status
run: |
if docker compose ps | grep "(unhealthy)"; then
echo "One or more services are unhealthy"
exit 1
else
echo "All services are healthy"
fi
- name: Seed DB
run: |
pnpm run --filter=shared ch:up
pnpm run db:migrate
pnpm run db:seed
pnpm run --filter=shared ch:up
- name: Build
run: pnpm --filter=worker... run build
- name: run tests
run: pnpm --filter=worker run test
e2e-tests:
runs-on: ubuntu-latest
steps:
@@ -308,6 +333,9 @@ jobs:
- name: Checkout
uses: actions/checkout@v3
- name: Set NEXT_PUBLIC_BUILD_ID
run: echo "NEXT_PUBLIC_BUILD_ID=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
@@ -27,3 +27,4 @@ jobs:
PORTER_STACK_NAME: web
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4060 }}
PORTER_NEXT_PUBLIC_BUILD_ID: ${{ steps.vars.outputs.sha_short }}
@@ -27,3 +27,4 @@ jobs:
PORTER_STACK_NAME: web
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4037 }}
PORTER_NEXT_PUBLIC_BUILD_ID: ${{ steps.vars.outputs.sha_short }}
@@ -27,3 +27,4 @@ jobs:
PORTER_STACK_NAME: web
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4054 }}
PORTER_NEXT_PUBLIC_BUILD_ID: ${{ steps.vars.outputs.sha_short }}
+3 -3
View File
@@ -1,11 +1,11 @@
name: Snyk Container
on:
push:
branches-ignore:
- "dependabot/**"
pull_request:
branches:
- "**"
push:
branches:
- main
jobs:
snyk:
+29 -30
View File
@@ -1,8 +1,7 @@
![langfuse_logo_1024](https://github.com/user-attachments/assets/f17d8fc1-8206-4afa-a5ec-774826be88a8)
![Langfuse GitHub Banner](https://github.com/langfuse/langfuse/assets/121163007/6035f0f3-d691-4963-b5d0-10cf506e9d42)
<div align="center"><h1> Langfuse: Open Source LLM Observability & Engineering Platform</h4></div>
<div align="center"><h2> Debug and improve your LLM app</h4></div>
<div align="center"><h3> LLM Observability, Prompt Management, LLM Evaluations, Datasets, LLM Metrics and Prompt Playground</h4></div>
<div align="center"><h1>Langfuse: Open Source LLM Engineering Platform</h1></div>
<div align="center"><h4>LLM Observability, Prompt Management, LLM Evaluations,<br/>Datasets, LLM Metrics, and Prompt Playground</h4></div>
<div align="center">
<div>
@@ -28,15 +27,15 @@
</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>
<span>We're hiring. <a href="https://langfuse.com/careers"><strong>Join us</strong></a> in 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>
<a href="https://www.ycombinator.com/companies/langfuse"><img src="https://img.shields.io/badge/Y%20Combinator-W23-orange?style=flat-square" alt="Y Combinator W23"></a>
<a href="https://github.com/langfuse/langfuse/pkgs/container/langfuse"><img alt="Docker Image" src="https://img.shields.io/badge/docker-langfuse-blue?logo=Docker&logoColor=white&style=flat-square"></a>
<a href="https://www.npmjs.com/package/langfuse"><img src="https://img.shields.io/npm/v/langfuse?style=flat-square&label=npm+langfuse" alt="langfuse npm package"></a>
<a href="https://pypi.python.org/pypi/langfuse"><img src="https://img.shields.io/pypi/v/langfuse.svg?style=flat-square&label=pypi+langfuse" alt="langfuse Python package on PyPi"></a>
<a href="https://pypi.python.org/pypi/langfuse"><img src="https://img.shields.io/pypi/dm/langfuse?style=flat-square&logo=python&logoColor=white&label=pypi%20langfuse&color=blue" alt="langfuse Python package on PyPi"></a>
<a href="https://www.npmjs.com/package/langfuse"><img src="https://img.shields.io/npm/dm/langfuse?style=flat-square&logo=npm&logoColor=white&label=npm%20langfuse&color=blue" alt="langfuse npm package"></a>
</div>
</div>
</br>
@@ -93,10 +92,10 @@ docker compose up -d
### Self-host (docker)
Langfuse is simple to self-host and keep updated. It currently requires only a single docker container.
Langfuse is simple to self-host and keep updated. It currently requires only a single docker container and a postgres database.
[→ Self Hosting Instructions](https://langfuse.com/docs/deployment/self-host)
Templated deployments: [Railway, GCP Cloud Run, AWS Fargate, Kubernetes and others](https://langfuse.com/docs/deployment/self-host#platform-specific-information)
Templated deployments: [Railway, GCP, AWS, Azure, Kubernetes and others](https://langfuse.com/docs/deployment/self-host#platform-specific-information)
## Get Started
@@ -108,31 +107,31 @@ You need a Langfuse public and secret key to get started. Sign up [here](https:/
Note: We recommend using our fully async, typed [SDKs](https://langfuse.com/docs/sdk) that allow you to instrument any LLM application with any underlying model. They are available in [Python (Decorators)](https://langfuse.com/docs/sdk/python) & [JS/TS](https://langfuse.com/docs/sdk/typescript). The SDKs will always be the most fully featured and stable way to ingest data into Langfuse.
You may want to use another integration to get started quickly or implement a use case that we do not yet support. However, we recommend to migrate to the Langfuse SDKs over time to ensure performance and stability.
See the [→ Quickstart](https://langfuse.com/docs/get-started) to integrate Langfuse.
### LLM Observability Integrations
| Integration | Supports | Description |
| -------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| [SDK](/docs/sdk) | Python, JS/TS | Manual instrumentation using the SDKs for full flexibility. |
| [OpenAI](/docs/integrations/openai) | Python, JS/TS | Automated instrumentation using drop-in replacement of OpenAI SDK. |
| [Langchain](/docs/integrations/langchain) | Python, JS/TS | Automated instrumentation by passing callback handler to Langchain application. |
| [LlamaIndex](/docs/integrations/llama-index/get-started) | Python | Automated instrumentation via LlamaIndex callback system. |
| [Haystack](/docs/integrations/haystack) | Python | Automated instrumentation via Haystack content tracing system. |
| [LiteLLM](/docs/integrations/litellm) | Python, JS/TS (proxy only) | Use any LLM as a drop in replacement for GPT. Use Azure, OpenAI, Cohere, Anthropic, Ollama, VLLM, Sagemaker, HuggingFace, Replicate (100+ LLMs). |
| [API](/docs/api) | | Directly call the public API. OpenAPI spec available. |
| Integration | Supports | Description |
| ---------------------------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| [SDK](https://langfuse.com/docs/sdk) | Python, JS/TS | Manual instrumentation using the SDKs for full flexibility. |
| [OpenAI](https://langfuse.com/docs/integrations/openai) | Python, JS/TS | Automated instrumentation using drop-in replacement of OpenAI SDK. |
| [Langchain](https://langfuse.com/docs/integrations/langchain) | Python, JS/TS | Automated instrumentation by passing callback handler to Langchain application. |
| [LlamaIndex](https://langfuse.com/docs/integrations/llama-index/get-started) | Python | Automated instrumentation via LlamaIndex callback system. |
| [Haystack](https://langfuse.com/docs/integrations/haystack) | Python | Automated instrumentation via Haystack content tracing system. |
| [LiteLLM](https://langfuse.com/docs/integrations/litellm) | Python, JS/TS (proxy only) | Use any LLM as a drop in replacement for GPT. Use Azure, OpenAI, Cohere, Anthropic, Ollama, VLLM, Sagemaker, HuggingFace, Replicate (100+ LLMs). |
| [Vercel AI SDK](https://langfuse.com/docs/integrations/vercel-ai-sdk) | JS/TS | TypeScript toolkit designed to help developers build AI-powered applications with React, Next.js, Vue, Svelte, Node.js. |
| [API](https://langfuse.com/docs/api) | | Directly call the public API. OpenAPI spec available. |
Packages that integrate with Langfuse:
Packages integrated with Langfuse:
| Name | Description |
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| [Instructor](/docs/integrations/instructor) | Library to get structured LLM outputs (JSON, Pydantic) |
| [Mirascope](/docs/integrations/mirascope) | Python toolkit for building LLM applications. |
| [AI SDK by Vercel](/docs/sdk/typescript/example-vercel-ai) | Typescript SDK that makes streaming LLM outputs super easy. |
| [Flowise](/docs/integrations/flowise) | JS/TS no-code builder for customized LLM flows. |
| [Langflow](/docs/integrations/langflow) | Python-based UI for LangChain, designed with react-flow to provide an effortless way to experiment and prototype flows. |
| Name | Description |
| --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| [Instructor](https://langfuse.com/docs/integrations/instructor) | Library to get structured LLM outputs (JSON, Pydantic) |
| [Dify](https://langfuse.com/docs/integrations/dify) | Open source LLM app development platform with no-code builder. |
| [Ollama](https://langfuse.com/docs/integrations/ollama) | Easily run open source LLMs on your own machine. |
| [Mirascope](https://langfuse.com/docs/integrations/mirascope) | Python toolkit for building LLM applications. |
| [Flowise](https://langfuse.com/docs/integrations/flowise) | JS/TS no-code builder for customized LLM flows. |
| [Langflow](https://langfuse.com/docs/integrations/langflow) | Python-based UI for LangChain, designed with react-flow to provide an effortless way to experiment and prototype flows. |
## Questions and feedback
@@ -140,13 +139,13 @@ Packages that integrate with Langfuse:
- [Roadmap](https://langfuse.com/roadmap)
- [GitHub Discussions](https://github.com/orgs/langfuse/discussions)
- [Feature Requests](https://langfuse.com/idea
- [Feature Requests](https://langfuse.com/ideas)
### Support and feedback
In order of preference the best way to communicate with us:
- [GitHub Discussions](https://github.com/orgs/langfuse/discussions): Contribute [ideas](https://langfuse.com/idea) [support requests](https://github.com/orgs/langfuse/discussions/categories/support) and [report bugs](https://github.com/langfuse/langfuse/issues/new?labels=%F0%9F%90%9E%E2%9D%94+unconfirmed+bug&projects=&template=bug_report.yml&title=bug%3A+) (preferred as we create a permanent, indexed artifact for other community members)
- [GitHub Discussions](https://github.com/orgs/langfuse/discussions) (preferred): Contribute [ideas](https://langfuse.com/ideas), [support requests](https://langfuse.com/gh-support) and [report bugs](https://langfuse.com/issues)
- [Discord](https://langfuse.com/discord): community support
- Privately: contact at langfuse dot com
+1 -1
View File
@@ -26,7 +26,7 @@
"dependencies": {
"@langfuse/shared": "workspace:*",
"@opentelemetry/api": ">=1.0.0 <1.9.0",
"next": "^14.2.3",
"next": "^14.2.6",
"next-auth": "^4.24.7",
"zod": "^3.23.8"
},
+2 -2
View File
@@ -31,10 +31,10 @@ service:
docs: Optional filter for metrics where traces include all of these tags
fromTimestamp:
type: optional<datetime>
docs: Optional filter to only include traces on or after a certain datetime (ISO 8601)
docs: Optional filter to only include traces and observations on or after a certain datetime (ISO 8601)
toTimestamp:
type: optional<datetime>
docs: Optional filter to only include traces before a certain datetime (ISO 8601)
docs: Optional filter to only include traces and observations before a certain datetime (ISO 8601)
response: DailyMetrics
types:
DailyMetrics:
+2 -9
View File
@@ -11,24 +11,17 @@ groups:
namespaceExport: Langfuse
allowCustomFetcher: true
- name: fernapi/fern-python-sdk
version: 1.1.0-rc2
version: 2.16.0
output:
location: local-file-system
path: ../../../generated/python
config:
client_class_name: FernLangfuse
improved_imports: false
inline_request_params: false
pydantic_config:
require_optional_fields: false
use_str_enums: false
- name: fernapi/fern-typescript-node-sdk
version: 0.7.1
output:
location: local-file-system
path: ../../../generated/typescript-server
config:
namespaceExport: Langfuse
allowCustomFetcher: true
- name: fernapi/fern-postman
version: 0.0.45
output:
+1 -1
View File
@@ -1,4 +1,4 @@
{
"organization": "finto",
"version": "0.30.3"
"version": "0.41.1"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "2.74.0",
"version": "2.80.0",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
+1 -1
View File
@@ -11,7 +11,7 @@
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.12.0",
"@vercel/style-guide": "^6.0.0",
"eslint-config-next": "^14.2.3",
"eslint-config-next": "^14.2.6",
"eslint-config-prettier": "^9.1.0",
"eslint-config-turbo": "^1.13.4",
"eslint-plugin-only-warn": "^1.1.0",
+1
View File
@@ -18,5 +18,6 @@ if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
else
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-migrations-table-engine=MergeTree"
fi
# Execute the up command
migrate -source file://clickhouse/migrations -database "$DATABASE_URL" up
+14 -8
View File
@@ -21,6 +21,10 @@
"import": "./dist/src/server/index.js",
"require": "./dist/src/server/index.js"
},
"./src/server/auth/apiKeys": {
"import": "./dist/src/server/auth/apiKeys.js",
"require": "./dist/src/server/auth/apiKeys.js"
},
"./encryption": {
"import": "./dist/src/encryption/index.js",
"require": "./dist/src/encryption/index.js"
@@ -51,7 +55,7 @@
"dependencies": {
"@anthropic-ai/tokenizer": "^0.0.4",
"@aws-sdk/client-s3": "^3.627.0",
"@aws-sdk/lib-storage": "^3.568.0",
"@aws-sdk/lib-storage": "^3.645.0",
"@aws-sdk/s3-request-presigner": "^3.554.0",
"@clickhouse/client": "^1.4.0",
"@langchain/anthropic": "^0.1.21",
@@ -63,25 +67,27 @@
"@react-email/render": "^0.0.15",
"@types/bcryptjs": "^2.4.6",
"bcryptjs": "^2.4.3",
"bullmq": "^5.4.2",
"bullmq": "^5.12.10",
"dd-trace": "^5.19.0",
"decimal.js": "^10.4.3",
"exponential-backoff": "^3.1.1",
"dd-trace": "^5.19.0",
"ioredis": "^5.4.1",
"kysely": "^0.27.3",
"kysely": "^0.27.4",
"langchain": "^0.2.6",
"lodash": "^4.17.21",
"next-auth": "^4.24.7",
"nodemailer": "^6.9.13",
"prisma-extension-kysely": "^2.1.0",
"uuid": "^9.0.1",
"winston": "^3.14.2",
"winston-transport": "^4.7.1",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.22.5"
"zod-to-json-schema": "^3.23.2"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/lodash": "^4.17.5",
"@types/lodash": "^4.17.7",
"@types/node": "^20.11.29",
"@types/nodemailer": "^6.4.15",
"@types/pg": "^8.11.6",
@@ -103,7 +109,7 @@
"vitest": "^1.5.3"
},
"peerDependencies": {
"@types/react": "^18.2.79",
"react": "^18.0.0"
"@types/react": "~18.2.79",
"react": "~18.2.0"
}
}
@@ -80,6 +80,7 @@ export type Account = {
scope: string | null;
id_token: string | null;
session_state: string | null;
refresh_token_expires_in: number | null;
};
export type ApiKey = {
id: string;
@@ -0,0 +1,30 @@
-- For OpenAI reasoning models, manually tokenizing output string does not account for reasoning tokens, thus would lead to incorrect cost
-- So no manual tokenizer config is provided
-- Reference: https://platform.openai.com/docs/guides/reasoning/how-reasoning-works
INSERT INTO models (
id,
project_id,
model_name,
match_pattern,
start_date,
input_price,
output_price,
total_price,
unit,
tokenizer_id,
tokenizer_config
)
VALUES
-- o1-preview
('cm10ivcdp0000gix7lelmbw80', NULL, 'o1-preview', '(?i)^(o1-preview)$', NULL, 0.000015, 0.00006, NULL, 'TOKENS', NULL, NULL),
-- o1-preview-2024-09-12
('cm10ivo130000n8x7qopcjjcg', NULL, 'o1-preview-2024-09-12', '(?i)^(o1-preview-2024-09-12)$', NULL, 0.000015, 0.00006, NULL, 'TOKENS', NULL, NULL),
-- o1-mini
('cm10ivwo40000r1x7gg3syjq0', NULL, 'o1-mini', '(?i)^(o1-mini)$', NULL, 0.000003, 0.000012, NULL, 'TOKENS', NULL, NULL),
-- o1-mini-2024-09-12
('cm10iw6p20000wgx7it1hlb22', NULL, 'o1-mini-2024-09-12', '(?i)^(o1-mini-2024-09-12)$', NULL, 0.000003, 0.000012, NULL, 'TOKENS', NULL, NULL)
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Account" ADD COLUMN "refresh_token_expires_in" INTEGER;
+16 -15
View File
@@ -34,21 +34,22 @@ generator kysely {
// Necessary for Next auth
model Account {
id String @id @default(cuid())
userId String @map("user_id")
type String
provider String
providerAccountId String
refresh_token String? // @db.Text
access_token String? // @db.Text
expires_at Int?
expires_in Int?
ext_expires_in Int?
token_type String?
scope String?
id_token String? // @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
userId String @map("user_id")
type String
provider String
providerAccountId String
refresh_token String? // @db.Text
access_token String? // @db.Text
expires_at Int?
expires_in Int?
ext_expires_in Int?
token_type String?
scope String?
id_token String? // @db.Text
session_state String?
refresh_token_expires_in Int?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@index([userId])
+46 -46
View File
@@ -12,7 +12,7 @@ import { parseArgs } from "node:util";
import { chunk } from "lodash";
import { v4 } from "uuid";
import { ModelUsageUnit } from "../src";
import { getDisplaySecretKey, hashSecretKey } from "../src/server";
import { getDisplaySecretKey, hashSecretKey, logger } from "../src/server";
import { encrypt } from "../src/encryption";
import { redis } from "../src/server/redis/redis";
@@ -265,11 +265,11 @@ async function main() {
project1,
project2,
promptIds,
configIdsAndNames
configIdsAndNames,
);
console.log(
`Seeding ${traces.length} traces, ${observations.length} observations, and ${scores.length} scores`
logger.info(
`Seeding ${traces.length} traces, ${observations.length} observations, and ${scores.length} scores`,
);
await uploadObjects(
@@ -278,7 +278,7 @@ async function main() {
scores,
sessions,
events,
comments
comments,
);
// If openai key is in environment, add it to the projects LLM API keys
@@ -295,8 +295,8 @@ async function main() {
},
});
} else {
console.warn(
"No OPENAI_API_KEY found in environment. Skipping seeding LLM API key."
logger.warn(
"No OPENAI_API_KEY found in environment. Skipping seeding LLM API key.",
);
}
@@ -431,7 +431,7 @@ async function main() {
for (const datasetItemId of datasetItemIds) {
const relevantObservations = observations.filter(
(o) => o.projectId === project2.id
(o) => o.projectId === project2.id,
);
const observation =
relevantObservations[
@@ -457,13 +457,13 @@ main()
.then(async () => {
await prisma.$disconnect();
redis?.disconnect();
console.log("Disconnected from postgres and redis");
logger.info("Disconnected from postgres and redis");
})
.catch(async (e) => {
console.error(e);
logger.error(e);
await prisma.$disconnect();
redis?.disconnect();
console.log("Disconnected from postgres and redis");
logger.info("Disconnected from postgres and redis");
process.exit(1);
});
@@ -473,7 +473,7 @@ async function uploadObjects(
scores: Prisma.ScoreCreateManyInput[],
sessions: Prisma.TraceSessionCreateManyInput[],
events: Prisma.ObservationCreateManyInput[],
comments: Prisma.CommentCreateManyInput[]
comments: Prisma.CommentCreateManyInput[],
) {
let promises: Prisma.PrismaPromise<unknown>[] = [];
@@ -487,14 +487,14 @@ async function uploadObjects(
},
create: chunk[0]!,
update: {},
})
}),
);
});
for (let i = 0; i < promises.length; i++) {
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
console.log(
`Seeding of Sessions ${((i + 1) / promises.length) * 100}% complete`
logger.info(
`Seeding of Sessions ${((i + 1) / promises.length) * 100}% complete`,
);
await promises[i];
}
@@ -505,13 +505,13 @@ async function uploadObjects(
promises.push(
prisma.trace.createMany({
data: chunk,
})
}),
);
});
for (let i = 0; i < promises.length; i++) {
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
console.log(
`Seeding of Traces ${((i + 1) / promises.length) * 100}% complete`
logger.info(
`Seeding of Traces ${((i + 1) / promises.length) * 100}% complete`,
);
await promises[i];
}
@@ -521,14 +521,14 @@ async function uploadObjects(
promises.push(
prisma.observation.createMany({
data: chunk,
})
}),
);
});
for (let i = 0; i < promises.length; i++) {
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
console.log(
`Seeding of Observations ${((i + 1) / promises.length) * 100}% complete`
logger.info(
`Seeding of Observations ${((i + 1) / promises.length) * 100}% complete`,
);
await promises[i];
}
@@ -538,14 +538,14 @@ async function uploadObjects(
promises.push(
prisma.observation.createMany({
data: chunk,
})
}),
);
});
for (let i = 0; i < promises.length; i++) {
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
console.log(
`Seeding of Events ${((i + 1) / promises.length) * 100}% complete`
logger.info(
`Seeding of Events ${((i + 1) / promises.length) * 100}% complete`,
);
await promises[i];
}
@@ -555,13 +555,13 @@ async function uploadObjects(
promises.push(
prisma.score.createMany({
data: chunk,
})
}),
);
});
for (let i = 0; i < promises.length; i++) {
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
console.log(
`Seeding of Scores ${((i + 1) / promises.length) * 100}% complete`
logger.info(
`Seeding of Scores ${((i + 1) / promises.length) * 100}% complete`,
);
await promises[i];
}
@@ -571,13 +571,13 @@ async function uploadObjects(
promises.push(
prisma.comment.createMany({
data: chunk,
})
}),
);
});
for (let i = 0; i < promises.length; i++) {
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
console.log(
`Seeding of Comments ${((i + 1) / promises.length) * 100}% complete`
logger.info(
`Seeding of Comments ${((i + 1) / promises.length) * 100}% complete`,
);
await promises[i];
}
@@ -598,7 +598,7 @@ function createObjects(
dataType: ScoreDataType;
categories: ConfigCategory[] | null;
}[]
>
>,
) {
const traces: Prisma.TraceCreateManyInput[] = [];
const observations: Prisma.ObservationCreateManyInput[] = [];
@@ -612,7 +612,7 @@ function createObjects(
// print progress to console with a progress bar that refreshes every 10 iterations
// random date within last 90 days, with a linear bias towards more recent dates
const traceTs = new Date(
Date.now() - Math.floor(Math.random() ** 1.5 * 90 * 24 * 60 * 60 * 1000)
Date.now() - Math.floor(Math.random() ** 1.5 * 90 * 24 * 60 * 60 * 1000),
);
const envTag = envTags[Math.floor(Math.random() * envTags.length)];
@@ -753,11 +753,11 @@ function createObjects(
for (let j = 0; j < Math.floor(Math.random() * 10) + 1; j++) {
// add between 1 and 30 ms to trace timestamp
const spanTsStart = new Date(
traceTs.getTime() + Math.floor(Math.random() * 30)
traceTs.getTime() + Math.floor(Math.random() * 30),
);
// random duration of upto 5000ms
const spanTsEnd = new Date(
spanTsStart.getTime() + Math.floor(Math.random() * 5000)
spanTsStart.getTime() + Math.floor(Math.random() * 5000),
);
const span = {
@@ -792,22 +792,22 @@ function createObjects(
const generationTsStart = new Date(
spanTsStart.getTime() +
Math.floor(
Math.random() * (spanTsEnd.getTime() - spanTsStart.getTime())
)
Math.random() * (spanTsEnd.getTime() - spanTsStart.getTime()),
),
);
const generationTsEnd = new Date(
generationTsStart.getTime() +
Math.floor(
Math.random() *
(spanTsEnd.getTime() - generationTsStart.getTime())
)
(spanTsEnd.getTime() - generationTsStart.getTime()),
),
);
// somewhere in the middle
const generationTsCompletionStart = new Date(
generationTsStart.getTime() +
Math.floor(
(generationTsEnd.getTime() - generationTsStart.getTime()) / 3
)
(generationTsEnd.getTime() - generationTsStart.getTime()) / 3,
),
);
const promptTokens = Math.floor(Math.random() * 1000) + 300;
@@ -828,7 +828,7 @@ function createObjects(
const promptId =
promptIds.get(projectId)![
Math.floor(
Math.random() * Math.floor(promptIds.get(projectId)!.length / 2)
Math.random() * Math.floor(promptIds.get(projectId)!.length / 2),
)
];
@@ -910,8 +910,8 @@ function createObjects(
const eventTs = new Date(
spanTsStart.getTime() +
Math.floor(
Math.random() * (spanTsEnd.getTime() - spanTsStart.getTime())
)
Math.random() * (spanTsEnd.getTime() - spanTsStart.getTime()),
),
);
events.push({
@@ -933,7 +933,7 @@ function createObjects(
}
// find unique sessions by id and projectid
const uniqueSessions: Prisma.TraceSessionCreateManyInput[] = Array.from(
new Set(sessions.map((session) => JSON.stringify(session)))
new Set(sessions.map((session) => JSON.stringify(session))),
).map((session) => JSON.parse(session) as Prisma.TraceSessionCreateManyInput);
return {
@@ -954,7 +954,7 @@ async function generatePromptsForProject(projects: Project[]) {
projects.map(async (project) => {
const promptIdsForProject = await generatePrompts(project);
promptIds.set(project.id, promptIdsForProject);
})
}),
);
return promptIds;
}
@@ -1140,7 +1140,7 @@ async function generateConfigsForProject(projects: Project[]) {
projects.map(async (project) => {
const configNameAndId = await generateConfigs(project);
projectIdsToConfigs.set(project.id, configNameAndId);
})
}),
);
return projectIdsToConfigs;
}
+28 -7
View File
@@ -1,7 +1,7 @@
// This file exports the prisma db connection, the Prisma Object, and the Typescript types.
// This is not imported in the index.ts file of this package, as we must not import this into FE code.
import { PrismaClient } from "@prisma/client";
import { Prisma, PrismaClient } from "@prisma/client";
import { env } from "process";
import kyselyExtension from "prisma-extension-kysely";
import {
@@ -11,17 +11,38 @@ import {
PostgresQueryCompiler,
} from "kysely";
import { DB } from ".";
import { logger } from "./server";
// Instantiated according to the Prisma documentation
// https://www.prisma.io/docs/orm/more/help-and-troubleshooting/help-articles/nextjs-prisma-client-dev-practices
const prismaClientSingleton = () => {
return new PrismaClient({
log:
env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["error", "warn"],
const client = new PrismaClient<
Prisma.PrismaClientOptions,
"warn" | "error" | "query"
>({
log: [
{ emit: "event", level: "query" },
{ emit: "event", level: "error" },
{ emit: "event", level: "warn" },
],
});
if (env.NODE_ENV === "development") {
client.$on("query", (event) => {
logger.info(`prisma:query ${event.query}, ${event.duration}ms`);
});
}
client.$on("warn", (event) => {
logger.warn(`prisma:warn ${event.message}`);
});
client.$on("error", (event) => {
logger.error(`prisma:error ${event.message}`);
});
return client;
};
const kyselySingleton = (prismaClient: PrismaClient) => {
@@ -38,7 +59,7 @@ const kyselySingleton = (prismaClient: PrismaClient) => {
createQueryCompiler: () => new PostgresQueryCompiler(),
},
}),
})
}),
);
};
declare global {
+7 -1
View File
@@ -16,11 +16,12 @@ const EnvSchema = z.object({
.nullable(),
REDIS_AUTH: z.string().nullish(),
REDIS_CONNECTION_STRING: z.string().nullish(),
REDIS_ENABLE_AUTO_PIPELINING: z.enum(["true", "false"]).default("true"),
ENCRYPTION_KEY: z
.string()
.length(
64,
"ENCRYPTION_KEY must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32"
"ENCRYPTION_KEY must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32",
)
.optional(),
LANGFUSE_CACHE_PROMPT_ENABLED: z.enum(["true", "false"]).default("false"),
@@ -37,6 +38,11 @@ const EnvSchema = z.object({
.number()
.positive()
.default(60 * 10),
SALT: z.string().optional(), // used by components imported by web package
LANGFUSE_LOG_LEVEL: z
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
.optional(),
LANGFUSE_LOG_FORMAT: z.enum(["text", "json"]).default("text"),
});
export const env = EnvSchema.parse(process.env);
@@ -19,6 +19,7 @@ export enum BatchExportFileFormat {
export enum BatchExportTableName {
Sessions = "sessions",
Traces = "traces",
}
export const exportOptions: Record<
@@ -12,3 +12,6 @@ export const plans = Object.keys(planLabels) as Plan[];
// This function is kept here to ensure consistency when updating plan names in the future.
export const isCloudPlan = (plan: Plan) => plan.startsWith("cloud");
export const isPlan = (value: string): value is Plan =>
plans.includes(value as Plan);
@@ -83,13 +83,13 @@ export const ScoreBodyWithoutConfig = z.discriminatedUnion("dataType", [
z.object({
value: z.number(),
dataType: z.literal("NUMERIC"),
})
}),
),
BaseScoreBody.merge(
z.object({
value: z.string(),
dataType: z.literal("CATEGORICAL"),
})
}),
),
BaseScoreBody.merge(
z.object({
@@ -97,7 +97,7 @@ export const ScoreBodyWithoutConfig = z.discriminatedUnion("dataType", [
message: "Value must be either 0 or 1",
}),
dataType: z.literal("BOOLEAN"),
})
}),
),
]);
@@ -161,7 +161,7 @@ export const ScorePropsAgainstConfig = z.union([
*/
export const filterAndValidateDbScoreList = (
scores: Score[],
onParseError?: (error: z.ZodError) => void
onParseError?: (error: z.ZodError) => void,
): APIScore[] =>
scores.reduce((acc, ts) => {
const result = APIScoreSchema.safeParse(ts);
@@ -198,14 +198,14 @@ export const PostScoresBody = z.discriminatedUnion("dataType", [
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({
@@ -215,14 +215,14 @@ export const PostScoresBody = z.discriminatedUnion("dataType", [
}),
dataType: z.literal("BOOLEAN"),
configId: z.string().nullish(),
})
}),
),
BaseScoreBody.merge(
z.object({
value: z.union([z.string(), z.number()]),
dataType: z.undefined(),
configId: z.string().nullish(),
})
}),
),
]);
@@ -256,7 +256,7 @@ const LegacyGetScoreResponseDataV1 = z.intersection(
trace: z.object({
userId: z.string().nullish(),
}),
})
}),
);
export const GetScoresResponse = z.object({
data: z.array(LegacyGetScoreResponseDataV1),
@@ -265,7 +265,7 @@ export const GetScoresResponse = z.object({
export const legacyFilterAndValidateV1GetScoreList = (
scores: unknown[],
onParseError?: (error: z.ZodError) => void
onParseError?: (error: z.ZodError) => void,
): z.infer<typeof LegacyGetScoreResponseDataV1>[] =>
scores.reduce(
(acc: z.infer<typeof LegacyGetScoreResponseDataV1>[], ts) => {
@@ -278,7 +278,7 @@ export const legacyFilterAndValidateV1GetScoreList = (
}
return acc;
},
[] as z.infer<typeof LegacyGetScoreResponseDataV1>[]
[] as z.infer<typeof LegacyGetScoreResponseDataV1>[],
);
// GET /scores/{scoreId}
+3 -4
View File
@@ -1,20 +1,19 @@
export * from "./constants";
export * from "./queries";
export * from "./interfaces/filters";
export * from "./interfaces/orderBy";
export * from "./interfaces/cloudConfigSchema";
export * from "./interfaces/parseDbOrg";
export * from "./tableDefinitions";
export * from "./types";
export * from "./filterToPrisma";
export * from "./orderByToPrisma";
export * from "./tracesTable";
export * from "./server/auth/auth";
export * from "./server/auth/apiKeys";
export * from "./observationsTable";
export * from "./utils/zod";
export * from "./utils/json";
export * from "./utils/objects";
export * from "./utils/typeChecks";
export * from "./features/entitlements/plans";
export * from "./interfaces/rate-limits";
export { env } from "./env";
// llm api
@@ -1,4 +1,5 @@
import { z } from "zod";
import { CloudConfigRateLimit } from "./rate-limits";
export const CloudConfigSchema = z.object({
plan: z.enum(["Hobby", "Pro", "Team", "Enterprise"]).optional(),
@@ -13,5 +14,9 @@ export const CloudConfigSchema = z.object({
activeProductId: z.string().optional(),
})
.optional(),
// custom rate limits for an organization
rateLimitOverrides: CloudConfigRateLimit.optional(),
});
export type CloudConfigSchema = z.infer<typeof CloudConfigSchema>;
@@ -0,0 +1,30 @@
import z from "zod";
import { ApiAccessScope } from "../server";
export const RateLimitResource = z.enum([
"ingestion",
"public-api",
"public-api-metrics",
"prompts",
]);
// result of the rate limit check.
export type RateLimitResult = {
resource: z.infer<typeof RateLimitResource>;
points: number;
scope: ApiAccessScope;
// from rate-limiter-flexible
remainingPoints: number;
msBeforeNext: number;
consumedPoints: number;
isFirstInDuration: boolean;
};
export const RateLimitConfig = z.object({
points: z.number().nullish(),
durationInSec: z.number().nullish(),
resource: RateLimitResource,
});
export const CloudConfigRateLimit = z.array(RateLimitConfig);
+35 -2
View File
@@ -18,6 +18,7 @@ export const observationsTableCols: ColumnDefinition[] = [
type: "stringOptions",
internal: 'o."name"',
options: [], // to be added at runtime
nullable: true,
},
{ name: "Trace ID", id: "traceId", type: "string", internal: 't."id"' },
{
@@ -26,8 +27,15 @@ export const observationsTableCols: ColumnDefinition[] = [
type: "stringOptions",
internal: 't."name"',
options: [], // to be added at runtime
nullable: true,
},
{
name: "User ID",
id: "userId",
type: "string",
internal: 't."user_id"',
nullable: true,
},
{ name: "User ID", id: "userId", type: "string", internal: 't."user_id"' },
{
name: "Start Time",
id: "startTime",
@@ -44,7 +52,9 @@ export const observationsTableCols: ColumnDefinition[] = [
name: "Time To First Token (s)",
id: "timeToFirstToken",
type: "number",
internal: 'o."completion_start_time" - o."start_time"',
internal:
'EXTRACT(EPOCH FROM (o."completion_start_time" - o."start_time"))',
nullable: true,
},
{
name: "Latency (s)",
@@ -57,24 +67,28 @@ export const observationsTableCols: ColumnDefinition[] = [
id: "timePerOutputToken",
type: "number",
internal: '"latency" / o."completion_tokens"',
nullable: true,
},
{
name: "Input Cost ($)",
id: "inputCost",
type: "number",
internal: 'o."calculated_input_cost"',
nullable: true,
},
{
name: "Output Cost ($)",
id: "outputCost",
type: "number",
internal: 'o."calculated_output_cost"',
nullable: true,
},
{
name: "Total Cost ($)",
id: "totalCost",
type: "number",
internal: 'o."calculated_total_cost"',
nullable: true,
},
{
name: "Level",
@@ -88,6 +102,7 @@ export const observationsTableCols: ColumnDefinition[] = [
id: "statusMessage",
type: "string",
internal: 'o."status_message"',
nullable: true,
},
{
name: "Model",
@@ -95,24 +110,28 @@ export const observationsTableCols: ColumnDefinition[] = [
type: "stringOptions",
internal: 'o."model"',
options: [], // to be added at runtime
nullable: true,
},
{
name: "Input Tokens",
id: "inputTokens",
type: "number",
internal: 'o."prompt_tokens"',
nullable: true,
},
{
name: "Output Tokens",
id: "outputTokens",
type: "number",
internal: 'o."completion_tokens"',
nullable: true,
},
{
name: "Total Tokens",
id: "totalTokens",
type: "number",
internal: 'o."total_tokens"',
nullable: true,
},
{
name: "Usage",
@@ -137,6 +156,7 @@ export const observationsTableCols: ColumnDefinition[] = [
id: "version",
type: "string",
internal: 'o."version"',
nullable: true,
},
{
name: "Prompt Name",
@@ -144,12 +164,21 @@ export const observationsTableCols: ColumnDefinition[] = [
type: "stringOptions",
internal: "p.name",
options: [], // to be added at runtime
nullable: true,
},
{
name: "Prompt Version",
id: "promptVersion",
type: "number",
internal: "p.version",
nullable: true,
},
{
name: "Trace Tags",
id: "tags",
type: "arrayOptions",
internal: "t.tags",
options: [], // to be added at runtime
},
];
@@ -161,6 +190,7 @@ export type ObservationOptions = {
traceName: Array<OptionsDefinition>;
scores_avg: Array<string>;
promptName: Array<OptionsDefinition>;
tags: Array<OptionsDefinition>;
};
export function observationsTableColsWithOptions(
@@ -182,6 +212,9 @@ export function observationsTableColsWithOptions(
if (col.id === "promptName") {
return { ...col, options: options?.promptName ?? [] };
}
if (col.id === "tags") {
return { ...col, options: options?.tags ?? [] };
}
return col;
});
}
-1
View File
@@ -1 +0,0 @@
export { getSessionTableSQL } from "./getSessionTableSQL";
@@ -0,0 +1,81 @@
import { PrismaClient } from "@prisma/client";
import { compare, hash } from "bcryptjs";
import { randomUUID } from "crypto";
import * as crypto from "crypto";
import { env } from "../../env";
export function getDisplaySecretKey(secretKey: string) {
return secretKey.slice(0, 6) + "..." + secretKey.slice(-4);
}
export async function hashSecretKey(key: string) {
// legacy, uses bcrypt, transformed into hashed key upon first use
const hashedKey = await hash(key, 11);
return hashedKey;
}
async function generateKeySet() {
return {
pk: `pk-lf-${randomUUID()}`,
sk: `sk-lf-${randomUUID()}`,
};
}
export async function verifySecretKey(key: string, hashedKey: string) {
const isValid = await compare(key, hashedKey);
return isValid;
}
export function createShaHash(privateKey: string, salt: string): string {
const hash = crypto
.createHash("sha256")
.update(privateKey)
.update(crypto.createHash("sha256").update(salt, "utf8").digest("hex"))
.digest("hex");
return hash;
}
export async function createAndAddApiKeysToDb(p: {
prisma: PrismaClient;
projectId: string;
note?: string;
predefinedKeys?: {
secretKey: string;
publicKey: string;
};
}) {
const salt = env.SALT;
if (!salt) {
throw new Error("SALT is not set");
}
const { pk, sk } = p.predefinedKeys
? { pk: p.predefinedKeys.publicKey, sk: p.predefinedKeys.secretKey }
: await generateKeySet();
const hashedSk = await hashSecretKey(sk);
const displaySk = getDisplaySecretKey(sk);
const hashFromProvidedKey = createShaHash(sk, salt);
const apiKey = await p.prisma.apiKey.create({
data: {
projectId: p.projectId,
publicKey: pk,
hashedSecretKey: hashedSk,
displaySecretKey: displaySk,
fastHashedSecretKey: hashFromProvidedKey,
note: p.note,
},
});
return {
id: apiKey.id,
createdAt: apiKey.createdAt,
note: apiKey.note,
publicKey: apiKey.publicKey,
secretKey: sk,
displaySecretKey: displaySk,
};
}
-81
View File
@@ -1,81 +0,0 @@
import { compare, hash } from "bcryptjs";
import { randomUUID } from "crypto";
import * as crypto from "crypto";
import type { OAuthConfig, OAuthUserConfig } from "next-auth/providers/oauth";
export function generateSecretKey() {
return `sk-lf-${randomUUID()}`;
}
export function generatePublicKey() {
return `pk-lf-${randomUUID()}`;
}
export function getDisplaySecretKey(secretKey: string) {
return secretKey.slice(0, 6) + "..." + secretKey.slice(-4);
}
export async function hashSecretKey(key: string) {
// legacy, uses bcrypt, transformed into hashed key upon first use
const hashedKey = await hash(key, 11);
return hashedKey;
}
export async function generateKeySet() {
const pk = generatePublicKey();
const sk = generateSecretKey();
const hashedSk = await hashSecretKey(sk);
const displaySk = getDisplaySecretKey(sk);
return {
pk,
sk,
hashedSk,
displaySk,
};
}
export async function verifySecretKey(key: string, hashedKey: string) {
const isValid = await compare(key, hashedKey);
return isValid;
}
export function createShaHash(privateKey: string, salt: string): string {
const hash = crypto
.createHash("sha256")
.update(privateKey)
.update(crypto.createHash("sha256").update(salt, "utf8").digest("hex"))
.digest("hex");
return hash;
}
export interface CustomSSOUser extends Record<string, any> {
email: string;
id: string;
name: string;
verified: boolean;
}
export function CustomSSOProvider<P extends CustomSSOUser>(
options: OAuthUserConfig<P>
): OAuthConfig<P> {
return {
id: "custom",
name: "CustomSSOProvider",
type: "oauth",
wellKnown: `${options.issuer}/.well-known/openid-configuration`,
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) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
image: null,
};
},
options,
};
}
@@ -0,0 +1,31 @@
import type { OAuthConfig, OAuthUserConfig } from "next-auth/providers/oauth";
interface CustomSSOUser extends Record<string, any> {
email: string;
id: string;
name: string;
verified: boolean;
}
export function CustomSSOProvider<P extends CustomSSOUser>(
options: OAuthUserConfig<P>
): OAuthConfig<P> {
return {
id: "custom",
name: "CustomSSOProvider",
type: "oauth",
wellKnown: `${options.issuer}/.well-known/openid-configuration`,
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) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
image: null,
};
},
options,
};
}
+30
View File
@@ -1,3 +1,30 @@
import z from "zod";
import { Plan, plans } from "../../features/entitlements/plans";
import { CloudConfigRateLimit } from "../../interfaces/rate-limits";
export const OrgEnrichedApiKey = z.object({
id: z.string(),
note: z.string().nullable(),
publicKey: z.string(),
displaySecretKey: z.string(),
createdAt: z.string().datetime().nullable(),
lastUsedAt: z.string().datetime().nullable(),
expiresAt: z.string().datetime().nullable(),
projectId: z.string(),
fastHashedSecretKey: z.string(),
hashedSecretKey: z.string(),
orgId: z.string(),
plan: z.enum(plans as unknown as [string, ...string[]]),
rateLimitOverrides: CloudConfigRateLimit.nullish(),
});
export const API_KEY_NON_EXISTENT = "api-key-non-existent";
export const CachedApiKey = z.union([
OrgEnrichedApiKey,
z.literal(API_KEY_NON_EXISTENT),
]);
export type AuthHeaderVerificationResult =
| AuthHeaderValidVerificationResult
| {
@@ -13,4 +40,7 @@ export type AuthHeaderValidVerificationResult = {
export type ApiAccessScope = {
projectId: string;
accessLevel: "all" | "scores";
orgId: string;
plan: Plan;
rateLimitOverrides: z.infer<typeof CloudConfigRateLimit>;
};
@@ -1,7 +1,9 @@
import { Prisma } from "@prisma/client";
import { ColumnDefinition, type TableNames } from "./tableDefinitions";
import { FilterState } from "./types";
import { filterOperators } from "./interfaces/filters";
import { ColumnDefinition, type TableNames } from "../tableDefinitions";
import { FilterState } from "../types";
import { filterOperators, timeFilter } from "../interfaces/filters";
import { z } from "zod";
import { logger } from "./index";
const operatorReplacements = {
"any of": "IN",
@@ -50,7 +52,7 @@ export function tableColumnsToSqlFilter(
c.name === filter.column || c.id === filter.column
);
if (!col) {
console.error("Invalid filter column", filter.column);
logger.error("Invalid filter column", filter.column);
throw new Error("Invalid filter column: " + filter.column);
}
const colPrisma = Prisma.raw(col.internal);
@@ -179,3 +181,19 @@ export const datetimeFilterToPrismaSql = (
operator
)} ${value}::timestamp with time zone at time zone 'UTC'`;
};
export const datetimeFilterToPrisma = (
timestampFilter: z.infer<typeof timeFilter>
) => {
const prismaTimestampFilter =
timestampFilter.operator === ">="
? { gte: timestampFilter.value }
: timestampFilter.operator === ">"
? { gt: timestampFilter.value }
: timestampFilter.operator === "<="
? { lte: timestampFilter.value }
: timestampFilter.operator === "<"
? { lt: timestampFilter.value }
: {};
return prismaTimestampFilter;
};
+6 -1
View File
@@ -3,7 +3,8 @@ export * from "./services/email/organizationInvitation/sendMembershipInvitationE
export * from "./services/email/batchExportSuccess/sendBatchExportSuccessEmail";
export * from "./services/email/passwordReset/sendResetPasswordVerificationRequest";
export * from "./services/PromptService";
export * from "./auth/auth";
export * from "./auth/apiKeys";
export * from "./auth/customSsoProvider";
export * from "./llm/fetchLLMCompletion";
export * from "./llm/types";
export * from "./utils/DatabaseReadStream";
@@ -24,4 +25,8 @@ export * from "./auth/types";
export * from "./ingestion/legacy/index";
export * from "./queues";
export * from "./ingestion/legacy/EventProcessor";
export * from "./orderByToPrisma";
export * from "./filterToPrisma";
export * from "./instrumentation";
export * from "./logger";
export * from "./queries";
@@ -12,18 +12,20 @@ import {
sdkLogEvent,
} from "../types";
import { validateAndInflateScore } from "../validateAndInflateScore";
import { ApiAccessScope } from "../../auth/types";
import { Trace, Observation, Score, Prisma, Model } from "@prisma/client";
import { ForbiddenError, LangfuseNotFoundError } from "../../../errors";
import { mergeJson } from "../../../utils/json";
import { jsonSchema } from "../../../utils/zod";
import { prisma } from "../../../db";
import { LegacyIngestionAccessScope } from ".";
import { logger } from "../../logger";
import { ApiAccessScope } from "../../auth/types";
export interface EventProcessor {
auth(apiScope: ApiAccessScope): void;
auth(apiScope: LegacyIngestionAccessScope): void;
process(
apiScope: ApiAccessScope
apiScope: LegacyIngestionAccessScope
): Promise<Trace | Observation | Score> | undefined;
}
@@ -46,8 +48,8 @@ export class ObservationProcessor implements EventProcessor {
}
async convertToObservation(
apiScope: ApiAccessScope,
existingObservation: Observation | null
apiScope: LegacyIngestionAccessScope,
existingObservation: Omit<Observation, "input" | "output"> | null
): Promise<{
id: string;
create: Prisma.ObservationUncheckedCreateInput;
@@ -119,7 +121,8 @@ export class ObservationProcessor implements EventProcessor {
// Token counts
const [newInputCount, newOutputCount] =
"usage" in this.event.body
? this.calculateTokenCounts(
? await this.calculateTokenCounts(
apiScope.projectId,
this.event.body,
this.calculateTokenDelegate,
internalModel ?? undefined,
@@ -187,8 +190,9 @@ export class ObservationProcessor implements EventProcessor {
: undefined;
// Only null if promptName and promptVersion are set but prompt is not found
if (prompt === null)
console.warn("Prompt not found for observation", this.event.body);
if (prompt === null) {
logger.warn("Prompt not found for observation", this.event.body);
}
const observationId = this.event.body.id ?? v4();
@@ -309,7 +313,8 @@ export class ObservationProcessor implements EventProcessor {
};
}
calculateTokenCounts(
async calculateTokenCounts(
projectId: string,
body:
| z.infer<typeof legacyObservationCreateEvent>["body"]
| z.infer<typeof generationCreateEvent>["body"],
@@ -318,29 +323,59 @@ export class ObservationProcessor implements EventProcessor {
text: unknown;
}) => number | undefined,
model?: Model,
existingObservation?: Observation
existingObservation?: Omit<Observation, "input" | "output">
) {
const newPromptTokens =
body.usage?.input ??
((body.input || existingObservation?.input) && model && model.tokenizerId
? calculateTokenDelegate({
model: model,
text: body.input ?? existingObservation?.input,
})
: undefined);
let newPromptTokens = body.usage?.input;
if (newPromptTokens === undefined && model && model.tokenizerId) {
if (body.input) {
newPromptTokens = calculateTokenDelegate({
model: model,
text: body.input,
});
} else {
logger.info(
`No input provided, trying to calculate for id: ${existingObservation?.id}`
);
const observationInput = await prisma.observation.findFirst({
where: { id: existingObservation?.id, projectId: projectId },
select: {
input: true,
},
});
const newCompletionTokens =
body.usage?.output ??
((body.output || existingObservation?.output) &&
model &&
model.tokenizerId
? calculateTokenDelegate({
model: model,
text: body.output ?? existingObservation?.output,
})
: undefined);
newPromptTokens = calculateTokenDelegate({
model: model,
text: observationInput?.input,
});
}
}
return [newPromptTokens, newCompletionTokens];
let newCompletionTokens = body.usage?.output;
if (newCompletionTokens === undefined && model && model.tokenizerId) {
if (body.output) {
newCompletionTokens = calculateTokenDelegate({
model: model,
text: body.output,
});
} else {
logger.info(
`No output provided, trying to calculate for id: ${existingObservation?.id}`
);
const observationOutput = await prisma.observation.findFirst({
where: { id: existingObservation?.id, projectId: projectId },
select: {
output: true,
},
});
newCompletionTokens = calculateTokenDelegate({
model: model,
text: observationOutput?.output,
});
}
}
return [newPromptTokens ?? undefined, newCompletionTokens ?? undefined];
}
static calculateTokenCosts(
@@ -398,17 +433,53 @@ export class ObservationProcessor implements EventProcessor {
};
}
auth(apiScope: ApiAccessScope): void {
auth(apiScope: LegacyIngestionAccessScope): void {
if (apiScope.accessLevel !== "all")
throw new ForbiddenError("Access denied for observation creation");
}
async process(apiScope: ApiAccessScope): Promise<Observation> {
async process(apiScope: LegacyIngestionAccessScope): Promise<Observation> {
this.auth(apiScope);
const existingObservation = this.event.body.id
? await prisma.observation.findFirst({
where: { id: this.event.body.id },
select: {
// do not select I/O to spare our db
input: false,
output: false,
id: true,
traceId: true,
projectId: true,
type: true,
startTime: true,
endTime: true,
name: true,
metadata: true,
parentObservationId: true,
level: true,
statusMessage: true,
version: true,
createdAt: true,
updatedAt: true,
model: true,
internalModelId: true,
modelParameters: true,
promptTokens: true,
completionTokens: true,
totalTokens: true,
unit: true,
inputCost: true,
outputCost: true,
totalCost: true,
calculatedInputCost: true,
calculatedOutputCost: true,
calculatedTotalCost: true,
completionStartTime: true,
promptId: true,
internalModel: true,
},
where: { id: this.event.body.id, projectId: apiScope.projectId },
})
: null;
@@ -441,13 +512,13 @@ export class TraceProcessor implements EventProcessor {
this.event = event;
}
auth(apiScope: ApiAccessScope): void {
auth(apiScope: LegacyIngestionAccessScope): void {
if (apiScope.accessLevel !== "all")
throw new ForbiddenError("Access denied for trace creation");
}
async process(
apiScope: ApiAccessScope
apiScope: LegacyIngestionAccessScope
): Promise<Trace | Observation | Score> {
const { body } = this.event;
@@ -455,11 +526,8 @@ export class TraceProcessor implements EventProcessor {
const internalId = body.id ?? v4();
console.log(
"Trying to create trace, project ",
apiScope.projectId,
", id:",
internalId
logger.debug(
`Trying to create trace, project ${apiScope.projectId}, id: ${internalId}`
);
const existingTrace = await prisma.trace.findFirst({
@@ -553,7 +621,7 @@ export class ScoreProcessor implements EventProcessor {
this.event = event;
}
auth(apiScope: ApiAccessScope) {
auth(apiScope: LegacyIngestionAccessScope) {
if (apiScope.accessLevel !== "scores" && apiScope.accessLevel !== "all")
throw new ForbiddenError(
`Access denied for score creation, ${apiScope.accessLevel}`
@@ -561,7 +629,7 @@ export class ScoreProcessor implements EventProcessor {
}
async process(
apiScope: ApiAccessScope
apiScope: LegacyIngestionAccessScope
): Promise<Trace | Observation | Score> {
const { body } = this.event;
@@ -613,13 +681,13 @@ export class SdkLogProcessor implements EventProcessor {
this.event = event;
}
auth(apiScope: ApiAccessScope) {
auth(apiScope: LegacyIngestionAccessScope) {
return;
}
process() {
try {
console.log("SDK Log", this.event);
logger.info("SDK Log", this.event);
return undefined;
} catch (error) {
return undefined;
@@ -8,10 +8,11 @@ import { IngestionUtils } from "../IngestionUtils";
import { IngestionEventType } from "../types";
import { redis } from "../../redis/redis";
import { env } from "../../../env";
import { logger } from "../../logger";
export async function enqueueIngestionEvents(
projectId: string,
events: IngestionEventType[]
events: IngestionEventType[],
) {
const ingestionFlushQueue = getIngestionFlushQueue();
@@ -31,8 +32,8 @@ export async function enqueueIngestionEvents(
event,
redis,
ingestionFlushQueue,
batchTimestamp
)
batchTimestamp,
),
);
}
@@ -44,11 +45,11 @@ async function enqueueSingleIngestionEvent(
event: IngestionEventType,
redis: Redis,
ingestionFlushQueue: IngestionFlushQueue,
batchTimestamp: string
batchTimestamp: string,
): Promise<void> {
if (!("id" in event.body && event.body.id)) {
console.warn(
`Received ingestion event without id: ${JSON.stringify(event)}`
logger.warn(
`Received ingestion event without id: ${JSON.stringify(event)}`,
);
return;
@@ -14,11 +14,12 @@ import {
convertTraceUpsertEventsToRedisEvents,
getTraceUpsertQueue,
} from "../../redis/trace-upsert";
import { ApiAccessScope, AuthHeaderVerificationResult } from "../../auth/types";
import { ApiAccessScope } from "../../auth/types";
import { redis } from "../../redis/redis";
import { backOff } from "exponential-backoff";
import { Model } from "../../..";
import { enqueueIngestionEvents } from "./enqueueIngestionEvents";
import { logger } from "../../logger";
export type BatchResult = {
result: unknown;
@@ -31,12 +32,27 @@ type TokenCountInput = {
text: unknown;
};
export type LegacyIngestionAccessScope = Omit<
ApiAccessScope,
"orgId" | "plan" | "rateLimitOverrides"
>;
type LegacyIngestionAuthHeaderVerificationResult =
| {
validKey: true;
scope: LegacyIngestionAccessScope;
}
| {
validKey: false;
error: string;
};
export const handleBatch = async (
events: z.infer<typeof ingestionApiSchema>["batch"],
authCheck: AuthHeaderVerificationResult,
calculateTokenDelegate: (p: TokenCountInput) => number | undefined
authCheck: LegacyIngestionAuthHeaderVerificationResult,
calculateTokenDelegate: (p: TokenCountInput) => number | undefined,
) => {
console.log(`handling ingestion ${events.length} events`);
logger.debug(`handling ingestion ${events.length} events`);
if (!authCheck.validKey) throw new UnauthorizedError(authCheck.error);
@@ -54,7 +70,7 @@ export const handleBatch = async (
return await handleSingleEvent(
singleEvent,
authCheck.scope,
calculateTokenDelegate
calculateTokenDelegate,
);
});
results.push({
@@ -64,7 +80,7 @@ export const handleBatch = async (
}); // Push each result into the array
} catch (error) {
// Handle or log the error if `handleSingleEvent` fails
console.error("Error handling event:", error);
logger.error("Error handling event:", error);
// Decide how to handle the error: rethrow, continue, or push an error object to results
// For example, push an error object:
errors.push({
@@ -78,9 +94,9 @@ export const handleBatch = async (
if (env.CLICKHOUSE_URL) {
try {
await enqueueIngestionEvents(authCheck.scope.projectId, events);
console.log(`Added ${events.length} ingestion events to queue`);
logger.info(`Added ${events.length} ingestion events to queue`);
} catch (err) {
console.error("Error adding ingestion events to queue", err);
logger.error("Error adding ingestion events to queue", err);
}
}
@@ -92,10 +108,10 @@ async function retry<T>(request: () => Promise<T>): Promise<T> {
numOfAttempts: env.LANGFUSE_ASYNC_INGESTION_PROCESSING === "true" ? 5 : 3,
retry: (e: Error, attemptNumber: number) => {
if (e instanceof UnauthorizedError || e instanceof ForbiddenError) {
console.log("not retrying auth error");
logger.info("not retrying auth error");
return false;
}
console.log(`retrying processing events ${attemptNumber}`);
logger.info(`retrying processing events ${attemptNumber}`);
return true;
},
});
@@ -103,11 +119,11 @@ async function retry<T>(request: () => Promise<T>): Promise<T> {
const handleSingleEvent = async (
event: z.infer<typeof ingestionEvent>,
apiScope: ApiAccessScope,
apiScope: LegacyIngestionAccessScope,
calculateTokenDelegate: (p: {
model: Model;
text: unknown;
}) => number | undefined
}) => number | undefined,
) => {
const { body } = event;
let restEvent = body;
@@ -122,8 +138,8 @@ const handleSingleEvent = async (
restEvent = rest;
}
console.log(
`handling single event ${event.id} of type ${event.type}: ${JSON.stringify({ body: restEvent })}`
logger.info(
`handling single event ${event.id} of type ${event.type}: ${JSON.stringify({ body: restEvent })}`,
);
const cleanedEvent = ingestionEvent.parse(cleanEvent(event));
@@ -144,7 +160,7 @@ const handleSingleEvent = async (
case eventTypes.GENERATION_UPDATE:
processor = new ObservationProcessor(
cleanedEvent,
calculateTokenDelegate
calculateTokenDelegate,
);
break;
case eventTypes.SCORE_CREATE: {
@@ -186,7 +202,7 @@ export function cleanEvent(obj: unknown): unknown {
}
export const isNotNullOrUndefined = <T>(
val?: T | null
val?: T | null,
): val is Exclude<T, null | undefined> => !isUndefinedOrNull(val);
export const isUndefinedOrNull = <T>(val?: T | null): val is undefined | null =>
@@ -194,7 +210,7 @@ export const isUndefinedOrNull = <T>(val?: T | null): val is undefined | null =>
export const sendToWorkerIfEnvironmentConfigured = async (
batchResults: BatchResult[],
projectId: string
projectId: string,
): Promise<void> => {
const traceEvents: TraceUpsertEventType[] = batchResults
.filter((result) => result.type === eventTypes.TRACE_CREATE) // we only have create, no update.
@@ -204,17 +220,17 @@ export const sendToWorkerIfEnvironmentConfigured = async (
"id" in result.result
? // ingestion API only gets traces for one projectId
{ traceId: result.result.id as string, projectId }
: null
: null,
)
.filter(isNotNullOrUndefined);
try {
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION && redis) {
console.log(`Sending ${traceEvents.length} events to worker via Redis`);
logger.info(`Sending ${traceEvents.length} events to worker via Redis`);
const queue = getTraceUpsertQueue();
if (!queue) {
console.error("TraceUpsertQueue not initialized");
logger.error("TraceUpsertQueue not initialized");
return;
}
@@ -224,7 +240,7 @@ export const sendToWorkerIfEnvironmentConfigured = async (
env.LANGFUSE_WORKER_PASSWORD &&
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
) {
console.log(`Sending ${traceEvents.length} events to worker via HTTP`);
logger.info(`Sending ${traceEvents.length} events to worker via HTTP`);
const body: EventBodyType = {
name: EventName.TraceUpsert,
payload: traceEvents,
@@ -238,7 +254,7 @@ export const sendToWorkerIfEnvironmentConfigured = async (
Authorization:
"Basic " +
Buffer.from(
"admin" + ":" + env.LANGFUSE_WORKER_PASSWORD
"admin" + ":" + env.LANGFUSE_WORKER_PASSWORD,
).toString("base64"),
},
body: JSON.stringify(body),
@@ -247,6 +263,6 @@ export const sendToWorkerIfEnvironmentConfigured = async (
}
}
} catch (error) {
console.error("Error sending events to worker", error);
logger.error("Error sending events to worker", error);
}
};
@@ -8,7 +8,7 @@ export async function findModel(p: {
unit?: string;
startTime?: Date;
};
existingDbObservation?: Observation;
existingDbObservation?: Omit<Observation, "input" | "output">;
}): Promise<Model | null> {
const { event, existingDbObservation } = p;
// either get the model from the existing observation
@@ -15,7 +15,7 @@ type ValidateAndInflateScoreParams = {
};
export async function validateAndInflateScore(
params: ValidateAndInflateScoreParams
params: ValidateAndInflateScoreParams,
): Promise<Score> {
const { body, projectId } = params;
@@ -29,7 +29,7 @@ export async function validateAndInflateScore(
if (!config || !validateDbScoreConfigSafe(config).success)
throw new LangfuseNotFoundError(
"The configId you provided does not match a valid config in this project"
"The configId you provided does not match a valid config in this project",
);
validateConfigAgainstBody(body, config as ValidatedScoreConfig);
@@ -47,7 +47,7 @@ export async function validateAndInflateScore(
if (!validation.success) {
throw new InvalidRequestError(
`Ingested score value type not valid against provided data type. Provide numeric values for numeric and boolean scores, and string values for categorical scores.`
`Ingested score value type not valid against provided data type. Provide numeric values for numeric and boolean scores, and string values for categorical scores.`,
);
}
@@ -62,7 +62,7 @@ function inferDataType(value: string | number): ScoreDataType {
function mapStringValueToNumericValue(
config: ValidatedScoreConfig,
label: string
label: string,
): number | null {
return (
config.categories?.find((category) => category.label === label)?.value ??
@@ -71,7 +71,7 @@ function mapStringValueToNumericValue(
}
function inflateScoreBody(
params: ValidateAndInflateScoreParams & { config?: ValidatedScoreConfig }
params: ValidateAndInflateScoreParams & { config?: ValidatedScoreConfig },
): Score {
const { body, projectId, scoreId, config } = params;
@@ -105,25 +105,25 @@ function inflateScoreBody(
function validateConfigAgainstBody(
body: any,
config: ValidatedScoreConfig
config: ValidatedScoreConfig,
): void {
const { maxValue, minValue, categories, dataType: configDataType } = config;
if (body.dataType && body.dataType !== configDataType) {
throw new InvalidRequestError(
`Data type mismatch based on config: expected ${configDataType}, got ${body.dataType}`
`Data type mismatch based on config: expected ${configDataType}, got ${body.dataType}`,
);
}
if (config.isArchived) {
throw new InvalidRequestError(
"Config is archived and cannot be used to create new scores. Please restore the config first."
"Config is archived and cannot be used to create new scores. Please restore the config first.",
);
}
if (config.name !== body.name) {
throw new InvalidRequestError(
`Name mismatch based on config: expected ${config.name}, got ${body.name}`
`Name mismatch based on config: expected ${config.name}, got ${body.name}`,
);
}
@@ -136,7 +136,7 @@ function validateConfigAgainstBody(
if (!dataTypeValidation.success) {
throw new InvalidRequestError(
`Ingested score body not valid against provided config data type.`
`Ingested score body not valid against provided config data type.`,
);
}
@@ -154,7 +154,7 @@ function validateConfigAgainstBody(
.join(", ");
throw new InvalidRequestError(
`Ingested score body not valid against provided config: ${errorDetails}`
`Ingested score body not valid against provided config: ${errorDetails}`,
);
}
}
@@ -3,61 +3,105 @@ import * as dd from "dd-trace";
// type CallbackFn<T> = () => T;
export type TCarrier = {
traceparent?: string;
tracestate?: string;
};
export type SpanCtx = {
name: string;
spanKind?: opentelemetry.SpanKind; // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/api.md#spankind
rootSpan?: boolean; // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/overview.md#traces
traceScope?: string;
traceContext?: TCarrier;
};
type CallbackFn<T> = () => T | Promise<T>;
type AsyncCallbackFn<T> = (span: opentelemetry.Span) => Promise<T>;
export function instrument<T>(
export async function instrumentAsync<T>(
ctx: SpanCtx,
callback: CallbackFn<T>
): T extends Promise<any> ? Promise<T> : T {
callback: AsyncCallbackFn<T>,
): Promise<T> {
const activeContext = ctx.traceContext
? opentelemetry.propagation.extract(
opentelemetry.context.active(),
ctx.traceContext,
)
: opentelemetry.context.active();
return getTracer(ctx.traceScope ?? callback.name).startActiveSpan(
ctx.name,
{
root: ctx.rootSpan,
root: !Boolean(ctx.traceContext) && ctx.rootSpan,
kind: ctx.spanKind,
},
(span) => {
const handleResult = (result: T) => {
activeContext,
async (span) => {
try {
const result = await callback(span);
span.end();
return result;
};
const handleError = (ex: unknown) => {
} catch (ex) {
traceException(ex as opentelemetry.Exception, span);
span.end();
throw ex;
};
try {
const result = callback();
if (result instanceof Promise) {
return result
.then(handleResult)
.catch(handleError) as T extends Promise<any> ? Promise<T> : T;
} else {
return handleResult(result) as T extends Promise<any>
? Promise<T>
: T;
}
} catch (ex) {
return handleError(ex) as T extends Promise<any> ? Promise<T> : T;
}
}
},
);
}
type SyncCallbackFn<T> = (span: opentelemetry.Span) => T;
export function instrumentSync<T>(
ctx: SpanCtx,
callback: SyncCallbackFn<T>,
): T {
const activeContext = ctx.traceContext
? opentelemetry.propagation.extract(
opentelemetry.context.active(),
ctx.traceContext,
)
: opentelemetry.context.active();
return getTracer(ctx.traceScope ?? callback.name).startActiveSpan(
ctx.name,
{
root: !Boolean(ctx.traceContext) && ctx.rootSpan,
kind: ctx.spanKind,
},
activeContext,
(span) => {
try {
const result = callback(span);
span.end();
return result;
} catch (ex) {
traceException(ex as opentelemetry.Exception, span);
span.end();
throw ex;
}
},
);
}
export const getCurrentSpan = () => opentelemetry.trace.getActiveSpan();
export const addTraceContext = <T extends Record<string, any>>(
input: T,
): T & { _tracecontext?: TCarrier } => {
const context = {};
opentelemetry.propagation.inject(opentelemetry.context.active(), context);
return {
...input,
_tracecontext: context,
};
};
export const traceException = (
ex: unknown,
span?: opentelemetry.Span,
code?: string
code?: string,
) => {
const activeSpan = span ?? getCurrentSpan();
@@ -67,9 +111,24 @@ export const traceException = (
const exception = {
code: code,
message: ex instanceof Error ? ex.message : String(ex),
name: ex instanceof Error ? ex.name : "Error",
stack: ex instanceof Error ? ex.stack : undefined,
message:
ex instanceof Error
? ex.message
: typeof ex === "object" && ex !== null && "message" in ex
? JSON.stringify(ex.message)
: JSON.stringify(ex),
name:
ex instanceof Error
? ex.name
: typeof ex === "object" && ex !== null && "name" in ex
? JSON.stringify(ex.name)
: "Error",
stack:
ex instanceof Error
? JSON.stringify(ex.stack)
: typeof ex === "object" && ex !== null && "stack" in ex
? JSON.stringify(ex.stack)
: undefined,
};
// adds an otel event
@@ -90,7 +149,7 @@ export const traceException = (
export const addUserToSpan = (
attributes: { userId?: string; projectId?: string; email?: string },
span?: opentelemetry.Span
span?: opentelemetry.Span,
) => {
const activeSpan = span ?? getCurrentSpan();
@@ -113,7 +172,7 @@ export const recordGauge = (
| {
[tag: string]: string | number;
}
| undefined
| undefined,
) => {
dd.dogstatsd.gauge(stat, value, tags);
};
@@ -121,7 +180,7 @@ export const recordGauge = (
export const recordIncrement = (
stat: string,
value?: number | undefined,
tags?: { [tag: string]: string | number } | undefined
tags?: { [tag: string]: string | number } | undefined,
) => {
dd.dogstatsd.increment(stat, value, tags);
};
@@ -129,7 +188,7 @@ export const recordIncrement = (
export const recordHistogram = (
stat: string,
value?: number | undefined,
tags?: { [tag: string]: string | number } | undefined
tags?: { [tag: string]: string | number } | undefined,
) => {
dd.dogstatsd.histogram(stat, value, tags);
};
@@ -135,6 +135,37 @@ export async function fetchLLMCompletion(
return await functionCallingModel.pipe(outputParser).invoke(finalMessages);
}
/*
Workaround OpenAI o1 while in beta:
This is a temporary workaround to avoid sending system messages to OpenAI's O1 models.
O1 models do not support in beta:
- system messages
- top_p
- max_tokens at all, one has to use max_completion_tokens instead
- temperature different than 1
Reference: https://platform.openai.com/docs/guides/reasoning/beta-limitations
*/
if (modelParams.model.startsWith("o1-")) {
return await new ChatOpenAI({
openAIApiKey: apiKey,
modelName: modelParams.model,
temperature: 1,
maxTokens: undefined,
topP: undefined,
callbacks,
maxRetries,
configuration: {
baseURL,
},
})
.pipe(new StringOutputParser())
.invoke(
finalMessages.filter((message) => message._getType() !== "system")
);
}
if (streaming) {
return chatModel.pipe(new BytesOutputParser()).stream(finalMessages);
}
+7 -1
View File
@@ -4,7 +4,7 @@ import z from "zod";
export type PromptVariable = { name: string; value: string; isUsed: boolean };
export type ChatMessage = {
role: ChatMessageRole;
role: ChatMessageRole | string; // Users may ingest any string as role via API/SDK
content: string;
};
@@ -22,6 +22,8 @@ export enum ChatMessageRole {
Assistant = "assistant",
}
export const ChatMessageDefaultRoleSchema = z.nativeEnum(ChatMessageRole);
export type ModelParams = {
provider: string;
adapter: LLMAdapter;
@@ -52,6 +54,10 @@ export const openAIModels = [
"gpt-4o-2024-05-13",
"gpt-4o-mini",
"gpt-4o-mini-2024-07-18",
"o1-preview",
"o1-preview-2024-09-12",
"o1-mini",
"o1-mini-2024-09-12",
"gpt-4-turbo-preview",
"gpt-4-1106-preview",
"gpt-4-0613",
+58
View File
@@ -0,0 +1,58 @@
import { env } from "../env";
import winston from "winston";
import Transport from "winston-transport";
import { getCurrentSpan } from "./instrumentation";
class TracedTransport extends Transport {
constructor(opts: Transport.TransportStreamOptions = {}) {
super(opts);
}
log(info: Record<string, any>, callback: () => void): void {
setImmediate(() => {
this.emit("logged", info);
});
const currentSpan = getCurrentSpan();
info.trace_id = currentSpan?.spanContext().traceId;
info.span_id = currentSpan?.spanContext().spanId;
console.log(JSON.stringify(info));
callback();
}
}
const getWinstonLogger = (
nodeEnv: "development" | "production" | "test",
minLevel = "info",
) => {
const textLoggerFormat = winston.format.combine(
winston.format.errors({ stack: true }),
winston.format.timestamp(),
winston.format.align(),
winston.format.printf((info) => {
const logMessage = `${info.timestamp} ${info.level} ${info.message}`;
return info.stack ? `${logMessage}\n${info.stack}` : logMessage;
}),
);
const jsonLoggerFormat = winston.format.combine(
winston.format.errors({ stack: true }),
winston.format.timestamp(),
winston.format.json(),
);
const format =
env.LANGFUSE_LOG_FORMAT === "text" ? textLoggerFormat : jsonLoggerFormat;
const transport =
env.LANGFUSE_LOG_FORMAT === "text"
? new winston.transports.Console()
: new TracedTransport();
return winston.createLogger({
level: minLevel,
format: format,
transports: [transport],
});
};
export const logger = getWinstonLogger(env.NODE_ENV, env.LANGFUSE_LOG_LEVEL);
@@ -2,8 +2,9 @@ import { z } from "zod";
import { Prisma } from "@prisma/client";
import type { ColumnDefinition } from "./tableDefinitions/types";
import type { OrderByState } from "./interfaces/orderBy";
import type { ColumnDefinition } from "../tableDefinitions/types";
import type { OrderByState } from "../interfaces/orderBy";
import { logger } from "./logger";
/**
* Convert orderBy to SQL ORDER BY clause
@@ -13,20 +14,20 @@ import type { OrderByState } from "./interfaces/orderBy";
*/
export function orderByToPrismaSql(
orderBy: OrderByState,
tableColumns: ColumnDefinition[]
tableColumns: ColumnDefinition[],
): Prisma.Sql {
if (!orderBy) {
return Prisma.sql`ORDER BY t.timestamp DESC NULLS LAST`;
return Prisma.sql`ORDER BY t.timestamp DESC`;
}
// Get column definition to map column to internal name, e.g. "t.id"
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) {
console.log("Invalid filter column", orderBy.column);
logger.warn("Invalid filter column", orderBy.column);
throw new Error("Invalid filter column: " + orderBy.column);
}
@@ -34,12 +35,12 @@ export function orderByToPrismaSql(
const orderByOrder = z.enum(["ASC", "DESC"]);
const order = orderByOrder.safeParse(orderBy.order);
if (!order.success) {
console.log("Invalid order", orderBy.order);
logger.warn("Invalid order", orderBy.order);
throw new Error("Invalid order: " + orderBy.order);
}
// 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} ${col.nullable ? (orderBy.order === "DESC" ? "NULLS LAST" : "NULLS FIRST") : ""}`,
);
}
@@ -2,10 +2,10 @@ import { z } from "zod";
import { Prisma } from "@prisma/client";
import { tableColumnsToSqlFilterAndPrefix } from "../filterToPrisma";
import { singleFilter } from "../interfaces/filters";
import { orderBy } from "../interfaces/orderBy";
import { singleFilter } from "../../interfaces/filters";
import { orderBy } from "../../interfaces/orderBy";
import { orderByToPrismaSql } from "../orderByToPrisma";
import { sessionsViewCols } from "../tableDefinitions/index";
import { sessionsViewCols } from "../../tableDefinitions";
const GetSessionTableSQLParamsSchema = z.object({
projectId: z.string(),
@@ -16,8 +16,13 @@ const GetSessionTableSQLParamsSchema = z.object({
});
type GetSessionTableSQLParams = z.infer<typeof GetSessionTableSQLParamsSchema>;
export const getSessionTableSQL = (
params: GetSessionTableSQLParams
export const createSessionsAllQuery = (
select: Prisma.Sql,
params: GetSessionTableSQLParams,
options?: {
ignoreOrderBy?: boolean; // used by session.metrics and session.all.totalCount
sessionIdList?: string[]; // used by session.metrics
},
): Prisma.Sql => {
const { projectId, filter, orderBy, page, limit } =
GetSessionTableSQLParamsSchema.parse(params);
@@ -25,26 +30,17 @@ export const getSessionTableSQL = (
const filterCondition = tableColumnsToSqlFilterAndPrefix(
filter ?? [],
sessionsViewCols,
"sessions"
"sessions",
);
const orderByCondition = orderByToPrismaSql(orderBy, sessionsViewCols);
const sessionIdFilter = options?.sessionIdList
? Prisma.sql`AND s.id IN (${Prisma.join(options?.sessionIdList)})`
: Prisma.sql``;
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"
${select}
FROM
trace_sessions AS s
LEFT JOIN LATERAL (
@@ -54,9 +50,12 @@ export const getSessionTableSQL = (
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"
count(t.id)::int AS "countTraces",
array_agg(DISTINCT u.tag) AS "tags"
FROM
traces t
LEFT JOIN LATERAL (
SELECT DISTINCT UNNEST(t.tags) AS tag) AS u ON TRUE
WHERE
t.project_id = ${projectId}
AND t.session_id = s.id
@@ -79,7 +78,8 @@ export const getSessionTableSQL = (
WHERE
s. "project_id" = ${projectId}
${filterCondition}
${orderByCondition}
${sessionIdFilter}
${options?.ignoreOrderBy ? Prisma.sql`` : orderByCondition}
LIMIT ${limit}
OFFSET ${page * limit}
`;
@@ -0,0 +1,89 @@
import { Prisma } from "@prisma/client";
export function createTracesQuery({
select,
projectId,
observationTimeseriesFilter = Prisma.empty,
page,
limit,
searchCondition = Prisma.empty,
filterCondition = Prisma.empty,
orderByCondition = Prisma.empty,
selectScoreValues = false,
}: {
select: Prisma.Sql;
projectId: string;
observationTimeseriesFilter?: Prisma.Sql;
page?: number;
limit?: number;
searchCondition?: Prisma.Sql;
filterCondition?: Prisma.Sql;
orderByCondition?: Prisma.Sql;
selectScoreValues?: boolean;
}) {
return Prisma.sql`
SELECT
${select}
FROM
"traces" AS t
LEFT JOIN LATERAL (
SELECT
SUM(prompt_tokens) AS "promptTokens",
SUM(completion_tokens) AS "completionTokens",
SUM(total_tokens) AS "totalTokens",
SUM(calculated_total_cost) AS "calculatedTotalCost",
SUM(calculated_input_cost) AS "calculatedInputCost",
SUM(calculated_output_cost) AS "calculatedOutputCost",
COALESCE(
MAX(CASE WHEN level = 'ERROR' THEN 'ERROR' END),
MAX(CASE WHEN level = 'WARNING' THEN 'WARNING' END),
MAX(CASE WHEN level = 'DEFAULT' THEN 'DEFAULT' END),
'DEBUG'
) AS "level"
FROM
"observations_view"
WHERE
trace_id = t.id
AND "type" = 'GENERATION'
AND "project_id" = ${projectId}
${observationTimeseriesFilter}
) AS tm ON true
LEFT JOIN LATERAL (
SELECT
COUNT(*) AS "observationCount",
EXTRACT(EPOCH FROM COALESCE(MAX("end_time"), MAX("start_time"))) - EXTRACT(EPOCH FROM MIN("start_time"))::double precision AS "latency"
FROM
"observations"
WHERE
trace_id = t.id
AND "project_id" = ${projectId}
${observationTimeseriesFilter}
) AS tl ON true
LEFT JOIN LATERAL (
SELECT
${selectScoreValues ? Prisma.sql`jsonb_object_agg(name::text, "values") AS "scores_values",` : Prisma.empty}
jsonb_object_agg(name::text, avg_value::double precision) AS "scores_avg"
FROM (
SELECT
name,
${selectScoreValues ? Prisma.sql`array_agg(COALESCE(string_value, value::text)) AS "values",` : Prisma.empty}
AVG(value) avg_value
FROM
scores
WHERE
trace_id = t.id
AND t."project_id" = ${projectId}
${selectScoreValues ? Prisma.empty : Prisma.sql`AND scores."data_type" IN ('NUMERIC', 'BOOLEAN')`}
GROUP BY
name
) tmp
) AS s_avg ON true
WHERE
t."project_id" = ${projectId}
${searchCondition}
${filterCondition}
${orderByCondition}
${limit ? Prisma.sql`LIMIT ${limit}` : Prisma.empty}
${page && limit ? Prisma.sql`OFFSET ${page * limit}` : Prisma.empty}
`;
}
@@ -0,0 +1,2 @@
export { createSessionsAllQuery } from "./createSessionsAllQuery";
export { createTracesQuery } from "./createTracesQuery";
+5 -1
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { ingestionBatchEvent } from ".";
import { ingestionBatchEvent, TCarrier } from ".";
export enum EventName {
TraceUpsert = "TraceUpsert",
@@ -80,23 +80,27 @@ export type TQueueJobTypes = {
id: string;
payload: TraceUpsertEventType;
name: QueueJobs.TraceUpsert;
_tracecontext?: TCarrier;
};
[QueueName.EvaluationExecution]: {
timestamp: Date;
id: string;
payload: EvalExecutionEventType;
name: QueueJobs.EvaluationExecution;
_tracecontext?: TCarrier;
};
[QueueName.BatchExport]: {
timestamp: Date;
id: string;
payload: BatchExportJobType;
name: QueueJobs.BatchExportJob;
_tracecontext?: TCarrier;
};
[QueueName.LegacyIngestionQueue]: {
timestamp: Date;
id: string;
payload: LegacyIngestionEventType;
name: QueueJobs.LegacyIngestionJob;
_tracecontext?: TCarrier;
};
};
@@ -1,6 +1,6 @@
import { Queue } from "bullmq";
import { QueueName, TQueueJobTypes } from "../queues";
import { redis } from "./redis";
import { createNewRedisInstance } from "./redis";
let batchExportQueue: Queue<TQueueJobTypes[QueueName.BatchExport]> | null =
null;
@@ -8,9 +8,11 @@ let batchExportQueue: Queue<TQueueJobTypes[QueueName.BatchExport]> | null =
export const getBatchExportQueue = () => {
if (batchExportQueue) return batchExportQueue;
batchExportQueue = redis
const connection = createNewRedisInstance();
batchExportQueue = connection
? new Queue<TQueueJobTypes[QueueName.BatchExport]>(QueueName.BatchExport, {
connection: redis,
connection: connection,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
@@ -1,7 +1,7 @@
import { Queue } from "bullmq";
import { env } from "../../env";
import { redis } from "../redis/redis";
import { createNewRedisInstance } from "../redis/redis";
import { QueueName } from "../queues";
export type IngestionFlushQueue = Queue<null>;
@@ -11,9 +11,11 @@ let ingestionFlushQueue: IngestionFlushQueue | null = null;
export const getIngestionFlushQueue = () => {
if (ingestionFlushQueue) return ingestionFlushQueue;
ingestionFlushQueue = redis
const connection = createNewRedisInstance();
ingestionFlushQueue = connection
? new Queue<null>(QueueName.IngestionFlushQueue, {
connection: redis,
connection: connection,
defaultJobOptions: {
removeOnComplete: true, // Important: If not true, new jobs for that ID would be ignored as jobs in the complete set are still considered as part of the queue
removeOnFail: 1_000,
@@ -1,27 +1,33 @@
import { Queue } from "bullmq";
import { QueueName, TQueueJobTypes } from "../queues";
import { redis } from "./redis";
import { createNewRedisInstance } from "./redis";
let legacyIngestionQueue: Queue<
TQueueJobTypes[QueueName.LegacyIngestionQueue]
> | null = null;
export class LegacyIngestionQueue {
private static instance: Queue<
TQueueJobTypes[QueueName.LegacyIngestionQueue]
> | null = null;
export const getLegacyIngestionQueue = () => {
if (legacyIngestionQueue) return legacyIngestionQueue;
public static getInstance(): Queue<
TQueueJobTypes[QueueName.LegacyIngestionQueue]
> | null {
if (LegacyIngestionQueue.instance) return LegacyIngestionQueue.instance;
legacyIngestionQueue = redis
? new Queue<TQueueJobTypes[QueueName.LegacyIngestionQueue]>(
QueueName.LegacyIngestionQueue,
{
connection: redis,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
attempts: 5,
const newRedis = createNewRedisInstance({ enableOfflineQueue: false });
LegacyIngestionQueue.instance = newRedis
? new Queue<TQueueJobTypes[QueueName.LegacyIngestionQueue]>(
QueueName.LegacyIngestionQueue,
{
connection: newRedis,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
attempts: 5,
},
},
}
)
: null;
)
: null;
return legacyIngestionQueue;
};
return LegacyIngestionQueue.instance;
}
}
+25 -12
View File
@@ -1,20 +1,33 @@
import Redis from "ioredis";
import Redis, { RedisOptions } from "ioredis";
import { env } from "../../env";
import { logger } from "../logger";
export const createNewRedisInstance = (
additionalOptions: Partial<RedisOptions> = {},
) => {
return env.REDIS_CONNECTION_STRING
? new Redis(env.REDIS_CONNECTION_STRING, {
maxRetriesPerRequest: null,
enableAutoPipelining: env.REDIS_ENABLE_AUTO_PIPELINING === "true",
...additionalOptions,
})
: env.REDIS_HOST
? new Redis({
host: String(env.REDIS_HOST),
port: Number(env.REDIS_PORT),
password: String(env.REDIS_AUTH),
maxRetriesPerRequest: null, // Set to `null` to disable retrying
enableAutoPipelining: env.REDIS_ENABLE_AUTO_PIPELINING === "true",
...additionalOptions,
})
: null;
};
const createRedisClient = () => {
try {
return env.REDIS_CONNECTION_STRING
? new Redis(env.REDIS_CONNECTION_STRING, { maxRetriesPerRequest: null })
: env.REDIS_HOST
? new Redis({
host: String(env.REDIS_HOST),
port: Number(env.REDIS_PORT),
password: String(env.REDIS_AUTH),
maxRetriesPerRequest: null, // Set to `null` to disable retrying
})
: null;
return createNewRedisInstance();
} catch (e) {
console.error(e, "Failed to connect to redis");
logger.error("Failed to connect to redis", e);
return null;
}
};
@@ -6,7 +6,7 @@ import {
TraceUpsertEventType,
} from "../queues";
import { Queue } from "bullmq";
import { redis } from "./redis";
import { createNewRedisInstance } from "./redis";
let traceUpsertQueue: Queue<TQueueJobTypes[QueueName.TraceUpsert]> | null =
null;
@@ -14,9 +14,11 @@ let traceUpsertQueue: Queue<TQueueJobTypes[QueueName.TraceUpsert]> | null =
export const getTraceUpsertQueue = () => {
if (traceUpsertQueue) return traceUpsertQueue;
traceUpsertQueue = redis
const connection = createNewRedisInstance();
traceUpsertQueue = connection
? new Queue<TQueueJobTypes[QueueName.TraceUpsert]>(QueueName.TraceUpsert, {
connection: redis,
connection: connection,
defaultJobOptions: {
removeOnComplete: 100, // Important: If not true, new jobs for that ID would be ignored as jobs in the complete set are still considered as part of the queue
removeOnFail: 1_000,
@@ -1,6 +1,7 @@
import { Prompt, PrismaClient } from "@prisma/client";
import { Redis } from "ioredis";
import { env } from "../../env";
import { logger } from "../logger";
export class PromptService {
private cacheEnabled: boolean;
@@ -11,7 +12,7 @@ export class PromptService {
private redis: Redis | null,
private metricIncrementer?: // used for otel metrics
(name: string, value?: number) => void,
cacheEnabled?: boolean // used for testing
cacheEnabled?: boolean, // used for testing
) {
this.cacheEnabled =
Boolean(redis) &&
@@ -25,7 +26,7 @@ export class PromptService {
const cachedPrompt = await this.getCachedPrompt(params);
this.incrementMetric(
cachedPrompt ? Metrics.PromptCacheHit : Metrics.PromptCacheMiss
cachedPrompt ? Metrics.PromptCacheHit : Metrics.PromptCacheMiss,
);
if (cachedPrompt) {
@@ -117,7 +118,7 @@ export class PromptService {
}
public async lockCache(
params: Pick<PromptParams, "projectId" | "promptName">
params: Pick<PromptParams, "projectId" | "promptName">,
): Promise<void> {
if (!this.cacheEnabled) return;
@@ -133,7 +134,7 @@ export class PromptService {
}
public async unlockCache(
params: Pick<PromptParams, "projectId" | "promptName">
params: Pick<PromptParams, "projectId" | "promptName">,
): Promise<void> {
if (!this.cacheEnabled) return;
@@ -149,7 +150,7 @@ export class PromptService {
}
private async isCacheLocked(
params: Pick<PromptParams, "projectId" | "promptName">
params: Pick<PromptParams, "projectId" | "promptName">,
): Promise<boolean> {
const lockKey = this.getLockKey(params);
@@ -163,14 +164,14 @@ export class PromptService {
}
private getLockKey(
params: Pick<PromptParams, "projectId" | "promptName">
params: Pick<PromptParams, "projectId" | "promptName">,
): string {
// Important to *pre*fix LOCK as otherwise it would be deleted by deleteKeysByPrefix
return `LOCK:${this.getCacheKeyPrefix(params)}`;
}
public async invalidateCache(
params: Pick<PromptParams, "projectId" | "promptName">
params: Pick<PromptParams, "projectId" | "promptName">,
): Promise<void> {
if (!this.cacheEnabled) return;
@@ -187,7 +188,7 @@ export class PromptService {
await this.redis?.del([...(keys ?? []), keyIndexKey]);
this.logInfo(
`Cache invalidated for prefix ${cacheKeyPrefix} in ${Date.now() - startTime}ms`
`Cache invalidated for prefix ${cacheKeyPrefix} in ${Date.now() - startTime}ms`,
);
} catch (e) {
this.logError("Error deleting keys for prefix", cacheKeyPrefix, e);
@@ -203,23 +204,23 @@ export class PromptService {
}
private getCacheKeyPrefix(
params: Pick<PromptParams, "projectId" | "promptName">
params: Pick<PromptParams, "projectId" | "promptName">,
): string {
return `prompt:${params.projectId}:${params.promptName}`;
}
private getKeyIndexKey(
params: Pick<PromptParams, "projectId" | "promptName">
params: Pick<PromptParams, "projectId" | "promptName">,
): string {
return `prompt_key_index:${params.projectId}:${params.promptName}`;
}
private logError(message: string, ...args: any[]) {
console.error(`[PromptService] ${message}`, ...args);
logger.error(`[PromptService] ${message}`, ...args);
}
private logInfo(message: string, ...args: any[]) {
console.log(`[PromptService] ${message}`, ...args);
logger.info(`[PromptService] ${message}`, ...args);
}
private incrementMetric(name: Metrics, value: number = 1) {
@@ -2,6 +2,7 @@ import type { Readable } from "stream";
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { logger } from "../logger";
type UploadFile = {
fileName: string;
@@ -56,15 +57,14 @@ export class S3StorageService {
return { signedUrl };
} catch (err) {
console.error(err);
logger.error(err);
throw new Error("Failed to upload to S3 or generate signed URL");
}
}
private async getSignedUrl(
fileName: string,
ttlSeconds: number
ttlSeconds: number,
): Promise<string> {
try {
return await getSignedUrl(
@@ -74,7 +74,7 @@ export class S3StorageService {
Key: fileName,
ResponseContentDisposition: `attachment; filename="${fileName}"`,
}),
{ expiresIn: ttlSeconds }
{ expiresIn: ttlSeconds },
);
} catch (err) {
throw Error("Failed to generate signed URL");
@@ -59,6 +59,10 @@ export const BatchExportSuccessEmailTemplate = ({
download. The download link will expire in{" "}
<strong>{expiresInHours} hours</strong>.
</Text>
<Text className="text-sm leading-6 text-black">
Please note data exports do not reflect custom column ordering or
visibility.
</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"
@@ -3,6 +3,7 @@ import { parseConnectionUrl } from "nodemailer/lib/shared/index.js";
import { render } from "@react-email/render";
import { BatchExportSuccessEmailTemplate } from "./BatchExportSuccessEmailTemplate";
import { logger } from "../../../logger";
type SendBatchExportSuccessParams = {
env: Partial<
@@ -24,8 +25,7 @@ export const sendBatchExportSuccessEmail = async ({
expiresInHours,
}: SendBatchExportSuccessParams) => {
if (!env.EMAIL_FROM_ADDRESS || !env.SMTP_CONNECTION_URL) {
console.error("Missing environment variables for sending email.");
logger.error("Missing environment variables for sending email.");
return;
}
@@ -38,7 +38,7 @@ export const sendBatchExportSuccessEmail = async ({
userName,
batchExportName,
expiresInHours,
})
}),
);
await mailer.sendMail({
@@ -51,6 +51,6 @@ export const sendBatchExportSuccessEmail = async ({
html: htmlTemplate,
});
} catch (error) {
console.error(error);
logger.error(error);
}
};
@@ -3,6 +3,7 @@ import { parseConnectionUrl } from "nodemailer/lib/shared/index.js";
import { render } from "@react-email/render";
import MembershipInvitationTemplate from "./MembershipInvitationEmailTemplate";
import { logger } from "../../../logger";
const langfuseUrls = {
US: "https://us.cloud.langfuse.com",
@@ -34,8 +35,8 @@ export const sendMembershipInvitationEmail = async ({
orgName,
}: SendMembershipInvitationParams) => {
if (!env.EMAIL_FROM_ADDRESS || !env.SMTP_CONNECTION_URL) {
console.error(
"Missing environment variables for sending membership invitation email."
logger.error(
"Missing environment variables for sending membership invitation email.",
);
return;
}
@@ -49,8 +50,8 @@ export const sendMembershipInvitationEmail = async ({
const authUrl = getAuthURL();
if (!authUrl) {
console.error(
"Missing NEXTAUTH_URL or NEXT_PUBLIC_LANGFUSE_CLOUD_REGION environment variable."
logger.error(
"Missing NEXTAUTH_URL or NEXT_PUBLIC_LANGFUSE_CLOUD_REGION environment variable.",
);
return;
}
@@ -67,7 +68,7 @@ export const sendMembershipInvitationEmail = async ({
inviteLink: authUrl,
emailFromAddress: env.EMAIL_FROM_ADDRESS,
langfuseCloudRegion: env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION,
})
}),
);
await mailer.sendMail({
@@ -77,6 +78,6 @@ export const sendMembershipInvitationEmail = async ({
html: htmlTemplate,
});
} catch (error) {
console.error(error);
logger.error(error);
}
};
@@ -17,6 +17,7 @@ export const sessionsViewCols: ColumnDefinition[] = [
type: "arrayOptions",
internal: 't."userIds"',
options: [], // to be filled in at runtime
nullable: true,
},
{
name: "Session Duration (s)",
@@ -78,10 +79,18 @@ export const sessionsViewCols: ColumnDefinition[] = [
type: "number",
internal: 'o."totalTokens"',
},
{
name: "Trace Tags",
id: "tags",
type: "arrayOptions",
internal: 't."tags"',
options: [], // to be filled in at runtime
},
];
export type SessionOptions = {
userIds: Array<OptionsDefinition>;
tags: Array<OptionsDefinition>;
};
export function sessionsTableColsWithOptions(
@@ -91,6 +100,9 @@ export function sessionsTableColsWithOptions(
if (col.id === "userIds") {
return { ...col, options: options?.userIds ?? [] };
}
if (col.id === "tags") {
return { ...col, options: options?.tags ?? [] };
}
return col;
});
}
@@ -9,6 +9,7 @@ export type ColumnDefinition =
id: string;
type: "number" | "string" | "datetime" | "boolean";
internal: string;
nullable?: boolean;
}
| {
name: string;
@@ -16,6 +17,7 @@ export type ColumnDefinition =
type: "stringOptions";
options: Array<OptionsDefinition>;
internal: string;
nullable?: boolean;
}
| {
name: string;
@@ -23,6 +25,7 @@ export type ColumnDefinition =
type: "arrayOptions";
options: Array<OptionsDefinition>;
internal: string;
nullable?: boolean;
}
| {
name: string;
@@ -30,6 +33,7 @@ export type ColumnDefinition =
type: "stringObject" | "numberObject";
internal: string;
keyOptions?: Array<string>;
nullable?: boolean;
};
export const tableNames = [
+18 -1
View File
@@ -15,6 +15,7 @@ const tracesOnlyCols: ColumnDefinition[] = [
type: "stringOptions",
internal: 't."name"',
options: [], // to be filled in at runtime
nullable: true,
},
{
name: "Timestamp",
@@ -22,12 +23,19 @@ const tracesOnlyCols: ColumnDefinition[] = [
type: "datetime",
internal: 't."timestamp"',
},
{ name: "User ID", id: "userId", type: "string", internal: 't."user_id"' },
{
name: "User ID",
id: "userId",
type: "string",
internal: 't."user_id"',
nullable: true,
},
{
name: "Session ID",
id: "sessionId",
type: "string",
internal: 't."session_id"',
nullable: true,
},
{
name: "Metadata",
@@ -40,12 +48,14 @@ const tracesOnlyCols: ColumnDefinition[] = [
id: "version",
type: "string",
internal: 't."version"',
nullable: true,
},
{
name: "Release",
id: "release",
type: "string",
internal: 't."release"',
nullable: true,
},
{
name: "Level",
@@ -69,24 +79,28 @@ export const tracesTableCols: ColumnDefinition[] = [
id: "inputTokens",
type: "number",
internal: 'tm."promptTokens"',
nullable: true,
},
{
name: "Output Tokens",
id: "outputTokens",
type: "number",
internal: 'tm."completionTokens"',
nullable: true,
},
{
name: "Total Tokens",
id: "totalTokens",
type: "number",
internal: 'tm."totalTokens"',
nullable: true,
},
{
name: "Usage",
id: "usage",
type: "number",
internal: 'tm."totalTokens"',
nullable: true,
},
{
@@ -106,18 +120,21 @@ export const tracesTableCols: ColumnDefinition[] = [
id: "inputCost",
type: "number",
internal: '"calculatedInputCost"',
nullable: true,
},
{
name: "Output Cost ($)",
id: "outputCost",
type: "number",
internal: '"calculatedOutputCost"',
nullable: true,
},
{
name: "Total Cost ($)",
id: "totalCost",
type: "number",
internal: '"calculatedTotalCost"',
nullable: true,
},
];
+2 -1
View File
@@ -1,7 +1,8 @@
import { type z } from "zod";
import { singleFilter } from "./interfaces/filters";
import { singleFilter, timeFilter } from "./interfaces/filters";
// to be sent to the server
export type TimeFilter = z.infer<typeof timeFilter>;
export type FilterCondition = z.infer<typeof singleFilter>;
export type FilterState = FilterCondition[];
+2052 -1617
View File
File diff suppressed because it is too large Load Diff
+30 -9
View File
@@ -33,15 +33,36 @@ ENV DOCKER_BUILD 1
ENV NEXT_MANUAL_SIG_HANDLE true
# pass public variables in build step
ARG NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
ARG NEXT_PUBLIC_DEMO_ORG_ID
ARG NEXT_PUBLIC_DEMO_PROJECT_ID
ARG NEXT_PUBLIC_SIGN_UP_DISABLED
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
ARG NEXT_PUBLIC_POSTHOG_KEY
ARG NEXT_PUBLIC_POSTHOG_HOST
ARG NEXT_PUBLIC_CRISP_WEBSITE_ID
ARG NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
ENV NEXT_PUBLIC_LANGFUSE_CLOUD_REGION=$NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
ARG NEXT_PUBLIC_DEMO_ORG_ID
ENV NEXT_PUBLIC_DEMO_ORG_ID=$NEXT_PUBLIC_DEMO_ORG_ID
ARG NEXT_PUBLIC_DEMO_PROJECT_ID
ENV NEXT_PUBLIC_DEMO_PROJECT_ID=$NEXT_PUBLIC_DEMO_PROJECT_ID
ARG NEXT_PUBLIC_SIGN_UP_DISABLED
ENV NEXT_PUBLIC_SIGN_UP_DISABLED=$NEXT_PUBLIC_SIGN_UP_DISABLED
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY
ARG NEXT_PUBLIC_POSTHOG_KEY
ENV NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY
ARG NEXT_PUBLIC_POSTHOG_HOST
ENV NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST
ARG NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE
ENV NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE=$NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE
ARG NEXT_PUBLIC_SENTRY_ENVIRONMENT
ENV NEXT_PUBLIC_SENTRY_ENVIRONMENT=$NEXT_PUBLIC_SENTRY_ENVIRONMENT
ARG NEXT_PUBLIC_SENTRY_DSN
ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN
ARG NEXT_PUBLIC_BASE_PATH
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
# Accept build id as NEXT_PUBLIC_BUILD_ID or PORTER_NEXT_PUBLIC_BUILD_ID
ARG PORTER_NEXT_PUBLIC_BUILD_ID
ARG NEXT_PUBLIC_BUILD_ID
ENV NEXT_PUBLIC_BUILD_ID=${PORTER_NEXT_PUBLIC_BUILD_ID:-$NEXT_PUBLIC_BUILD_ID}
# Copy source code of isolated subworkspace
COPY --from=pruner /app/out/full/ .
@@ -54,7 +75,7 @@ RUN rm -f ./web/src/middleware.ts
ENV NEXT_TELEMETRY_DISABLED 1
ENV NEXT_MANUAL_SIG_HANDLE true
RUN turbo run build --filter=web
RUN NODE_OPTIONS='--max-old-space-size=4096' turbo run build --filter=web
# Production image, copy all the files and run next
FROM --platform=${TARGETPLATFORM:-linux/amd64} base AS runner
@@ -100,4 +121,4 @@ ENV PORT 3000
ENTRYPOINT ["dumb-init", "--", "./web/entrypoint.sh"]
# startup command
CMD ["node", "./web/server.js"]
CMD ["node", "./web/server.js", "--keepAliveTimeout", "110000"]
+62 -34
View File
@@ -11,28 +11,51 @@ import { env } from "./src/env.mjs";
* img-src https to allow loading images from SSO providers
*/
const cspHeader = `
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;
frame-src 'self' https://challenges.cloudflare.com https://*.stripe.com;
default-src 'self' https://*.langfuse.com https://*.langfuse.dev https://*.posthog.com https://*.sentry.io wss://*.crisp.chat https://*.crisp.chat;
script-src 'self' 'unsafe-eval' 'unsafe-inline' https://*.langfuse.com https://*.langfuse.dev https://client.crisp.chat https://settings.crisp.chat https://challenges.cloudflare.com https://*.sentry.io https://static.cloudflareinsights.com https://*.stripe.com;
style-src 'self' 'unsafe-inline' https://client.crisp.chat;
img-src 'self' https: blob: data: https://client.crisp.chat https://image.crisp.chat https://storage.crisp.chat;
font-src 'self' https://client.crisp.chat;
frame-src 'self' https://challenges.cloudflare.com https://*.stripe.com https://game.crisp.chat;
worker-src 'self' blob:;
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
connect-src 'self' https://*.langfuse.com https://*.langfuse.dev https://client.crisp.chat https://storage.crisp.chat wss://client.relay.crisp.chat wss://stream.relay.crisp.chat https://*.ingest.us.sentry.io;
media-src 'self' https://client.crisp.chat;
${env.LANGFUSE_CSP_ENFORCE_HTTPS === "true" ? "upgrade-insecure-requests; block-all-mixed-content;" : ""}
${env.SENTRY_CSP_REPORT_URI ? `report-uri ${env.SENTRY_CSP_REPORT_URI}; report-to csp-endpoint;` : ""}
`;
const reportToHeader = {
key: "Report-To",
value: JSON.stringify({
group: "csp-endpoint",
max_age: 10886400,
endpoints: [
{
url: env.SENTRY_CSP_REPORT_URI,
},
],
include_subdomains: true,
}),
};
/** @type {import("next").NextConfig} */
const nextConfig = {
transpilePackages: ["@langfuse/shared"],
reactStrictMode: true,
experimental: {
instrumentationHook: true,
serverComponentsExternalPackages: [
"dd-trace",
"@opentelemetry/auto-instrumentations-node",
"@opentelemetry/api",
],
},
poweredByHeader: false,
basePath: env.NEXT_PUBLIC_BASE_PATH,
/**
* If you have `experimental: { appDir: true }` set, then you must comment the below `i18n` config
@@ -67,6 +90,7 @@ const nextConfig = {
key: "Permissions-Policy",
value: "autoplay=*, fullscreen=*, microphone=*",
},
...(env.SENTRY_CSP_REPORT_URI ? [reportToHeader] : []),
],
},
{
@@ -117,10 +141,6 @@ const nextConfig = {
// webassembly support for @dqbd/tiktoken
webpack(config, { isServer }) {
if (isServer) {
// https://github.com/open-telemetry/opentelemetry-js/issues/4173
config.ignoreWarnings = [{ module: /opentelemetry/ }];
}
config.experiments = {
asyncWebAssembly: true,
layers: true,
@@ -130,36 +150,44 @@ const nextConfig = {
},
};
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:
// release, url, authToken, configFile, stripPrefix,
// urlPrefix, include, ignore
export default withSentryConfig(nextConfig, {
// For all available options, see:
// https://github.com/getsentry/sentry-webpack-plugin#options
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
silent: true, // Suppresses all logs
authToken: env.SENTRY_AUTH_TOKEN,
// Only print logs for uploading source maps in CI
silent: !process.env.CI,
// For all available options, see:
// https://github.com/getsentry/sentry-webpack-plugin#options.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// See the sections below for information on the following options:
// 'Configure Source Maps':
// - disableServerWebpackPlugin
// - disableClientWebpackPlugin
// - hideSourceMaps
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Automatically annotate React components to show their full name in breadcrumbs and session replay
reactComponentAnnotation: {
enabled: true,
},
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// side errors will fail.
// tunnelRoute: "/api/monitoring-tunnel",
// Hides source maps from generated client bundles
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, sentryOptions);
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
// See the following for more information:
// https://docs.sentry.io/product/crons/
// https://vercel.com/docs/cron-jobs
automaticVercelMonitors: false,
});
+46 -39
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "2.74.0",
"version": "2.80.0",
"private": true,
"license": "MIT",
"engines": {
@@ -27,6 +27,10 @@
"@baselime/trpc-opentelemetry-middleware": "^0.1.2",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lint": "^6.8.0",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@headlessui/react": "1.7.18",
"@headlessui/tailwindcss": "0.2.1",
"@heroicons/react": "^2.1.3",
@@ -47,39 +51,40 @@
"@opentelemetry/instrumentation-http": "^0.52.1",
"@opentelemetry/instrumentation-ioredis": "^0.42.0",
"@opentelemetry/instrumentation-undici": "^0.4.0",
"@opentelemetry/instrumentation-winston": "^0.36.0",
"@opentelemetry/resources": "^1.25.1",
"@opentelemetry/sdk-metrics": "1.23.0",
"@opentelemetry/sdk-node": "^0.50.0",
"@opentelemetry/sdk-trace-base": "^1.25.1",
"@opentelemetry/sdk-trace-node": "^1.25.1",
"@opentelemetry/semantic-conventions": "^1.25.1",
"@opentelemetry/winston-transport": "^0.2.0",
"@prisma/instrumentation": "^5.13.0",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-hover-card": "^1.1.1",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-toggle-group": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@remixicon/react": "^4.2.0",
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@sentry/nextjs": "8.13.0",
"@sentry/node": "8.13.0",
"@sentry/nextjs": "8.26.0",
"@t3-oss/env-nextjs": "^0.10.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/react-query": "^4.36.1",
@@ -89,16 +94,16 @@
"@trpc/next": "^10.45.0",
"@trpc/react-query": "^10.45.0",
"@trpc/server": "^10.45.0",
"@uiw/codemirror-theme-github": "^4.21.25",
"@uiw/codemirror-theme-github": "^4.23.0",
"@uiw/codemirror-theme-tokyo-night": "^4.22.2",
"@uiw/react-codemirror": "^4.21.25",
"ai": "^3.0.23",
"bcryptjs": "^2.4.3",
"bullmq": "^5.4.2",
"bullmq": "^5.12.10",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"core-js": "^3.36.1",
"core-js": "^3.38.1",
"cors": "^2.8.5",
"date-fns": "^3.3.1",
"dd-trace": "^5.21.0",
@@ -108,24 +113,26 @@
"ioredis": "^5.4.1",
"ip-address": "^9.0.5",
"js-tiktoken": "^1.0.12",
"kysely": "^0.27.3",
"kysely": "^0.27.4",
"langchain": "^0.2.6",
"lodash": "^4.17.21",
"lucide-react": "^0.364.0",
"next": "^14.2.3",
"lucide-react": "^0.436.0",
"next": "^14.2.6",
"next-auth": "^4.24.7",
"next-query-params": "^5.0.0",
"next-themes": "^0.3.0",
"posthog-js": "^1.154.6",
"posthog-js": "^1.161.3",
"posthog-node": "^3.6.3",
"prexit": "^2.2.0",
"prisma": "^5.13.0",
"rate-limiter-flexible": "^5.0.3",
"react": "18.2.0",
"react-day-picker": "^8.10.1",
"react-dom": "18.2.0",
"react-hook-form": "^7.51.5",
"react-icons": "^5.2.1",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^2.1.1",
"react-responsive": "^10.0.0",
"react-syntax-highlighter": "^15.5.0",
"react18-json-view": "^0.2.8-canary.6",
@@ -134,7 +141,7 @@
"sonner": "^1.4.41",
"stripe": "^16.8.0",
"superjson": "2.2.1",
"tailwind-merge": "^2.2.1",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"use-query-params": "^2.2.1",
"uuid": "^9.0.1",
@@ -153,10 +160,10 @@
"@types/dompurify": "^3.0.5",
"@types/eslint": "^8.56.7",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.5",
"@types/lodash": "^4.17.7",
"@types/node": "20.10.5",
"@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25",
"@types/react": "~18.2.79",
"@types/react-dom": "~18.2.25",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^6.21.0",
@@ -164,17 +171,17 @@
"autoprefixer": "^10.4.19",
"dotenv-cli": "^7.4.2",
"eslint": "^8.56.0",
"eslint-config-next": "^14.2.3",
"eslint-config-next": "^14.2.6",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"node-mocks-http": "^1.14.1",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.14",
"tailwindcss": "^3.4.4",
"prettier-plugin-tailwindcss": "^0.6.6",
"tailwindcss": "^3.4.10",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.7.2",
"tsx": "^4.19.0",
"typescript": "^5.4.5",
"wait-for-expect": "^3.0.2"
},
@@ -182,6 +189,6 @@
"initVersion": "7.13.0"
},
"optionalDependencies": {
"crisp-sdk-web": "^1.0.21"
"crisp-sdk-web": "^1.0.25"
}
}
@@ -2,28 +2,31 @@ import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT,
// Replay may only be enabled for the client-side
integrations: [Sentry.replayIntegration()],
integrations: [
Sentry.replayIntegration(),
// Sentry.debugIntegration(),
// Sentry.captureConsoleIntegration({
// levels: ["error"],
// }),
],
// Set tracesSampleRate to 1.0 to capture 100%
// of transactions for performance monitoring.
// We recommend adjusting this value in production
tracesSampleRate: process.env.NEXT_LANGFUSE_TRACING_SAMPLE_RATE
? Number(process.env.NEXT_LANGFUSE_TRACING_SAMPLE_RATE)
: 0.1,
tracesSampleRate: process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE
? Number(process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE)
: 0,
// Capture Replay for 100% of all sessions,
// plus for 100% of sessions with an error
replaysSessionSampleRate: 0,
replaysSessionSampleRate: process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE
? Number(process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE)
: 0,
replaysOnErrorSampleRate: 1.0,
beforeSend(event, _hint) {
// Check if it is an exception, and if so, show the report dialog
if (event.exception) {
Sentry.showReportDialog({ eventId: event.event_id });
}
return event;
},
debug: false,
// ...
+96 -4
View File
@@ -1,8 +1,7 @@
import { v4 } from "uuid";
import { JobExecutionStatus, prisma } from "@langfuse/shared/src/db";
import { redis } from "@langfuse/shared/src/server";
import { JobExecutionStatus, Prisma, prisma } from "@langfuse/shared/src/db";
import { OrgEnrichedApiKey, redis } from "@langfuse/shared/src/server";
import waitForExpect from "wait-for-expect";
import { ApiKeyZod } from "@/src/features/public-api/server/apiAuth";
const generateAuth = (username: string, password: string) => {
const auth = Buffer.from(`${username}:${password}`).toString("base64");
@@ -38,6 +37,14 @@ describe("Health endpoints", () => {
});
describe("Ingestion Pipeline", () => {
beforeEach(async () => {
// clear the redis cache
const keys = await redis?.keys("*");
if (keys && keys.length > 0) {
await redis?.del(keys);
}
});
it("ingest a trace", async () => {
const traceId = v4();
const spanId = v4();
@@ -118,7 +125,7 @@ describe("Ingestion Pipeline", () => {
expect(redisKeys?.length).toBe(1);
const redisValue = await redis?.get(redisKeys![0]);
const llmApiKey = ApiKeyZod.parse(JSON.parse(redisValue!));
const llmApiKey = OrgEnrichedApiKey.parse(JSON.parse(redisValue!));
expect(llmApiKey.projectId).toBe("7a88fb47-b4e2-43b8-a06c-a5ce950dc53a");
});
@@ -143,6 +150,91 @@ describe("Ingestion Pipeline", () => {
expect(response.status).toBe(207);
}, 25000);
it("rate limit ingestion", async () => {
// update the org in the database and set the rate limit to 1 for ingestion
const org = await prisma.organization.findUnique({
where: {
id: "seed-org-id",
},
});
await prisma.organization.update({
where: {
id: "seed-org-id",
},
data: {
cloudConfig: {
...(typeof org?.cloudConfig === "object" ? org.cloudConfig : {}),
rateLimitOverrides: [
{
resource: "ingestion",
points: 1,
durationInSec: 60,
},
],
},
},
});
const traceId = v4();
const spanId = v4();
const event = {
batch: [
{
id: v4(),
type: "trace-create",
timestamp: new Date().toISOString(),
body: {
name: "test trace",
id: traceId,
userId: "user-1", // triggers the eval
},
},
{
id: v4(),
type: "span-create",
timestamp: new Date().toISOString(),
body: {
id: spanId,
traceId: traceId,
name: "test span",
},
},
],
};
// Arrange
const url = "http://localhost:3000/api/public/ingestion";
// Act
let responses = [];
for (let i = 0; i < 10; i++) {
responses.push(
await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: userApiKeyAuth,
},
body: JSON.stringify(event),
}),
);
}
// check that at least one of the responses is a 429
const rateLimitedResponse = responses.find((r) => r.status === 429);
expect(rateLimitedResponse).not.toBeNull();
// revert the rate limit on the org
await prisma.organization.update({
where: {
id: "seed-org-id",
},
data: {
cloudConfig: org?.cloudConfig ?? Prisma.JsonNull,
},
});
});
});
describe("Prompts endpoint", () => {
+321 -8
View File
@@ -1,14 +1,12 @@
import {
getDisplaySecretKey,
hashSecretKey,
OrgEnrichedApiKey,
} from "@langfuse/shared/src/server";
import {
ApiAuthService,
ApiKeyZod,
} from "@/src/features/public-api/server/apiAuth";
import { type PrismaClient, prisma } from "@langfuse/shared/src/db";
import { Prisma, type PrismaClient, prisma } from "@langfuse/shared/src/db";
import { Redis } from "ioredis";
import { env } from "@/src/env.mjs";
import { ApiAuthService } from "@/src/features/public-api/server/apiAuth";
describe("Authenticate API calls", () => {
beforeEach(async () => {
@@ -68,6 +66,118 @@ describe("Authenticate API calls", () => {
expect(auth2.validKey).toBe(true);
});
it("should create new api key with stripe data and succeed with new key", async () => {
await createAPIKey();
await prisma.organization.update({
where: {
id: "seed-org-id",
},
data: {
cloudConfig: {
stripe: {
customerId: "cus_test123",
activeSubscriptionId: "sub_test123",
activeProductId: "prod_test123",
},
},
},
});
await new ApiAuthService(prisma, null).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
const auth = await new ApiAuthService(
prisma,
null,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(auth.validKey).toBe(true);
if (auth.validKey) {
expect(auth.scope.orgId).toBe("seed-org-id");
expect(auth.scope.plan).toBe("cloud:hobby");
expect(auth.scope.rateLimitOverrides).toEqual([]);
}
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
});
expect(apiKey).not.toBeNull();
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
await prisma.organization.update({
where: {
id: "seed-org-id",
},
data: {
cloudConfig: Prisma.JsonNull,
},
});
});
it("should create new api key with custom rate limits and succeed with new key", async () => {
await createAPIKey();
await prisma.organization.update({
where: {
id: "seed-org-id",
},
data: {
cloudConfig: {
rateLimitOverrides: [
{
resource: "ingestion",
points: 100,
durationInSec: 60,
},
],
},
},
});
await new ApiAuthService(prisma, null).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
const auth = await new ApiAuthService(
prisma,
null,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(auth.validKey).toBe(true);
if (auth.validKey) {
expect(auth.scope.orgId).toBe("seed-org-id");
expect(auth.scope.plan).toBe("cloud:hobby");
expect(auth.scope.rateLimitOverrides).toEqual([
{
resource: "ingestion",
points: 100,
durationInSec: 60,
},
]);
}
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
});
expect(apiKey).not.toBeNull();
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
await prisma.organization.update({
where: {
id: "seed-org-id",
},
data: {
cloudConfig: Prisma.JsonNull,
},
});
});
it("should fail on wrong api key with new key", async () => {
await createAPIKey();
const auth = await new ApiAuthService(
@@ -149,6 +259,25 @@ describe("Authenticate API calls", () => {
it("should create new api key and read from cache", async () => {
await createAPIKey();
// update the organization with a cloud config
await prisma.organization.update({
where: { id: "seed-org-id" },
data: {
cloudConfig: {
rateLimitOverrides: [
{
resource: "public-api",
points: 1000,
durationInSec: 60,
},
{
resource: "ingestion",
},
],
},
},
});
// first auth will generate the fast hashed api key
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
@@ -177,12 +306,31 @@ describe("Authenticate API calls", () => {
expect(cachedKey2).not.toBeNull();
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey2!));
const parsed = OrgEnrichedApiKey.parse(JSON.parse(cachedKey2!));
expect(parsed).toEqual({
...apiKey,
orgId: "seed-org-id",
plan: "cloud:hobby",
rateLimitOverrides: [
{
resource: "public-api",
points: 1000,
durationInSec: 60,
},
{
resource: "ingestion",
},
],
createdAt: apiKey?.createdAt.toISOString(),
});
await prisma.organization.update({
where: { id: "seed-org-id" },
data: {
cloudConfig: Prisma.JsonNull,
},
});
});
it("searching for non-existing key stores flag in redis and fails auth", async () => {
@@ -275,7 +423,7 @@ describe("Authenticate API calls", () => {
);
expect(cachedKey).not.toBeNull();
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey!));
const parsed = OrgEnrichedApiKey.parse(JSON.parse(cachedKey!));
expect(parsed).toEqual({
id: expect.any(String),
@@ -288,6 +436,8 @@ describe("Authenticate API calls", () => {
lastUsedAt: null,
expiresAt: null,
projectId: expect.any(String),
orgId: "seed-org-id",
plan: "cloud:hobby",
});
});
@@ -372,10 +522,12 @@ describe("Authenticate API calls", () => {
);
expect(cachedKey).not.toBeNull();
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey!));
const parsed = OrgEnrichedApiKey.parse(JSON.parse(cachedKey!));
expect(parsed).toEqual({
...apiKey,
orgId: "seed-org-id",
plan: "cloud:hobby",
createdAt: apiKey?.createdAt.toISOString(),
});
@@ -396,6 +548,167 @@ describe("Authenticate API calls", () => {
});
});
describe("invalidates api keys in redis", () => {
const redis = new Redis("redis://:myredissecret@127.0.0.1:6379", {
maxRetriesPerRequest: null,
});
beforeEach(async () => {
// if we do not remove the key, it will remain in the cache and
// calling the test twice will not add the key to the cache
const keys = await redis.keys("api-key*");
console.log("before each deleting keys", keys);
if (keys.length > 0) {
console.log("before each deleting keys. actually deleting", keys);
await redis.del(keys);
}
});
afterEach(async () => {
// if we do not remove the key, it will remain in the cache and
// calling the test twice will not add the key to the cache
const keys = await redis.keys("api-key*");
console.log("after each deleting keys", keys);
if (keys.length > 0) {
await redis.del(keys);
}
});
afterAll(async () => {
redis.disconnect();
});
it("should invalidate organization API keys in redis", async () => {
await createAPIKey();
// put keys into cache
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
});
expect(apiKey).not.toBeNull();
const cachedKey = await redis.get(
`api-key:${apiKey?.fastHashedSecretKey}`,
);
expect(cachedKey).not.toBeNull();
await new ApiAuthService(prisma, redis).invalidateOrgApiKeys(
"seed-org-id",
);
const invalidatedCachedKey = await redis.get(
`api-key:${apiKey?.fastHashedSecretKey}`,
);
expect(invalidatedCachedKey).toBeNull();
});
it("if no keys in redis, invalidating org keys should do nothing", async () => {
await createAPIKey();
await prisma.apiKey.update({
where: { publicKey: "pk-lf-1234567890" },
data: {
fastHashedSecretKey: Math.random().toString(36).substring(2, 15),
},
});
await new ApiAuthService(prisma, redis).invalidateOrgApiKeys(
"seed-org-id",
);
const keys = await redis.keys("api-key*");
expect(keys.length).toBe(0);
});
it("if no keys in redis, invalidating org keys without fast hash should do nothing", async () => {
await createAPIKey();
await new ApiAuthService(prisma, redis).invalidateOrgApiKeys(
"seed-org-id",
);
const keys = await redis.keys("api-key*");
expect(keys.length).toBe(0);
});
it("should invalidate project API keys in redis", async () => {
await createAPIKey();
// put keys into cache
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
});
expect(apiKey).not.toBeNull();
const cachedKey = await redis.get(
`api-key:${apiKey?.fastHashedSecretKey}`,
);
expect(cachedKey).not.toBeNull();
await new ApiAuthService(prisma, redis).invalidateProjectApiKeys(
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
);
const invalidatedCachedKey = await redis.get(
`api-key:${apiKey?.fastHashedSecretKey}`,
);
expect(invalidatedCachedKey).toBeNull();
});
it("if no keys in redis, invalidating project keys should do nothing", async () => {
await createAPIKey();
await prisma.apiKey.update({
where: { publicKey: "pk-lf-1234567890" },
data: {
fastHashedSecretKey: Math.random().toString(36).substring(2, 15),
},
});
await new ApiAuthService(prisma, redis).invalidateProjectApiKeys(
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
);
const keys = await redis.keys("api-key*");
expect(keys.length).toBe(0);
});
it("if no keys in redis, invalidating project keys without fast hash should do nothing", async () => {
await createAPIKey();
await prisma.apiKey.update({
where: { publicKey: "pk-lf-1234567890" },
data: {
fastHashedSecretKey: Math.random().toString(36).substring(2, 15),
},
});
await new ApiAuthService(prisma, redis).invalidateProjectApiKeys(
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
);
const keys = await redis.keys("api-key*");
expect(keys.length).toBe(0);
});
});
const createAPIKey = async () => {
const seedApiKey = {
id: "seed-api-key",
@@ -1,4 +1,4 @@
import { orderByToPrismaSql } from "@langfuse/shared";
import { orderByToPrismaSql } from "@langfuse/shared/src/server";
import { tracesTableCols } from "@langfuse/shared";
// The test for the orderByToPrisma function
+269
View File
@@ -0,0 +1,269 @@
import { RateLimitService } from "@/src/features/public-api/server/RateLimitService";
import { Redis } from "ioredis";
describe("RateLimitService", () => {
const orgId = "seed-org-id";
let redis: Redis;
beforeAll(() => {
redis = new Redis("redis://:myredissecret@127.0.0.1:6379", {
maxRetriesPerRequest: null,
});
});
afterAll(async () => {
redis.disconnect();
});
beforeEach(async () => {
expect(redis).toBeDefined();
const keys = await redis?.keys("rate-limit*");
if (keys && keys?.length > 0) {
await redis?.del(keys);
}
});
afterEach(async () => {
const keys = await redis?.keys("rate-limit*");
if (keys && keys.length > 0) {
await redis?.del(keys);
}
});
it("should rate limit", async () => {
const scope = {
orgId: orgId,
plan: "cloud:hobby" as const,
projectId: "test-project-id",
accessLevel: "all" as const,
rateLimitOverrides: [],
};
const rateLimitService = new RateLimitService(redis!);
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
expect(result?.res).toEqual({
scope: scope,
resource: "public-api",
points: 1000,
remainingPoints: 999,
msBeforeNext: expect.any(Number),
consumedPoints: 1,
isFirstInDuration: true,
});
expect(result?.isRateLimited()).toBe(false);
// check redis for the rate limit key
const value = await redis.get("rate-limit:public-api:seed-org-id");
expect(value).toBeDefined();
expect(parseInt(value ?? "0")).toBeGreaterThan(0);
});
it("should increment the rate limit count", async () => {
const scope = {
orgId: orgId,
plan: "cloud:hobby" as const,
projectId: "test-project-id",
accessLevel: "all" as const,
rateLimitOverrides: [],
};
const rateLimitService = new RateLimitService(redis!);
await rateLimitService.rateLimitRequest(scope, "public-api");
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
expect(result?.res).toEqual({
scope: scope,
resource: "public-api",
points: 1000,
remainingPoints: 998,
msBeforeNext: expect.any(Number),
consumedPoints: 2,
isFirstInDuration: false,
});
expect(result?.isRateLimited()).toBe(false);
});
it("should reset the rate limit count after the window expires", async () => {
const scope = {
orgId: orgId,
plan: "cloud:hobby" as const,
projectId: "test-project-id",
accessLevel: "all" as const,
rateLimitOverrides: [
{ resource: "public-api" as const, points: 100, durationInSec: 2 },
],
};
const rateLimitService = new RateLimitService(redis!);
await rateLimitService.rateLimitRequest(scope, "public-api");
const firstResult = await rateLimitService.rateLimitRequest(
scope,
"public-api",
);
expect(firstResult?.isRateLimited()).toBe(false);
expect(firstResult?.res).toEqual({
scope: scope,
resource: "public-api",
points: 100,
remainingPoints: 98,
msBeforeNext: expect.any(Number),
consumedPoints: 2,
isFirstInDuration: false,
});
await new Promise((resolve) => setTimeout(resolve, 3000));
const secondResult = await rateLimitService.rateLimitRequest(
scope,
"public-api",
);
expect(secondResult?.res).toEqual({
scope: scope,
resource: "public-api",
points: 100,
remainingPoints: 99,
msBeforeNext: expect.any(Number),
consumedPoints: 1,
isFirstInDuration: true,
});
expect(secondResult?.isRateLimited()).toBe(false);
});
it("should return false when rate limit is exceeded", async () => {
const scope = {
orgId: orgId,
plan: "cloud:hobby" as const,
projectId: "test-project-id",
accessLevel: "all" as const,
rateLimitOverrides: [
{ resource: "public-api" as const, points: 5, durationInSec: 60 },
],
};
const rateLimitService = new RateLimitService(redis!);
for (let i = 0; i < 5; i++) {
await rateLimitService.rateLimitRequest(scope, "public-api");
}
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
expect(result?.res).toEqual({
scope: scope,
resource: "public-api",
points: 5,
remainingPoints: 0,
msBeforeNext: expect.any(Number),
consumedPoints: 6,
isFirstInDuration: false,
});
expect(result?.isRateLimited()).toBe(true);
});
it("should apply rate limits with override for specific resource", async () => {
const scope = {
orgId: orgId,
plan: "cloud:hobby" as const,
projectId: "test-project-id",
accessLevel: "all" as const,
rateLimitOverrides: [
{ resource: "public-api" as const, points: 5, durationInSec: 10 },
],
};
const rateLimitService = new RateLimitService(redis!);
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
expect(result?.res).toEqual({
scope: scope,
resource: "public-api",
points: 5,
remainingPoints: 4,
msBeforeNext: expect.any(Number),
consumedPoints: 1,
isFirstInDuration: true,
});
});
it("should not apply rate limits for resource prompts", async () => {
const scope = {
orgId: orgId,
plan: "cloud:hobby" as const,
projectId: "test-project-id",
accessLevel: "all" as const,
rateLimitOverrides: [
{ resource: "public-api" as const, points: 5, durationInSec: 10 },
],
};
const rateLimitService = new RateLimitService(redis!);
const result = await rateLimitService.rateLimitRequest(scope, "prompts");
expect(result?.res).toBeUndefined();
expect(result?.isRateLimited()).toBe(false);
});
it("should not apply rate limits for ingestion when overridden to null in API key", async () => {
const scope = {
orgId: orgId,
plan: "cloud:hobby" as const,
projectId: "test-project-id",
accessLevel: "all" as const,
rateLimitOverrides: [
{ resource: "ingestion" as const, points: null, durationInSec: null },
],
};
const rateLimitService = new RateLimitService(redis!);
const result = await rateLimitService.rateLimitRequest(scope, "ingestion");
expect(result?.res).toBeUndefined();
});
it("should not apply rate limits when redis is not defined", async () => {
const scope = {
orgId: orgId,
plan: "cloud:hobby" as const,
projectId: "test-project-id",
accessLevel: "all" as const,
rateLimitOverrides: [
{ resource: "public-api" as const, points: 5, durationInSec: 10 },
],
};
const rateLimitService = new RateLimitService(null);
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
expect(result?.res).toBeUndefined();
expect(result?.isRateLimited()).toBe(false);
});
it("should not apply rate limits for OSS plan", async () => {
const scope = {
orgId: orgId,
plan: "oss" as const,
projectId: "test-project-id",
accessLevel: "all" as const,
rateLimitOverrides: [],
};
const rateLimitService = new RateLimitService(redis!);
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
expect(result?.res).toBeUndefined();
expect(result?.isRateLimited()).toBe(false);
});
});
+1 -42
View File
@@ -1,7 +1,4 @@
import { hash } from "bcryptjs";
import { env } from "@/src/env.mjs";
import { getDisplaySecretKey, hashSecretKey } from "@langfuse/shared";
import { prisma } from "@langfuse/shared/src/db";
import { type z } from "zod";
@@ -24,10 +21,7 @@ export const pruneDatabase = async () => {
await prisma.llmApiKeys.deleteMany();
};
export function createBasicAuthHeader(
username: string,
password: string,
): string {
function createBasicAuthHeader(username: string, password: string): string {
const base64Credentials = Buffer.from(`${username}:${password}`).toString(
"base64",
);
@@ -97,38 +91,3 @@ export async function makeZodVerifiedAPICall<T extends z.ZodTypeAny>(
}
return { body: resBody, status };
}
export const setupUserAndProject = async () => {
const user = await prisma.user.create({
data: {
id: "user-1",
name: "Demo User",
email: "demo@langfuse.com",
password: await hash("password", 12),
},
});
const project = await prisma.project.create({
data: {
id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
name: "llm-app",
apiKeys: {
create: [
{
note: "seeded key",
hashedSecretKey: await hashSecretKey("sk-lf-1234567890"),
displaySecretKey: getDisplaySecretKey("sk-lf-1234567890"),
publicKey: "pk-lf-1234567890",
},
],
},
projectMembers: {
create: {
role: "OWNER",
userId: user.id,
},
},
},
});
return { user, project };
};
@@ -9,27 +9,40 @@ import type { MessagesContext } from "./types";
type ChatMessageProps = Pick<
MessagesContext,
"deleteMessage" | "updateMessage"
"deleteMessage" | "updateMessage" | "availableRoles"
> & { message: ChatMessageWithId };
export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
message,
updateMessage,
deleteMessage,
availableRoles,
}) => {
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
const [textAreaRows, setTextAreaRows] = useState(1);
const [roleIndex, setRoleIndex] = useState(1);
const toggleRole = () => {
if (message.role === ChatMessageRole.System) return;
updateMessage(
message.id,
"role",
message.role === ChatMessageRole.User
? ChatMessageRole.Assistant
: ChatMessageRole.User,
);
// if user has set custom roles, available roles will be non-empty and we toggle through custom and default roles (assistant, user)
if (!!availableRoles && Boolean(availableRoles.length)) {
let randomRole = availableRoles[roleIndex % availableRoles.length];
if (randomRole === message.role) {
randomRole = availableRoles[(roleIndex + 1) % availableRoles.length];
}
updateMessage(message.id, "role", randomRole);
setRoleIndex(roleIndex + 1);
} else {
// if user has not set custom roles, we toggle through default roles (assistant, user)
updateMessage(
message.id,
"role",
message.role === ChatMessageRole.User
? ChatMessageRole.Assistant
: ChatMessageRole.User,
);
}
};
const handleContentChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
@@ -64,7 +77,7 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
<Textarea
ref={textAreaRef}
id={message.id}
className="height-[auto] min-h-8 w-full pt-3 font-mono text-xs focus:outline-none"
className="height-[auto] min-h-6 w-full font-mono text-xs focus:outline-none"
placeholder={placeholder}
value={message.content}
onChange={handleContentChange}
+17 -9
View File
@@ -24,13 +24,21 @@ export const ChatMessages: React.FC<ChatMessagesProps> = (props) => {
}, [scrollAreaRef, messages.length]);
return (
<div className="h-full overflow-auto scroll-smooth" ref={scrollAreaRef}>
<div className="mb-4 flex-1 space-y-4">
{props.messages.map((message) => {
return (
<ChatMessageComponent {...{ message, ...props }} key={message.id} />
);
})}
<div className="flex h-full flex-col">
<div className="mb-2 font-semibold">Messages</div>
<div className="flex-1 overflow-auto scroll-smooth" ref={scrollAreaRef}>
<div className="mb-4 flex-1 space-y-3">
{props.messages.map((message) => {
return (
<ChatMessageComponent
{...{ message, ...props }}
key={message.id}
/>
);
})}
</div>
</div>
<div className="py-3">
<AddMessageButton {...props} />
</div>
</div>
@@ -52,10 +60,10 @@ const AddMessageButton: React.FC<AddMessageButtonProps> = ({
<Button
type="button" // prevents submitting a form if this button is inside a form
variant="outline"
className="w-full space-x-2 py-6"
className="w-full"
onClick={() => addMessage(nextMessageRole)}
>
<PlusCircleIcon size={16} />
<PlusCircleIcon size={14} className="mr-2" />
<p>Add message</p>
</Button>
);
+2 -2
View File
@@ -1,6 +1,5 @@
import type { ChatMessageRole, ChatMessageWithId } from "@langfuse/shared";
export type MessagesContext = {
messages: ChatMessageWithId[];
addMessage: (role: ChatMessageRole, content?: string) => ChatMessageWithId;
@@ -8,6 +7,7 @@ export type MessagesContext = {
updateMessage: <Key extends keyof ChatMessageWithId>(
id: string,
key: Key,
value: ChatMessageWithId[Key]
value: ChatMessageWithId[Key],
) => void;
availableRoles?: string[]; // Only defined if user has extended default roles (ChatMessageRole) with custom roles via SDK/API
};
+68 -23
View File
@@ -2,6 +2,9 @@ import { cn } from "@/src/utils/tailwind";
import Link from "next/link";
import { EnvLabel } from "./EnvLabel";
import { VersionLabel } from "./VersionLabel";
import { env } from "@/src/env.mjs";
import { useUiCustomization } from "@/src/ee/features/ui-customization/useUiCustomization";
import { PlusIcon } from "lucide-react";
export const LangfuseIcon = ({
size = 32,
@@ -12,7 +15,7 @@ export const LangfuseIcon = ({
}) => (
// eslint-disable-next-line @next/next/no-img-element
<img
src="/icon.svg"
src={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/icon.svg`}
width={size}
height={size}
alt="Langfuse Icon"
@@ -20,6 +23,51 @@ export const LangfuseIcon = ({
/>
);
const LangfuseLogotypeOrCustomized = ({ size }: { size: "sm" | "xl" }) => {
const uiCustomization = useUiCustomization();
if (uiCustomization?.logoLightModeHref && uiCustomization?.logoDarkModeHref) {
return (
<div className="flex items-center gap-1">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={uiCustomization.logoLightModeHref}
alt="Langfuse Logo"
className={cn(
"dark:hidden",
size === "sm" ? "max-h-4 max-w-14" : "max-h-5 max-w-16",
)}
/>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={uiCustomization.logoDarkModeHref}
alt="Langfuse Logo"
className={cn(
"hidden dark:block",
size === "sm" ? "max-h-4 max-w-14" : "max-h-5 max-w-16",
)}
/>
<PlusIcon size={size === "sm" ? 8 : 12} />
<LangfuseIcon size={size === "sm" ? 16 : 20} />
</div>
);
}
return (
<div className="flex items-center">
<LangfuseIcon size={size === "sm" ? 16 : 20} />
<span
className={cn(
"ml-2 font-mono font-semibold",
size === "sm" ? "text-sm" : "text-xl",
)}
>
Langfuse
</span>
</div>
);
};
export const LangfuseLogo = ({
className,
size = "sm",
@@ -30,26 +78,23 @@ export const LangfuseLogo = ({
className?: string;
version?: boolean;
showEnvLabel?: boolean;
}) => (
<div
className={cn("flex flex-wrap gap-4 lg:flex-col lg:items-start", className)}
>
{/* Environment Labeling for Langfuse Maintainers */}
{showEnvLabel && <EnvLabel />}
{/* Langfuse Logo */}
<div className="flex items-center">
<Link href="/" className="flex items-center">
<LangfuseIcon size={size === "sm" ? 16 : 20} />
<span
className={cn(
"ml-2 font-mono font-semibold",
size === "sm" ? "text-sm" : "text-xl",
)}
>
Langfuse
</span>
</Link>
{version && <VersionLabel className="ml-2" />}
}) => {
return (
<div
className={cn(
"flex flex-wrap gap-4 lg:flex-col lg:items-start",
className,
)}
>
{/* Environment Labeling for Langfuse Maintainers */}
{showEnvLabel && <EnvLabel />}
{/* Langfuse Logo */}
<div className="flex items-center">
<Link href="/" className="flex items-center">
<LangfuseLogotypeOrCustomized size={size} />
</Link>
{version && <VersionLabel className="ml-2" />}
</div>
</div>
</div>
);
);
};
@@ -47,7 +47,7 @@ export const LLMApiKeyComponent = (p: {
<Label className="text-xs font-semibold">API key</Label>
<div>
{apiKey ? (
<Link href={`/project/${p.projectId}/settings#llm-api-keys`}>
<Link href={`/project/${p.projectId}/settings/api-keys`}>
<span className="mr-2 rounded-sm bg-input p-1 text-xs">
{apiKey.displaySecretKey}
</span>
+31 -5
View File
@@ -18,6 +18,7 @@ import {
} from "@langfuse/shared";
import { LLMApiKeyComponent } from "./LLMApiKeyComponent";
import { FormDescription } from "@/src/components/ui/form";
export type ModelParamsContext = {
modelParams: UIModelParams;
@@ -29,15 +30,20 @@ export type ModelParamsContext = {
) => void;
setModelParamEnabled?: (key: keyof UIModelParams, enabled: boolean) => void;
formDisabled?: boolean;
modelParamsDescription?: string;
};
export const ModelParameters: React.FC<ModelParamsContext> = ({
export const ModelParameters: React.FC<
ModelParamsContext & { evalModelsOnly: boolean }
> = ({
modelParams,
availableProviders,
availableModels,
updateModelParamValue,
setModelParamEnabled,
evalModelsOnly,
formDisabled = false,
modelParamsDescription,
}) => {
const projectId = useProjectIdFromURL();
@@ -48,11 +54,11 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
<p className="font-semibold">Model</p>
{availableProviders.length === 0 ? (
<>
<p className="text-sm">
No LLM API key set in project. For evals, we only support OpenAI so
far.
<p className="text-xs">
No LLM API key set in project.{" "}
{evalModelsOnly && "For evals, only OpenAI models are supported."}
</p>
<CreateLLMApiKeyDialog evalModelsOnly={true} />
<CreateLLMApiKeyDialog evalModelsOnly={evalModelsOnly} />
</>
) : (
<div className="space-y-4">
@@ -71,7 +77,22 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
value={modelParams.model.value}
options={availableModels}
updateModelParam={updateModelParamValue}
modelParamsDescription={modelParamsDescription}
/>
{modelParams.model.value?.startsWith("o1-") ? (
<p className="text-sm text-dark-yellow">
For {modelParams.model.value}, the system message and the
temperature, max_tokens and top_p setting are not supported while
it is in beta.{" "}
<a
href="https://platform.openai.com/docs/guides/reasoning/beta-limitations"
target="_blank"
rel="noreferrer noopener"
>
More info
</a>
</p>
) : null}
<ModelParamsSlider
title="Temperature"
modelParamsKey="temperature"
@@ -125,6 +146,7 @@ type ModelParamsSelectProps = {
options: string[];
updateModelParam: ModelParamsContext["updateModelParamValue"];
disabled?: boolean;
modelParamsDescription?: string;
};
const ModelParamsSelect = ({
title,
@@ -133,6 +155,7 @@ const ModelParamsSelect = ({
options,
updateModelParam,
disabled,
modelParamsDescription,
}: ModelParamsSelectProps) => {
return (
<div className="space-y-2">
@@ -165,6 +188,9 @@ const ModelParamsSelect = ({
))}
</SelectContent>
</Select>
{modelParamsDescription ? (
<FormDescription>{modelParamsDescription}</FormDescription>
) : undefined}
</div>
);
};
@@ -49,7 +49,7 @@ export const PagedSettingsContainer = ({
return (
<main className="flex flex-1 flex-col gap-4 py-4 md:gap-8">
<div className="grid w-full items-start gap-4 md:grid-cols-[180px_1fr] lg:grid-cols-[220px_1fr]">
<div className="grid w-full items-start gap-4 md:grid-cols-[150px_1fr] lg:grid-cols-[220px_1fr]">
<nav className="block md:hidden">
<Select
onValueChange={(slug) => {
+2 -2
View File
@@ -11,11 +11,11 @@ export const SettingsDangerZone: React.FC<{
return (
<div className="space-y-3">
<Header title="Danger Zone" level="h3" />
<div className="rounded border">
<div className="rounded-lg border">
{items.map((item, index) => (
<div
key={index}
className="flex items-center justify-between gap-4 border-b p-4 last:border-b-0"
className="flex items-center justify-between gap-4 border-b p-3 last:border-b-0"
>
<div>
<h4 className="font-semibold">{item.title}</h4>
+16 -1
View File
@@ -2,6 +2,7 @@ import {
BadgeCheck,
Github,
HardDriveDownload,
Info,
Map,
Newspaper,
} from "lucide-react";
@@ -20,6 +21,7 @@ import { api } from "@/src/utils/api";
import { Button } from "@/src/components/ui/button";
import { useIsEeEnabled } from "@/src/ee/utils/useIsEeEnabled";
import { env } from "@/src/env.mjs";
import { cn } from "@/src/utils/tailwind";
export const VersionLabel = ({ className }: { className?: string }) => {
const checkUpdate = api.public.checkUpdate.useQuery(undefined, {
@@ -30,6 +32,7 @@ export const VersionLabel = ({ className }: { className?: string }) => {
onError: (error) => console.error("checkUpdate error", error), // do not render default error message
});
const isEeVersion = useIsEeEnabled();
const isLangfuseCloud = Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
const hasUpdate =
!env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION &&
@@ -46,8 +49,9 @@ export const VersionLabel = ({ className }: { className?: string }) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="xs" className={className}>
<Button variant="ghost" size="xs" className={cn("text-xs",className)}>
{VERSION}
{!isLangfuseCloud && (isEeVersion ? " EE" : " OSS")}
{hasUpdate && <ArrowUp className={`ml-1 h-3 w-3 ${color}`} />}
</Button>
</DropdownMenuTrigger>
@@ -96,6 +100,17 @@ export const VersionLabel = ({ className }: { className?: string }) => {
Roadmap
</Link>
</DropdownMenuItem>
{!isLangfuseCloud && !isEeVersion && (
<DropdownMenuItem asChild>
<Link
href="https://langfuse.com/docs/deployment/feature-overview"
target="_blank"
>
<Info size={16} className="mr-2" />
Compare Versions
</Link>
</DropdownMenuItem>
)}
{hasUpdate && (
<>
<DropdownMenuSeparator />
+12 -15
View File
@@ -9,15 +9,14 @@ import {
} from "@/src/components/ui/select";
import {
DEFAULT_AGGREGATION_SELECTION,
tableDateRangeAggregationSettings,
dashboardDateRangeAggregationSettings,
DASHBOARD_AGGREGATION_PLACEHOLDER,
type DashboardDateRangeOptions,
type TableDateRangeOptions,
DASHBOARD_AGGREGATION_OPTIONS,
type DashboardDateRange,
TABLE_RANGE_DROPDOWN_OPTIONS,
TABLE_AGGREGATION_OPTIONS,
getDateFromOption,
} from "@/src/utils/date-range-utils";
import { Clock } from "lucide-react";
@@ -101,24 +100,22 @@ export const TableDateRangeDropdown: React.FC<TableDateRangeDropdownProps> = ({
setDateRangeAndOption,
}) => {
const onDropDownSelection = (value: TableDateRangeOptions) => {
if (value === DEFAULT_AGGREGATION_SELECTION) {
setDateRangeAndOption(DEFAULT_AGGREGATION_SELECTION, undefined);
return;
}
const setting =
tableDateRangeAggregationSettings[
value as keyof typeof tableDateRangeAggregationSettings
];
setDateRangeAndOption(value, {
from: addMinutes(new Date(), -setting),
to: new Date(),
const dateFromOption = getDateFromOption({
filterSource: "TABLE",
option: value,
});
const initialDateRange = !!dateFromOption
? { from: dateFromOption, to: new Date() }
: undefined;
setDateRangeAndOption(value, initialDateRange);
};
return (
<BaseDateRangeDropdown
selectedOption={selectedOption}
options={TABLE_RANGE_DROPDOWN_OPTIONS}
options={TABLE_AGGREGATION_OPTIONS}
onSelectionChange={onDropDownSelection}
/>
);
+1 -1
View File
@@ -45,7 +45,7 @@ export function JsonEditor({
if (onChange) onChange(c);
setLinterEnabled(c !== "");
}}
className={cn("overflow-hidden rounded-md border", className)}
className={cn("overflow-hidden rounded-md border text-xs", className)}
editable={editable}
/>
);
@@ -1,8 +1,27 @@
export const FullScreenPage: React.FC<React.PropsWithChildren<{}>> = ({
import { cn } from "@/src/utils/tailwind";
export const FullScreenPage: React.FC<
React.PropsWithChildren<{
mobile?: boolean;
className?: string;
lgHeight?: string;
mobileHeight?: string;
}>
> = ({
children,
mobile = true,
className,
lgHeight = "lg:h-[calc(100dvh-1.5rem)]",
mobileHeight = "h-[calc(100dvh-6rem)]",
}) => {
return (
<div className="flex h-[calc(100dvh-6rem)] flex-col overflow-hidden lg:h-[calc(100dvh-2rem)]">
<div
className={cn(
`flex flex-col overflow-hidden ${lgHeight}`,
mobile && mobileHeight,
className,
)}
>
{children}
</div>
);
+14 -4
View File
@@ -35,6 +35,7 @@ import {
createOrganizationRoute,
createProjectRoute,
} from "@/src/features/setup/setupRoutes";
import { isCloudPlan, planLabels } from "@langfuse/shared";
export default function Header({
level = "h2",
@@ -170,8 +171,17 @@ const BreadcrumbComponent = ({
<BreadcrumbList>
{organization && (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1">
<DropdownMenuTrigger className="flex items-center gap-1 text-sm text-primary">
{organization?.name ?? "Organization"}
{isCloudPlan(organization?.plan) &&
organization.id !== env.NEXT_PUBLIC_DEMO_ORG_ID && (
<Badge
className="ml-1 px-1 py-0 text-xs font-normal"
variant="secondary"
>
{planLabels[organization.plan]}
</Badge>
)}
<ChevronDownIcon className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
@@ -212,7 +222,7 @@ const BreadcrumbComponent = ({
asChild
variant="ghost"
size="xs"
className="-my-1 ml-4 mr-1 hover:bg-background"
className="-my-1 ml-4 hover:bg-background"
>
<div
onClick={(e) => {
@@ -265,7 +275,7 @@ const BreadcrumbComponent = ({
<Slash />
</BreadcrumbSeparator>
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1">
<DropdownMenuTrigger className="flex items-center gap-1 text-primary">
{project?.name ?? "Project"}
<ChevronDownIcon className="h-4 w-4" />
</DropdownMenuTrigger>
@@ -299,7 +309,7 @@ const BreadcrumbComponent = ({
asChild
variant="ghost"
size="xs"
className="-my-1 ml-4 mr-1 hover:bg-background"
className="-my-1 ml-4 hover:bg-background"
>
<div
onClick={(e) => {
+71 -41
View File
@@ -33,14 +33,13 @@ import { EnvLabel } from "@/src/components/EnvLabel";
import { useQueryProjectOrOrganization } from "@/src/features/projects/hooks";
import { useOrgEntitlements } from "@/src/features/entitlements/hooks";
import { useUiCustomization } from "@/src/ee/features/ui-customization/useUiCustomization";
import { hasOrganizationAccess } from "@/src/features/rbac/utils/checkOrganizationAccess";
const signOutUser = async () => {
localStorage.clear();
sessionStorage.clear();
await signOut({
callbackUrl: "/auth/sign-in",
});
await signOut();
};
const userNavigation = [
@@ -118,6 +117,10 @@ export default function Layout(props: PropsWithChildren) {
const uiCustomization = useUiCustomization();
const cloudAdmin =
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined &&
session.data?.user?.admin === true;
// project info based on projectId in the URL
const { project, organization } = useQueryProjectOrOrganization();
@@ -134,7 +137,7 @@ export default function Layout(props: PropsWithChildren) {
if (
route.featureFlag !== undefined &&
!enableExperimentalFeatures &&
session.data?.user?.admin !== true &&
!cloudAdmin &&
session.data?.user?.featureFlags[route.featureFlag] !== true
)
return null;
@@ -143,14 +146,14 @@ export default function Layout(props: PropsWithChildren) {
if (
route.entitlement !== undefined &&
!entitlements.includes(route.entitlement) &&
session.data?.user?.admin !== true
!cloudAdmin
)
return null;
// RBAC
if (
route.projectRbacScope !== undefined &&
session.data?.user?.admin !== true &&
!cloudAdmin &&
(!project ||
!organization ||
!hasProjectAccess({
@@ -160,6 +163,21 @@ export default function Layout(props: PropsWithChildren) {
}))
)
return null;
if (
route.organizationRbacScope !== undefined &&
!cloudAdmin &&
(!organization ||
!hasOrganizationAccess({
organizationId: organization.id,
scope: route.organizationRbacScope,
session: session.data,
}))
)
return null;
// check show function
if (route.show && !route.show({ organization: organization ?? undefined }))
return null;
// apply to children as well
const children: (NavigationItem | null)[] =
@@ -346,7 +364,7 @@ export default function Layout(props: PropsWithChildren) {
</Transition.Root>
{/* Static sidebar for desktop */}
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-56 lg:flex-col">
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-52 lg:flex-col">
{/* Sidebar component, swap this element with another sidebar if you like */}
<div className="flex h-screen grow flex-col border-r border-border bg-background">
<nav className="flex h-full flex-1 flex-col overflow-y-auto px-4 py-3">
@@ -418,7 +436,7 @@ export default function Layout(props: PropsWithChildren) {
>
<Menu.Items className="absolute -top-full bottom-1 right-0 z-10 overflow-hidden rounded-md bg-background py-2 shadow-lg ring-1 ring-border focus:outline-none">
<span
className="block max-w-56 overflow-hidden truncate border-b px-3 pb-2 text-sm leading-6 text-muted-foreground"
className="block max-w-52 overflow-hidden truncate border-b px-3 pb-2 text-sm leading-6 text-muted-foreground"
title={session.data?.user?.email ?? undefined}
>
{session.data?.user?.email}
@@ -486,7 +504,7 @@ export default function Layout(props: PropsWithChildren) {
>
<Menu.Items className="absolute right-0 z-10 mt-2.5 rounded-md bg-background py-2 pb-1 shadow-lg ring-1 ring-border focus:outline-none">
<span
className="mb-1 block max-w-56 overflow-hidden truncate border-b px-3 pb-2 text-sm leading-6 text-muted-foreground"
className="mb-1 block max-w-52 overflow-hidden truncate border-b px-3 pb-2 text-sm leading-6 text-muted-foreground"
title={session.data?.user?.email ?? undefined}
>
{session.data?.user?.email}
@@ -511,12 +529,12 @@ export default function Layout(props: PropsWithChildren) {
</Transition>
</Menu>
</div>
<div className="lg:pl-56">
<div className="lg:pl-52">
{env.NEXT_PUBLIC_DEMO_ORG_ID &&
env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
routerProjectId === env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) ? (
<div className="flex w-full items-center border-b border-dark-yellow bg-light-yellow px-4 py-2 lg:sticky lg:top-0 lg:z-40">
<div className="flex w-full items-center border-b border-dark-yellow bg-light-yellow px-4 py-2 lg:sticky lg:top-0 lg:z-40">
<div className="flex flex-1 flex-wrap gap-1">
<div className="flex items-center gap-1">
<Info className="h-4 w-4" />
@@ -532,7 +550,7 @@ export default function Layout(props: PropsWithChildren) {
</Button>
</div>
) : null}
<main className="p-4">{props.children}</main>
<main className="p-3">{props.children}</main>
<Toaster visibleToasts={1} />
</div>
</div>
@@ -559,6 +577,8 @@ const MainNavigation: React.FC<{
{},
);
const uiCustomization = useUiCustomization();
return (
<li className={className}>
<ul role="list" className="-mx-2 space-y-1">
@@ -576,34 +596,44 @@ const MainNavigation: React.FC<{
onClick={onNavitemClick}
target={item.newTab ? "_blank" : undefined}
>
{item.icon && (
<item.icon
className={clsx(
item.current
? "text-primary-accent"
: "text-muted-foreground group-hover:text-primary-accent",
"h-5 w-5 shrink-0",
{item.pathname === "/" &&
uiCustomization?.logoLightModeHref &&
uiCustomization?.logoDarkModeHref ? (
// override the default logo with the uiCustomization logo if the pathname is "/"
<LangfuseLogo size="sm" version />
) : (
// default node for all other routes
<>
{item.icon && (
<item.icon
className={clsx(
item.current
? "text-primary-accent"
: "text-muted-foreground group-hover:text-primary-accent",
"h-5 w-5 shrink-0",
)}
aria-hidden="true"
/>
)}
aria-hidden="true"
/>
{item.name}
{item.label &&
(typeof item.label === "string" ? (
<span
className={cn(
"-my-0.5 self-center whitespace-nowrap break-keep rounded-sm border px-1 py-0.5 text-xs",
item.current
? "border-primary-accent text-primary-accent"
: "border-border text-muted-foreground group-hover:border-primary-accent group-hover:text-primary-accent",
)}
>
{item.label}
</span>
) : (
// ReactNode
item.label
))}
</>
)}
{item.name}
{item.label &&
(typeof item.label === "string" ? (
<span
className={cn(
"-my-0.5 self-center whitespace-nowrap break-keep rounded-sm border px-1 py-0.5 text-xs",
item.current
? "border-primary-accent text-primary-accent"
: "border-border text-muted-foreground group-hover:border-primary-accent group-hover:text-primary-accent",
)}
>
{item.label}
</span>
) : (
// ReactNode
item.label
))}
</Link>
) : item.children && item.children.length > 0 ? (
<Disclosure
@@ -634,7 +664,7 @@ const MainNavigation: React.FC<{
{item.label && (
<span
className={cn(
"-my-0.5 self-center whitespace-nowrap break-keep rounded-sm border px-1 py-0.5 text-xs",
"-my-0.5 self-center whitespace-nowrap break-keep rounded-sm border px-1 text-xs",
item.current
? "border-primary-accent text-primary-accent"
: "border-border text-muted-foreground group-hover:border-primary-accent group-hover:text-primary-accent",
@@ -648,7 +678,7 @@ const MainNavigation: React.FC<{
open
? "rotate-90 text-muted-foreground"
: "text-muted-foreground",
"ml-auto h-5 w-5 shrink-0",
"ml-auto h-4 w-4 shrink-0",
)}
aria-hidden="true"
/>
@@ -663,7 +693,7 @@ const MainNavigation: React.FC<{
subItem.current
? "bg-primary-foreground text-primary-accent"
: "text-primary hover:bg-primary-foreground hover:text-primary-accent",
"ml-0.5 flex w-full items-center gap-x-3 rounded-md p-1.5 pl-7 pr-2 text-sm",
"ml-0.5 flex w-full items-center gap-x-3 rounded-md p-1 pl-7 pr-2 text-sm",
)}
target={subItem.newTab ? "_blank" : undefined}
>
+30 -1
View File
@@ -13,18 +13,23 @@ import {
TerminalIcon,
Lightbulb,
Grid2X2,
Sparkle,
} from "lucide-react";
import { LangfuseIcon } from "@/src/components/LangfuseLogo";
import { type ReactNode } from "react";
import { VersionLabel } from "@/src/components/VersionLabel";
import { type Entitlement } from "@/src/features/entitlements/constants/entitlements";
import { type UiCustomizationOption } from "@/src/ee/features/ui-customization/useUiCustomization";
import { type User } from "next-auth";
import { type OrganizationScope } from "@/src/features/rbac/constants/organizationAccessRights";
import { UsageTracker } from "@/src/ee/features/billing/components/UsageTracker";
export type Route = {
name: string;
featureFlag?: Flag;
label?: string | ReactNode;
projectRbacScope?: ProjectScope;
organizationRbacScope?: OrganizationScope;
icon?: LucideIcon | typeof LangfuseIcon; // ignored for nested routes
pathname?: string; // link, ignored if children
children?: Array<Route>; // folder
@@ -32,6 +37,9 @@ export type Route = {
newTab?: boolean; // open in new tab
entitlement?: Entitlement; // entitlement required
customizableHref?: UiCustomizationOption; // key of useUiCustomization object to use to replace the href
show?: (p: {
organization: User["organizations"][number] | undefined;
}) => boolean;
};
export const ROUTES: Route[] = [
@@ -39,7 +47,8 @@ export const ROUTES: Route[] = [
name: "Langfuse",
pathname: "/",
icon: LangfuseIcon,
label: <VersionLabel />,
label: <VersionLabel className="-ml-3" />,
// node is overridden in layout.tsx if uiCustomization.logoLightModeHref and uiCustomization.logoDarkModeHref are set
},
{
name: "Projects",
@@ -125,6 +134,26 @@ export const ROUTES: Route[] = [
pathname: `/project/[projectId]/datasets`,
icon: Database,
},
{
name: "Upgrade",
icon: Sparkle,
pathname: "/project/[projectId]/settings/billing",
bottom: true,
entitlement: "cloud-billing",
organizationRbacScope: "langfuseCloudBilling:CRUD",
show: ({ organization }) => organization?.plan === "cloud:hobby",
label: <UsageTracker />,
},
{
name: "Upgrade",
icon: Sparkle,
pathname: "/organization/[organizationId]/settings/billing",
bottom: true,
entitlement: "cloud-billing",
organizationRbacScope: "langfuseCloudBilling:CRUD",
show: ({ organization }) => organization?.plan === "cloud:hobby",
label: <UsageTracker />,
},
{
name: "Settings",
pathname: "/project/[projectId]/settings",
+3 -3
View File
@@ -53,10 +53,10 @@ export const SessionPage: React.FC<{
string[]
>("emptySelectedConfigIds", []);
const commentCounts = api.comments.getCountsByObjectIds.useQuery(
const commentCounts = api.comments.getCountByObjectId.useQuery(
{
projectId,
objectIds: [sessionId],
objectId: sessionId,
objectType: "SESSION",
},
{ enabled: session.isSuccess },
@@ -196,7 +196,7 @@ const SessionIO = ({
},
);
return (
<div className="col-span-2 grid grid-flow-row gap-2 p-0">
<div className="col-span-2 flex flex-col gap-2 p-0">
{!trace.data ? (
<JsonSkeleton
className="h-full w-full overflow-hidden px-2 py-1"
+26 -15
View File
@@ -13,13 +13,13 @@ export function StarToggle({
value,
disabled = false,
onClick,
size = "sm",
size = "icon",
isLoading,
}: {
value: boolean;
disabled?: boolean;
onClick: (value: boolean) => Promise<unknown>;
size?: "sm" | "xs";
size?: "icon" | "icon-xs";
isLoading: boolean;
}) {
return (
@@ -40,18 +40,19 @@ export function StarToggle({
);
}
// use by the trace table
export function StarTraceToggle({
tracesFilter,
projectId,
traceId,
value,
size = "sm",
size = "icon",
}: {
tracesFilter: RouterInput["traces"]["all"];
projectId: string;
traceId: string;
value: boolean;
size?: "sm" | "xs";
size?: "icon" | "icon-xs";
}) {
const utils = api.useUtils();
const hasAccess = useHasProjectAccess({
@@ -89,7 +90,6 @@ export function StarTraceToggle({
tracesFilter,
(oldQueryData: RouterOutput["traces"]["all"] | undefined) => {
return {
totalCount: oldQueryData?.totalCount,
traces: oldQueryData?.traces
? oldQueryData.traces.map((trace) => {
return {
@@ -130,16 +130,17 @@ export function StarTraceToggle({
);
}
// use by the single trace view
export function StarTraceDetailsToggle({
projectId,
traceId,
value,
size = "sm",
size = "icon",
}: {
projectId: string;
traceId: string;
value: boolean;
size?: "sm" | "xs";
size?: "icon" | "icon-xs";
}) {
const utils = api.useUtils();
const hasAccess = useHasProjectAccess({
@@ -153,12 +154,15 @@ export function StarTraceDetailsToggle({
onMutate: async () => {
// Cancel any outgoing refetches
// (so they don't overwrite our optimistic update)
await utils.traces.byId.cancel();
await utils.traces.byIdWithObservationsAndScores.cancel();
setIsLoading(true);
// Snapshot the previous value
const prevData = utils.traces.byId.getData({ traceId, projectId });
const prevData = utils.traces.byIdWithObservationsAndScores.getData({
traceId,
projectId,
});
return { prevData };
},
@@ -166,14 +170,21 @@ export function StarTraceDetailsToggle({
setIsLoading(false);
trpcErrorToast(err);
// Rollback to the previous value if mutation fails
utils.traces.byId.setData({ traceId, projectId }, context?.prevData);
utils.traces.byIdWithObservationsAndScores.setData(
{ traceId, projectId },
context?.prevData,
);
},
onSettled: () => {
setIsLoading(false);
utils.traces.byId.setData(
utils.traces.byIdWithObservationsAndScores.setData(
{ traceId, projectId },
(oldQueryData: RouterOutput["traces"]["byId"] | undefined) => {
(
oldQueryData:
| RouterOutput["traces"]["byIdWithObservationsAndScores"]
| undefined,
) => {
return oldQueryData
? {
...oldQueryData,
@@ -182,7 +193,7 @@ export function StarTraceDetailsToggle({
: undefined;
},
);
void utils.traces.byId.invalidate();
void utils.traces.byIdWithObservationsAndScores.invalidate();
void utils.traces.all.invalidate();
},
});
@@ -212,12 +223,12 @@ export function StarSessionToggle({
projectId,
sessionId,
value,
size = "sm",
size = "icon",
}: {
projectId: string;
sessionId: string;
value: boolean;
size?: "sm" | "xs";
size?: "icon" | "icon-xs";
}) {
const utils = api.useUtils();
const hasAccess = useHasProjectAccess({
@@ -10,19 +10,53 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuCheckboxItem,
DropdownMenuSeparator,
DropdownMenuLabel,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuPortal,
DropdownMenuSubContent,
} from "@/src/components/ui/dropdown-menu";
import { type VisibilityState } from "@tanstack/react-table";
import { ChevronDown, Columns } from "lucide-react";
import {
type ColumnOrderState,
type VisibilityState,
} from "@tanstack/react-table";
import {
Check,
ChevronDown,
ChevronRight,
Columns,
Component,
Menu,
} from "lucide-react";
import { type LangfuseColumnDef } from "@/src/components/table/types";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import DocPopup from "@/src/components/layouts/doc-popup";
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/src/utils/tailwind";
import { isString } from "@/src/utils/types";
interface DataTableColumnVisibilityFilterProps<TData, TValue> {
columns: LangfuseColumnDef<TData, TValue>[];
columnVisibility: VisibilityState;
setColumnVisibility: Dispatch<SetStateAction<VisibilityState>>;
columnOrder?: ColumnOrderState;
setColumnOrder?: Dispatch<SetStateAction<ColumnOrderState>>;
}
const calculateColumnCounts = <TData, TValue>(
@@ -57,18 +91,45 @@ function ColumnVisibilityDropdownItem<TData, TValue>({
column,
toggleColumn,
columnVisibility,
isOrderable = false,
}: {
column: LangfuseColumnDef<TData, TValue>;
toggleColumn: (columnId: string) => void;
columnVisibility: VisibilityState;
isOrderable?: boolean;
}) {
if (column.enableHiding) {
return (
<DropdownMenuCheckboxItem
checked={columnVisibility[column.accessorKey]}
onCheckedChange={() => toggleColumn(column.accessorKey)}
>
<span className="capitalize">
const { attributes, isDragging, listeners, setNodeRef, transform } =
useSortable({
id: column.accessorKey,
});
return (
<DropdownMenuCheckboxItem
checked={columnVisibility[column.accessorKey] && column.enableHiding}
onCheckedChange={() => {
if (column.enableHiding) toggleColumn(column.accessorKey);
}}
ref={setNodeRef}
className={cn(
isDragging ? "opacity-80" : "opacity-100",
"group whitespace-nowrap",
)}
style={{
transform: transform ? CSS.Translate.toString(transform) : "none",
transition: "width transform 0.2s ease-in-out",
zIndex: isDragging ? 1 : undefined,
}}
>
{!column.enableHiding && (
<Check className="absolute left-2 h-4 w-4 opacity-50" />
)}
<div className="mr-1">
<span
className="capitalize"
title={
!column.enableHiding ? "This column may not be hidden" : undefined
}
>
{column.header && typeof column.header === "string"
? column.header
: column.accessorKey}
@@ -79,15 +140,78 @@ function ColumnVisibilityDropdownItem<TData, TValue>({
href={column.headerTooltip.href}
/>
)}
</DropdownMenuCheckboxItem>
);
}
</div>
{isOrderable && (
<Button
{...attributes}
{...listeners}
variant="ghost"
size="xs"
title="Drag and drop to reorder columns"
className="invisible ml-auto group-hover:visible"
>
<Menu className="h-3 w-3" />
</Button>
)}
</DropdownMenuCheckboxItem>
);
}
function GroupVisibilityDropdownHeader<TData, TValue>({
column,
}: {
column: LangfuseColumnDef<TData, TValue>;
}) {
const { attributes, isDragging, listeners, setNodeRef, transform } =
useSortable({
id: column.accessorKey,
});
return (
<DropdownMenuSubTrigger
hasCustomIcon
ref={setNodeRef}
className={cn(
isDragging ? "opacity-80" : "opacity-100",
"group flex w-full items-center justify-between whitespace-nowrap",
)}
style={{
transform: transform ? CSS.Translate.toString(transform) : "none",
transition: "width transform 0.2s ease-in-out",
zIndex: isDragging ? 1 : undefined,
}}
>
<div className="flex items-center">
<Component className="mr-2 h-4 w-4 opacity-50" />
<span>
{column.header && typeof column.header === "string"
? column.header
: column.accessorKey}
</span>
</div>
<div className="flex items-center">
<Button
{...attributes}
{...listeners}
variant="ghost"
size="xs"
title="Drag and drop to reorder columns"
className="invisible group-hover:visible"
>
<Menu className="h-3 w-3" />
</Button>
<ChevronRight className="h-4 w-4" />
</div>
</DropdownMenuSubTrigger>
);
}
export function DataTableColumnVisibilityFilter<TData, TValue>({
columns,
columnVisibility,
setColumnVisibility,
columnOrder,
setColumnOrder,
}: DataTableColumnVisibilityFilterProps<TData, TValue>) {
const [isOpen, setIsOpen] = useState(false);
const capture = usePostHogClientCapture();
@@ -110,62 +234,115 @@ export function DataTableColumnVisibilityFilter<TData, TValue>({
// eslint-disable-next-line react-hooks/exhaustive-deps
[setColumnVisibility],
);
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {}),
);
const { count, total } = calculateColumnCounts(columns, columnVisibility);
const columnIdsOrder = columnOrder ?? columns.map((col) => col.accessorKey);
const isColumnOrderingEnabled = !!setColumnOrder;
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (active && over && active.id !== over.id) {
const overColumn = columns.find((col) => col.accessorKey === over.id);
if (overColumn?.isPinned) {
return;
}
if (isString(active.id) && isString(over.id)) {
setColumnOrder!((columnOrder) => {
const oldIndex = columnOrder.indexOf(active.id as string);
const newIndex = columnOrder.indexOf(over.id as string);
return arrayMove(columnOrder, oldIndex, newIndex);
});
}
}
}
return (
<DropdownMenu open={isOpen}>
<DropdownMenuTrigger
onClick={() => {
setIsOpen(!isOpen);
}}
className="select-none"
asChild
>
<Button variant="outline" title="Show/hide columns">
<Columns className="mr-2 h-4 w-4" />
<span className="text-xs text-muted-foreground">{`(${count}/${total})`}</span>
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onPointerDownOutside={() => setIsOpen(false)}
className="max-h-96 overflow-y-auto"
>
{columns.map((column, index) => {
if (!!column.columns && Boolean(column.columns.length)) {
const isFollowingGroup = "columns" in (columns[index - 1] ?? {});
return (
<div key={index}>
{!isFollowingGroup && <DropdownMenuSeparator />}
<DropdownMenuLabel>
{column.header && typeof column.header === "string"
? column.header
: column.accessorKey}
</DropdownMenuLabel>
{column.columns.map((column) => (
<ColumnVisibilityDropdownItem
key={column.accessorKey}
column={column}
columnVisibility={columnVisibility}
toggleColumn={toggleColumn}
/>
))}
<DropdownMenuSeparator />
</div>
);
} else
return (
<ColumnVisibilityDropdownItem
key={column.accessorKey}
column={column}
columnVisibility={columnVisibility}
toggleColumn={toggleColumn}
/>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={isColumnOrderingEnabled ? handleDragEnd : undefined}
sensors={sensors}
>
<DropdownMenu open={isOpen}>
<DropdownMenuTrigger
onClick={() => {
setIsOpen(!isOpen);
}}
className="select-none"
asChild
>
<Button variant="outline" title="Show/hide columns">
<Columns className="mr-2 h-4 w-4" />
<span className="text-xs text-muted-foreground">{`(${count}/${total})`}</span>
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onPointerDownOutside={() => setIsOpen(false)}
className="max-h-96 overflow-y-auto"
>
<SortableContext
items={columnIdsOrder}
strategy={verticalListSortingStrategy}
>
{columnIdsOrder.map((columnId, index) => {
const column = columns.find(
(col) => col.accessorKey === columnId,
);
if (column) {
if (!!column.columns && Boolean(column.columns.length)) {
return (
<DropdownMenuSub key={index}>
{isColumnOrderingEnabled ? (
<GroupVisibilityDropdownHeader column={column} />
) : (
<DropdownMenuSubTrigger hasCustomIcon>
<Component className="mr-2 h-4 w-4 opacity-50" />
<span>
{column.header && typeof column.header === "string"
? column.header
: column.accessorKey}
</span>
</DropdownMenuSubTrigger>
)}
<DropdownMenuPortal>
<DropdownMenuSubContent>
{column.columns.map((col) => (
<ColumnVisibilityDropdownItem
key={col.accessorKey}
column={col}
columnVisibility={columnVisibility}
toggleColumn={toggleColumn}
isOrderable={false} // grouped columns are not orderable, group may only be ordered as a whole
/>
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
);
} else if (!column.isPinned)
return (
<ColumnVisibilityDropdownItem
key={column.accessorKey}
column={column}
columnVisibility={columnVisibility}
toggleColumn={toggleColumn}
isOrderable={isColumnOrderingEnabled}
/>
);
}
return null;
})}
</SortableContext>
</DropdownMenuContent>
</DropdownMenu>
</DndContext>
);
}
@@ -15,6 +15,9 @@ import {
SelectValue,
} from "@/src/components/ui/select";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { LoaderCircle } from "lucide-react";
import { Input } from "@/src/components/ui/input";
import { useEffect, useState } from "react";
interface DataTablePaginationProps<TData> {
table: Table<TData>;
@@ -26,6 +29,22 @@ export function DataTablePagination<TData>({
paginationOptions = [10, 20, 30, 40, 50],
}: DataTablePaginationProps<TData>) {
const capture = usePostHogClientCapture();
const currentPage = table.getState().pagination.pageIndex + 1;
const [inputState, setInputState] = useState<number | string>(currentPage);
useEffect(() => {
setInputState(currentPage);
}, [currentPage]);
const pageCount = table.getPageCount();
const setPageIndex = table.setPageIndex;
useEffect(() => {
if (currentPage > pageCount && pageCount > 0) {
setPageIndex(0);
}
}, [currentPage, pageCount, setPageIndex]);
return (
<div className="flex items-center justify-between">
<div className="flex-1 text-sm text-muted-foreground">
@@ -61,10 +80,60 @@ export function DataTablePagination<TData>({
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-center whitespace-nowrap text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{Math.max(table.getPageCount(), 1)}
<div className="flex items-center justify-center gap-1 whitespace-nowrap text-sm font-medium">
{table.getPageCount() !== -1 ? (
<>
Page
<Input
type="number"
min={1}
max={pageCount}
value={inputState} // Ensure the value is within bounds
onChange={(e) => {
setInputState(e.target.value);
}}
onBlur={(e) => {
const newValue = e.target.value;
if (newValue === "") {
table.setPageIndex(0);
setInputState(1);
return;
}
// if nan, reset to current page
if (isNaN(Number(newValue))) {
setInputState(currentPage);
return;
}
const newPageIndex = Number(newValue) - 1;
if (newPageIndex < 0 || newPageIndex >= pageCount) {
setInputState(currentPage);
return;
}
table.setPageIndex(newPageIndex);
setInputState(newPageIndex + 1);
}}
className="h-8 appearance-none"
style={{
width: `${3 + Math.max(1, pageCount.toString().length)}ch`,
}}
/>
</>
) : (
`Page ${currentPage}`
)}
{pageCount !== -1 ? (
<span>of {pageCount}</span>
) : (
<span>
of{" "}
<LoaderCircle className="ml-1 inline-block h-3 w-3 animate-spin text-muted-foreground" />
</span>
)}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
@@ -112,12 +181,12 @@ export function DataTablePagination<TData>({
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => {
table.setPageIndex(table.getPageCount() - 1);
table.setPageIndex(pageCount - 1);
capture("table:pagination_button_click", {
type: "lastPage",
});
}}
disabled={!table.getCanNextPage()}
disabled={!table.getCanNextPage() || pageCount === -1}
>
<span className="sr-only">Go to last page</span>
<DoubleArrowRightIcon className="h-4 w-4" />
@@ -5,7 +5,10 @@ import { DataTableColumnVisibilityFilter } from "@/src/components/table/data-tab
import { type FilterState } from "@langfuse/shared";
import { PopoverFilterBuilder } from "@/src/features/filters/components/filter-builder";
import { type ColumnDefinition } from "@langfuse/shared";
import { type VisibilityState } from "@tanstack/react-table";
import {
type ColumnOrderState,
type VisibilityState,
} from "@tanstack/react-table";
import { type LangfuseColumnDef } from "@/src/components/table/types";
import {
DataTableRowHeightSwitch,
@@ -31,9 +34,13 @@ interface DataTableToolbarProps<TData, TValue> {
searchConfig?: SearchConfig;
actionButtons?: React.ReactNode;
filterState?: FilterState;
setFilterState?: Dispatch<SetStateAction<FilterState>>;
setFilterState?:
| Dispatch<SetStateAction<FilterState>>
| ((newState: FilterState) => void);
columnVisibility?: VisibilityState;
setColumnVisibility?: Dispatch<SetStateAction<VisibilityState>>;
columnOrder?: ColumnOrderState;
setColumnOrder?: Dispatch<SetStateAction<ColumnOrderState>>;
rowHeight?: RowHeight;
setRowHeight?: Dispatch<SetStateAction<RowHeight>>;
columnsWithCustomSelect?: string[];
@@ -53,6 +60,8 @@ export function DataTableToolbar<TData, TValue>({
setFilterState,
columnVisibility,
setColumnVisibility,
columnOrder,
setColumnOrder,
rowHeight,
setRowHeight,
columnsWithCustomSelect,
@@ -79,7 +88,7 @@ export function DataTableToolbar<TData, TValue>({
searchConfig.updateQuery(searchString);
}
}}
className="h-10 w-[150px] rounded-r-none @6xl:w-[250px]"
className="w-[150px] rounded-r-none @6xl:w-[250px]"
/>
<Button
variant="outline"
@@ -113,6 +122,8 @@ export function DataTableToolbar<TData, TValue>({
columns={columns}
columnVisibility={columnVisibility}
setColumnVisibility={setColumnVisibility}
columnOrder={columnOrder}
setColumnOrder={setColumnOrder}
/>
)}
{!!rowHeight && !!setRowHeight && (
+81 -38
View File
@@ -21,6 +21,7 @@ import {
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { cn } from "@/src/utils/tailwind";
import {
type ColumnOrderState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
@@ -36,7 +37,7 @@ interface DataTableProps<TData, TValue> {
columns: LangfuseColumnDef<TData, TValue>[];
data: AsyncTableData<TData[]>;
pagination?: {
pageCount: number;
totalCount: number | null; // null if loading
onChange: OnChangeFn<PaginationState>;
state: PaginationState;
options?: number[];
@@ -45,6 +46,8 @@ interface DataTableProps<TData, TValue> {
setRowSelection?: OnChangeFn<RowSelectionState>;
columnVisibility?: VisibilityState;
onColumnVisibilityChange?: OnChangeFn<VisibilityState>;
columnOrder?: ColumnOrderState;
onColumnOrderChange?: OnChangeFn<ColumnOrderState>;
orderBy?: OrderByState;
setOrderBy?: (s: OrderByState) => void;
help?: { description: string; href: string };
@@ -62,6 +65,18 @@ export interface AsyncTableData<T> {
error?: string;
}
function insertArrayAfterKey(array: string[], toInsert: Map<string, string[]>) {
return array.reduce<string[]>((acc, key) => {
if (toInsert.has(key)) {
acc.push(...toInsert.get(key)!);
} else {
acc.push(key);
}
return acc;
}, []);
}
export function DataTable<TData extends object, TValue>({
columns,
data,
@@ -70,6 +85,8 @@ export function DataTable<TData extends object, TValue>({
setRowSelection,
columnVisibility,
onColumnVisibilityChange,
columnOrder,
onColumnOrderChange,
help,
orderBy,
setOrderBy,
@@ -83,14 +100,33 @@ export function DataTable<TData extends object, TValue>({
const rowheighttw = getRowHeightTailwindClass(rowHeight);
const capture = usePostHogClientCapture();
const flattedColumnsByGroup = useMemo(() => {
const flatColumnsByGroup = new Map<string, string[]>();
columns.forEach((col) => {
if (col.columns && Boolean(col.columns.length)) {
const children = col.columns.map((child) => child.accessorKey);
flatColumnsByGroup.set(col.accessorKey, children);
}
});
return flatColumnsByGroup;
}, [columns]);
const table = useReactTable({
data: data.data ?? [],
columns,
onColumnFiltersChange: setColumnFilters,
onColumnOrderChange: onColumnOrderChange,
getFilteredRowModel: getFilteredRowModel(),
getCoreRowModel: getCoreRowModel(),
manualPagination: pagination !== undefined,
pageCount: pagination?.pageCount ?? 0,
pageCount:
pagination?.totalCount === null ||
pagination?.state.pageSize === undefined
? -1
: Math.ceil(
Number(pagination?.totalCount) / pagination?.state.pageSize,
),
onPaginationChange: pagination?.onChange,
onRowSelectionChange: setRowSelection,
onColumnVisibilityChange: onColumnVisibilityChange,
@@ -105,6 +141,9 @@ export function DataTable<TData extends object, TValue>({
columnFilters,
pagination: pagination?.state,
columnVisibility,
columnOrder: columnOrder
? insertArrayAfterKey(columnOrder, flattedColumnsByGroup)
: undefined,
rowSelection,
},
manualFiltering: true,
@@ -165,6 +204,7 @@ export function DataTable<TData extends object, TValue>({
const columnDef = header.column
.columnDef as LangfuseColumnDef<ModelTableRow>;
const sortingEnabled = columnDef.enableSorting;
return header.column.getIsVisible() ? (
<TableHead
key={header.id}
@@ -175,9 +215,8 @@ export function DataTable<TData extends object, TValue>({
style={{
width: `calc(var(--header-${header.id}-size) * 1px)`,
}}
title={sortingEnabled ? "Sort by this column" : ""}
onClick={(event) => {
event.preventDefault(); // Add this line
event.preventDefault();
if (!setOrderBy || !columnDef.id || !sortingEnabled) {
return;
@@ -213,41 +252,40 @@ export function DataTable<TData extends object, TValue>({
}}
>
{header.isPlaceholder ? null : (
<>
<div className="flex select-none items-center">
<span className="truncate">
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</span>
{columnDef.headerTooltip && (
<DocPopup
description={
columnDef.headerTooltip.description
}
href={columnDef.headerTooltip.href}
/>
<div className="flex select-none items-center">
<span className="truncate">
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
{orderBy?.column === columnDef.id
? renderOrderingIndicator(orderBy)
: null}
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onDoubleClick={() => header.column.resetSize()}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={cn(
"absolute right-0 top-0 h-full w-1.5 cursor-col-resize touch-none select-none bg-secondary opacity-0 group-hover:opacity-100",
header.column.getIsResizing() &&
"bg-primary-accent opacity-100",
)}
</span>
{columnDef.headerTooltip && (
<DocPopup
description={
columnDef.headerTooltip.description
}
href={columnDef.headerTooltip.href}
/>
</div>
</>
)}
{orderBy?.column === columnDef.id
? renderOrderingIndicator(orderBy)
: null}
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onDoubleClick={() => header.column.resetSize()}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={cn(
"absolute right-0 top-0 h-full w-1.5 cursor-col-resize touch-none select-none bg-secondary opacity-0 group-hover:opacity-100",
header.column.getIsResizing() &&
"bg-primary-accent opacity-100",
)}
/>
</div>
)}
</TableHead>
) : null;
@@ -296,7 +334,12 @@ export function DataTable<TData extends object, TValue>({
function renderOrderingIndicator(orderBy?: OrderByState) {
if (!orderBy) return null;
if (orderBy.order === "ASC") return <span className="ml-1"></span>;
else return <span className="ml-1"></span>;
else
return (
<span className="ml-1" title="Sort by this column">
</span>
);
}
interface TableBodyComponentProps<TData> {
+1 -1
View File
@@ -17,7 +17,7 @@ export default function TableLink({
return (
<Link
className={cn(
"inline-block max-w-full overflow-hidden text-ellipsis text-nowrap rounded bg-primary-accent/20 px-2 py-1 text-xs font-semibold text-accent-dark-blue shadow-sm hover:bg-accent-light-blue/45",
"inline-block max-w-full overflow-hidden text-ellipsis text-nowrap rounded bg-primary-accent/20 px-2 py-0.5 text-xs font-semibold text-accent-dark-blue shadow-sm hover:bg-accent-light-blue/45",
className,
)}
href={path}

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