Compare commits

...
110 Commits
Author SHA1 Message Date
Marc Klingen 46b20f4107 chore: release v2.81.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-09-30 16:23:40 -07:00
Marc KlingenandGitHub fb8f26a00e feat(ee): add customizable default model adapter base urls (#3542) 2024-09-30 23:22:45 +00:00
Marc KlingenandGitHub 3d7ba475e2 chore(auth): reduce maxAge of password reset link to 10 minutes (#3541) 2024-09-30 21:40:18 +00:00
Marc KlingenandGitHub 03028a92b4 chore: upgrade fern openapi generator and cli (#3536) 2024-09-30 18:18:53 +02:00
Marc Klingen 8a96290f03 chore: release v2.81.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-09-27 17:05:22 +02:00
Marc KlingenandGitHub bca09bdefe fix(security): only allow redirect via target to internal urls (#3512) 2024-09-27 16:52:58 +02:00
Steffen SchmitzandGitHub 2d314250e9 chore: make credentials for export bucket optional (#3509) 2024-09-27 16:41:54 +02:00
Marc KlingenandGitHub 4a87f402ec fix: favicon reference when using custom basepath (#3506) 2024-09-27 13:10:21 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
800b2136b9 chore(deps-dev): bump @types/lodash from 4.17.7 to 4.17.9 in the lodash group (#3492)
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.7 to 4.17.9
- [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-09-27 10:25:35 +02:00
Steffen SchmitzandGitHub 278df5ab3c feat: optionally upload all incoming events to S3 (#3488) 2024-09-27 06:58:34 +00:00
Marc KlingenandGitHub c7f9e18768 fix(ui): include non-generation warnings/error in rolled-up trace status (#3494) 2024-09-27 00:31:53 +00:00
Max DeichmannandGitHub 2121b40cca security: upgrade snyk (#3482) 2024-09-25 23:10:57 +02:00
Max DeichmannandGitHub 5ba440f2de chore: rate limit legacy endpoints (#3473) 2024-09-25 12:58:13 +00:00
Max DeichmannandGitHub 657a3275de chore: mark admin api as internal and cloud only (#3464) 2024-09-24 12:47:47 +00:00
Steffen SchmitzandGitHub 56bb9c8dcf chore: remove porter configs from CI (#3463) 2024-09-24 11:36:06 +00:00
Steffen SchmitzandGitHub 66e15bcf0d chore: drop redundant indexes on postgres (#3377) 2024-09-24 11:35:04 +02:00
Marc KlingenandGitHub 5f94ca8e82 fix: skip entitlement check for instance admins (#3452) 2024-09-23 16:55:06 +02:00
Steffen SchmitzandGitHub e8561fb105 chore: move web-ee logic from console.log to logger (#3400) 2024-09-23 16:54:45 +02:00
marliessophieandGitHub 18e84c6498 fix(ui_markdown): whitelist images with valid ipv6 or ipv4 hostname (#3444) 2024-09-23 14:12:37 +00:00
Marc KlingenandGitHub 05152ac8d6 fix(auth): email/password sign-up with custom base-path (#3450)
fix: email/password sign-up with custom base-path
2024-09-23 13:48:59 +00:00
marliessophieandGitHub 6cb9e24ba2 chore(ui): support larger step sizes in Lanfguse Trace Timeline View (#3449) 2024-09-23 12:53:33 +00:00
Marc Klingen 484fa424fb docs: update comments on stripe util functions 2024-09-23 14:18:37 +02:00
Hassieb Pakzad 555aebe8c7 chore: release v2.81.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-23 13:59:21 +02:00
Max DeichmannandGitHub af29424b48 feat: delete API keys via API (#3448) 2024-09-23 12:30:41 +02:00
Max DeichmannandGitHub 42f1c63bbf chore: set sentry config on build time (#3445) 2024-09-23 11:27:15 +02:00
Hassieb PakzadandGitHub 663ded0743 chore(evals): move to tool calling instead of function calling (#3430) 2024-09-23 11:08:23 +02:00
marliessophieandGitHub d7ced99d16 feat(ui): export generations async on cloud (#3368) 2024-09-23 06:03:03 +00:00
Max DeichmannandGitHub c979cae620 chore: add worker logs for tokenization (#3440) 2024-09-21 16:07:18 +00:00
marliessophieandGitHub c483293b10 style(prompts): display all prompt tags on versions, not just on hover (#3422) 2024-09-20 16:33:10 +00:00
Steffen SchmitzandGitHub 932913cd76 fix: use actual singletons for db clients (#3427) 2024-09-20 13:27:46 +02:00
Steffen SchmitzandGitHub 5875537132 fix: keep more failed jobs in bullmq (#3424) 2024-09-20 13:26:14 +02:00
Marc KlingenandGitHub 1a53981a7a docs: add comment on max aspect ratio of custom logo (#3426) 2024-09-20 09:48:25 +00:00
Hassieb PakzadandGitHub 0e6b676b19 fix(playground): parse complex messages when jumping to playground (#3425) 2024-09-20 11:03:49 +02:00
Marc KlingenandGitHub 955e25555f fix(ui): performance of optimistic updates of bookmarked traces (#3418) 2024-09-19 22:20:11 +00:00
Marc KlingenandGitHub 2a11f1e8fb fix(ui): truncate long model names in dashboard (#3417) 2024-09-19 21:40:54 +00:00
Marc KlingenandGitHub 143a461f0c chore(ui): add link to support and tracing faq to onboarding (#3416) 2024-09-19 19:58:53 +00:00
Max DeichmannandGitHub c8544690f7 chore: remove event io from dd traces (#3413) 2024-09-19 18:06:00 +00:00
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
203 changed files with 5836 additions and 3718 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"
+21
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
@@ -97,6 +100,18 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
# S3_REGION=
# BATCH_EXPORT_DOWNLOAD_LINK_EXPIRATION_HOURS=
# S3 storage for events, optional, used to persist all incoming events
# LANGFUSE_S3_EVENT_UPLOAD_ENABLED="true"
# LANGFUSE_S3_EVENT_UPLOAD_BUCKET=
# Optional prefix to be used within the bucket. Must end with `/` if set
# LANGFUSE_S3_EVENT_UPLOAD_PREFIX=events/
# The following four options are optional and fallback to the normal SDK credential provider chain if omitted
# See https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html
# LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT=
# LANGFUSE_S3_EVENT_UPLOAD_REGION=
# LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=
# LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=
# Exports are streamed to S3 in pages to avoid memory issues
# The page size can be adjusted if needed to optimize performance
# DB_EXPORT_PAGE_SIZE=1000
@@ -124,6 +139,12 @@ 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
# LANGFUSE_UI_DEFAULT_MODEL_ADAPTER=Anthropic # OpenAI, Anthropic, Azure
# LANGFUSE_UI_DEFAULT_BASE_URL_OPENAI=https://api.openai.com/v1
# LANGFUSE_UI_DEFAULT_BASE_URL_ANTHROPIC=https://api.anthropic.com
# LANGFUSE_UI_DEFAULT_BASE_URL_AZURE_OPENAI=https://{instanceName}.openai.azure.com/openai/deployments
### END Enterprise Edition Configuration
+4
View File
@@ -32,6 +32,10 @@ updates:
patterns:
- "lodash"
- "@types/lodash"
express:
patterns:
- "express"
- "@types/express"
observability:
patterns:
- "dd-trace"
+75
View File
@@ -0,0 +1,75 @@
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 }} \
--build-arg SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} \
--build-arg SENTRY_ORG=${{ vars.SENTRY_ORG }} \
--build-arg SENTRY_PROJECT=${{ vars.SENTRY_PROJECT }} \
.
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 }}
+1 -5
View File
@@ -97,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: |
@@ -113,10 +113,6 @@ jobs:
- 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&)
@@ -1,30 +0,0 @@
"on":
push:
branches:
- production
name: Deploy to web
jobs:
porter-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set Github tag
id: vars
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Setup porter
uses: porter-dev/setup-porter@v0.1.0
- name: Deploy stack
timeout-minutes: 30
run: exec porter apply
env:
PORTER_CLUSTER: "4060"
PORTER_DEPLOYMENT_TARGET_ID: 3be075c0-b121-4123-aec1-5dc571626b0c
PORTER_HOST: https://dashboard.porter.run
PORTER_PR_NUMBER: ${{ github.event.number }}
PORTER_PROJECT: "12565"
PORTER_REPO_NAME: ${{ github.event.repository.name }}
PORTER_STACK_NAME: web
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4060 }}
PORTER_NEXT_PUBLIC_BUILD_ID: ${{ steps.vars.outputs.sha_short }}
@@ -1,30 +0,0 @@
"on":
push:
branches:
- main
name: Deploy to web (Staging)
jobs:
porter-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set Github tag
id: vars
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Setup porter
uses: porter-dev/setup-porter@v0.1.0
- name: Deploy stack
timeout-minutes: 30
run: exec porter apply
env:
PORTER_CLUSTER: "4037"
PORTER_DEPLOYMENT_TARGET_ID: c22d8fa2-f97c-4e1f-9cae-83cc6bfbad35
PORTER_HOST: https://dashboard.getporter.dev
PORTER_PR_NUMBER: ${{ github.event.number }}
PORTER_PROJECT: "12565"
PORTER_REPO_NAME: ${{ github.event.repository.name }}
PORTER_STACK_NAME: web
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4037 }}
PORTER_NEXT_PUBLIC_BUILD_ID: ${{ steps.vars.outputs.sha_short }}
@@ -1,30 +0,0 @@
"on":
push:
branches:
- production
name: Deploy to web (US temp)
jobs:
porter-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set Github tag
id: vars
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Setup porter
uses: porter-dev/setup-porter@v0.1.0
- name: Deploy stack
timeout-minutes: 30
run: exec porter apply
env:
PORTER_CLUSTER: "4054"
PORTER_DEPLOYMENT_TARGET_ID: cddc0f2a-bb19-401a-9819-29f59f76b101
PORTER_HOST: https://dashboard.porter.run
PORTER_PR_NUMBER: ${{ github.event.number }}
PORTER_PROJECT: "12565"
PORTER_REPO_NAME: ${{ github.event.repository.name }}
PORTER_STACK_NAME: web
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4054 }}
PORTER_NEXT_PUBLIC_BUILD_ID: ${{ steps.vars.outputs.sha_short }}
@@ -1,29 +0,0 @@
"on":
push:
branches:
- production
name: Deploy to worker (EU temp)
jobs:
porter-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set Github tag
id: vars
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Setup porter
uses: porter-dev/setup-porter@v0.1.0
- name: Deploy stack
timeout-minutes: 30
run: exec porter apply
env:
PORTER_CLUSTER: "4060"
PORTER_DEPLOYMENT_TARGET_ID: 3be075c0-b121-4123-aec1-5dc571626b0c
PORTER_HOST: https://dashboard.porter.run
PORTER_PR_NUMBER: ${{ github.event.number }}
PORTER_PROJECT: "12565"
PORTER_REPO_NAME: ${{ github.event.repository.name }}
PORTER_STACK_NAME: worker
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4060 }}
@@ -1,29 +0,0 @@
"on":
push:
branches:
- main
name: Deploy to worker (staging)
jobs:
porter-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set Github tag
id: vars
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Setup porter
uses: porter-dev/setup-porter@v0.1.0
- name: Deploy stack
timeout-minutes: 30
run: exec porter apply
env:
PORTER_CLUSTER: "4037"
PORTER_DEPLOYMENT_TARGET_ID: c22d8fa2-f97c-4e1f-9cae-83cc6bfbad35
PORTER_HOST: https://dashboard.getporter.dev
PORTER_PR_NUMBER: ${{ github.event.number }}
PORTER_PROJECT: "12565"
PORTER_REPO_NAME: ${{ github.event.repository.name }}
PORTER_STACK_NAME: worker
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4037 }}
@@ -1,29 +0,0 @@
"on":
push:
branches:
- production
name: Deploy to worker (US temp)
jobs:
porter-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set Github tag
id: vars
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Setup porter
uses: porter-dev/setup-porter@v0.1.0
- name: Deploy stack
timeout-minutes: 30
run: exec porter apply
env:
PORTER_CLUSTER: "4054"
PORTER_DEPLOYMENT_TARGET_ID: cddc0f2a-bb19-401a-9819-29f59f76b101
PORTER_HOST: https://dashboard.porter.run
PORTER_PR_NUMBER: ${{ github.event.number }}
PORTER_PROJECT: "12565"
PORTER_REPO_NAME: ${{ github.event.repository.name }}
PORTER_STACK_NAME: worker
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4054 }}
+2 -2
View File
@@ -2,10 +2,10 @@
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"name": "Web and Worker: server side",
"type": "node-terminal",
"request": "launch",
"command": "npm run dev"
"command": "pnpm run dev"
},
{
"name": "Next.js: debug client-side",
+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
+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:
+3 -13
View File
@@ -3,32 +3,22 @@ groups:
local:
generators:
- name: fernapi/fern-openapi
version: 0.0.28
version: 0.0.31
output:
location: local-file-system
path: ../../../web/public/generated/api
config:
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.43.7"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "2.78.0",
"version": "2.81.2",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
+7 -6
View File
@@ -55,12 +55,12 @@
"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",
"@langchain/core": "^0.1.61",
"@langchain/openai": "^0.0.33",
"@langchain/anthropic": "^0.3.1",
"@langchain/core": "^0.3.3",
"@langchain/openai": "^0.3.0",
"@opentelemetry/api": ">=1.0.0 <1.9.0",
"@prisma/client": "^5.17.0",
"@react-email/components": "^0.0.19",
@@ -73,20 +73,21 @@
"exponential-backoff": "^3.1.1",
"ioredis": "^5.4.1",
"kysely": "^0.27.4",
"langchain": "^0.2.6",
"langchain": "^0.3.2",
"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.23.2"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/lodash": "^4.17.7",
"@types/lodash": "^4.17.9",
"@types/node": "^20.11.29",
"@types/nodemailer": "^6.4.15",
"@types/pg": "^8.11.6",
@@ -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;
@@ -0,0 +1,3 @@
-- DropIndex
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
DROP INDEX CONCURRENTLY IF EXISTS "traces_project_id_idx"; -- covered by traces_project_id_timestamp_idx
@@ -0,0 +1,3 @@
-- DropIndex
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
DROP INDEX CONCURRENTLY IF EXISTS "datasets_project_id_idx"; -- covered by datasets_project_id_name_key (unique)
@@ -0,0 +1,3 @@
-- DropIndex
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
DROP INDEX CONCURRENTLY IF EXISTS "eval_templates_project_id_idx"; -- covered by eval_templates_project_id_id_idx
@@ -0,0 +1,3 @@
-- DropIndex
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
DROP INDEX CONCURRENTLY IF EXISTS "job_configurations_project_id_idx"; -- covered by job_configurations_project_id_id_idx
@@ -0,0 +1,3 @@
-- DropIndex
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
DROP INDEX CONCURRENTLY IF EXISTS "job_executions_project_id_idx"; -- covered by job_executions_project_id_id_idx
@@ -0,0 +1,3 @@
-- DropIndex
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
DROP INDEX CONCURRENTLY IF EXISTS "llm_api_keys_project_id_provider_idx"; -- covered by llm_api_keys_project_id_provider_key (unique)
@@ -0,0 +1,3 @@
-- DropIndex
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
DROP INDEX CONCURRENTLY IF EXISTS "models_project_id_model_name_idx"; -- covered by models_project_id_model_name_start_date_unit_key (unique)
@@ -0,0 +1,3 @@
-- DropIndex
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
DROP INDEX CONCURRENTLY IF EXISTS "models_project_id_model_name_start_date_unit_idx"; -- covered by models_project_id_model_name_start_date_unit_key (unique)
@@ -0,0 +1,3 @@
-- DropIndex
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
DROP INDEX CONCURRENTLY IF EXISTS "observations_project_id_idx"; -- covered by observations_project_id_start_time_type_idx
@@ -0,0 +1,3 @@
-- DropIndex
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
DROP INDEX CONCURRENTLY IF EXISTS "observations_trace_id_idx"; -- covered by observations_trace_id_project_id_start_time_idx
@@ -0,0 +1,3 @@
-- DropIndex
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
DROP INDEX CONCURRENTLY IF EXISTS "observations_trace_id_project_id_idx"; -- covered by observations_trace_id_project_id_start_time_idx
@@ -0,0 +1,3 @@
-- DropIndex
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
DROP INDEX CONCURRENTLY IF EXISTS "organization_memberships_org_id_idx"; -- covered by organization_memberships_org_id_user_id_key (unique)
@@ -0,0 +1,3 @@
-- DropIndex
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
DROP INDEX CONCURRENTLY IF EXISTS "posthog_integrations_project_id_idx"; -- covered by posthog_integrations_pkey (unique)
@@ -0,0 +1,3 @@
-- DropIndex
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
DROP INDEX CONCURRENTLY IF EXISTS "prompts_project_id_idx"; -- covered by prompts_project_id_name_version_key (unique)
@@ -0,0 +1,3 @@
-- DropIndex
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
DROP INDEX CONCURRENTLY IF EXISTS "prompts_project_id_name_version_idx"; -- covered by prompts_project_id_name_version_key (unique)
@@ -0,0 +1,3 @@
-- DropIndex
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
DROP INDEX CONCURRENTLY IF EXISTS "scores_project_id_idx"; -- covered by scores_project_id_name_idx
File diff suppressed because it is too large Load Diff
+63 -53
View File
@@ -13,64 +13,74 @@ import {
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
export class PrismaClientSingleton {
private static instance: PrismaClient;
const prismaClientSingleton = () => {
const client = new PrismaClient<
Prisma.PrismaClientOptions,
"warn" | "error" | "query"
>({
log: [
{ emit: "event", level: "query" },
{ emit: "event", level: "error" },
{ emit: "event", level: "warn" },
],
});
public static getInstance(): PrismaClient {
if (PrismaClientSingleton.instance) {
return PrismaClientSingleton.instance;
}
if (env.NODE_ENV === "development") {
client.$on("query", (event) => {
logger.info(`prisma:query ${event.query}, ${event.duration}ms`);
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}`);
});
PrismaClientSingleton.instance = client;
return PrismaClientSingleton.instance;
}
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) => {
return prismaClient.$extends(
kyselyExtension({
kysely: (driver) =>
new Kysely<DB>({
dialect: {
// This is where the magic happens!
createDriver: () => driver,
// Don't forget to customize these to match your database!
createAdapter: () => new PostgresAdapter(),
createIntrospector: (db) => new PostgresIntrospector(db),
createQueryCompiler: () => new PostgresQueryCompiler(),
},
}),
}),
);
};
declare global {
// eslint-disable-next-line no-var
var prisma: undefined | ReturnType<typeof prismaClientSingleton>;
var kyselyPrisma: undefined | ReturnType<typeof kyselySingleton>;
}
export const prisma = globalThis.prisma ?? prismaClientSingleton();
export const kyselyPrisma = globalThis.kyselyPrisma ?? kyselySingleton(prisma);
export class KyselySingleton {
private static instance: { $kysely: Kysely<DB> };
public static getInstance() {
if (KyselySingleton.instance) {
return KyselySingleton.instance;
}
KyselySingleton.instance = PrismaClientSingleton.getInstance().$extends(
kyselyExtension({
kysely: (driver) =>
new Kysely<DB>({
dialect: {
// This is where the magic happens!
createDriver: () => driver,
// Don't forget to customize these to match your database!
createAdapter: () => new PostgresAdapter(),
createIntrospector: (db) => new PostgresIntrospector(db),
createQueryCompiler: () => new PostgresQueryCompiler(),
},
}),
}),
);
return KyselySingleton.instance;
}
}
export const prisma = PrismaClientSingleton.getInstance();
export const kyselyPrisma = KyselySingleton.getInstance();
export * from "@prisma/client";
if (process.env.NODE_ENV !== "production") globalThis.prisma = prisma;
@@ -19,6 +19,8 @@ export enum BatchExportFileFormat {
export enum BatchExportTableName {
Sessions = "sessions",
Traces = "traces",
Generations = "generations",
}
export const exportOptions: Record<
@@ -6,6 +6,7 @@ export const RateLimitResource = z.enum([
"public-api",
"public-api-metrics",
"prompts",
"legacy-ingestion",
]);
// result of the rate limit check.
+2 -1
View File
@@ -52,7 +52,8 @@ 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,
},
{
+13 -13
View File
@@ -26,7 +26,7 @@ const arrayOperatorReplacements = {
export function tableColumnsToSqlFilterAndPrefix(
filters: FilterState,
tableColumns: ColumnDefinition[],
table: TableNames,
table: TableNames
): Prisma.Sql {
const sql = tableColumnsToSqlFilter(filters, tableColumns, table);
if (sql === Prisma.empty) {
@@ -42,14 +42,14 @@ export function tableColumnsToSqlFilterAndPrefix(
export function tableColumnsToSqlFilter(
filters: FilterState,
tableColumns: ColumnDefinition[],
table: TableNames,
table: TableNames
): Prisma.Sql {
const internalFilters = filters.map((filter) => {
// Get column definition to map column to internal name, e.g. "t.id"
const col = tableColumns.find(
(c) =>
// TODO: Only use id instead of name
c.name === filter.column || c.id === filter.column,
c.name === filter.column || c.id === filter.column
);
if (!col) {
logger.error("Invalid filter column", filter.column);
@@ -71,13 +71,13 @@ export function tableColumnsToSqlFilter(
? Prisma.raw(
arrayOperatorReplacements[
filter.operator as keyof typeof arrayOperatorReplacements
],
]
)
: filter.operator in operatorReplacements
? Prisma.raw(
operatorReplacements[
filter.operator as keyof typeof operatorReplacements
],
]
)
: Prisma.raw(filter.operator); //checked by zod
@@ -97,13 +97,13 @@ export function tableColumnsToSqlFilter(
break;
case "stringOptions":
valuePrisma = Prisma.sql`(${Prisma.join(
filter.value.map((v) => Prisma.sql`${v}`),
filter.value.map((v) => Prisma.sql`${v}`)
)})`;
break;
case "arrayOptions":
valuePrisma = Prisma.sql`ARRAY[${Prisma.join(
filter.value.map((v) => Prisma.sql`${v}`),
", ",
", "
)}] `;
break;
@@ -123,12 +123,12 @@ export function tableColumnsToSqlFilter(
filter.type === "string" || filter.type === "stringObject"
? [
["contains", "does not contain", "ends with"].includes(
filter.operator,
filter.operator
)
? Prisma.raw("'%' || ")
: Prisma.empty,
["contains", "does not contain", "starts with"].includes(
filter.operator,
filter.operator
)
? Prisma.raw(" || '%'")
: Prisma.empty,
@@ -152,7 +152,7 @@ export function tableColumnsToSqlFilter(
const castValueToPostgresTypes = (
column: ColumnDefinition,
table: TableNames,
table: TableNames
) => {
return column.name === "type" &&
(table === "observations" ||
@@ -168,7 +168,7 @@ const dateOperators = filterOperators["datetime"];
export const datetimeFilterToPrismaSql = (
safeColumn: string,
operator: (typeof dateOperators)[number],
value: Date,
value: Date
) => {
if (!dateOperators.includes(operator)) {
throw new Error("Invalid operator: " + operator);
@@ -178,12 +178,12 @@ export const datetimeFilterToPrismaSql = (
}
return Prisma.sql`AND ${Prisma.raw(safeColumn)} ${Prisma.raw(
operator,
operator
)} ${value}::timestamp with time zone at time zone 'UTC'`;
};
export const datetimeFilterToPrisma = (
timestampFilter: z.infer<typeof timeFilter>,
timestampFilter: z.infer<typeof timeFilter>
) => {
const prismaTimestampFilter =
timestampFilter.operator === ">="
@@ -19,12 +19,13 @@ 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: LegacyIngestionAccessScope): void;
process(
apiScope: LegacyIngestionAccessScope,
apiScope: LegacyIngestionAccessScope
): Promise<Trace | Observation | Score> | undefined;
}
@@ -40,7 +41,7 @@ export class ObservationProcessor implements EventProcessor {
calculateTokenDelegate: (p: {
model: Model;
text: unknown;
}) => number | undefined,
}) => number | undefined
) {
this.event = event;
this.calculateTokenDelegate = calculateTokenDelegate;
@@ -48,7 +49,7 @@ export class ObservationProcessor implements EventProcessor {
async convertToObservation(
apiScope: LegacyIngestionAccessScope,
existingObservation: Observation | null,
existingObservation: Omit<Observation, "input" | "output"> | null
): Promise<{
id: string;
create: Prisma.ObservationUncheckedCreateInput;
@@ -78,7 +79,7 @@ export class ObservationProcessor implements EventProcessor {
!existingObservation
) {
throw new LangfuseNotFoundError(
`Observation with id ${this.event.id} not found`,
`Observation with id ${this.event.id} not found`
);
}
@@ -120,11 +121,12 @@ 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,
existingObservation ?? undefined,
existingObservation ?? undefined
)
: [undefined, undefined];
@@ -160,7 +162,7 @@ export class ObservationProcessor implements EventProcessor {
const calculatedCosts = ObservationProcessor.calculateTokenCosts(
internalModel,
userProvidedTokenCosts,
tokenCounts,
tokenCounts
);
// merge metadata from existingObservation.metadata and metadata
@@ -168,7 +170,7 @@ export class ObservationProcessor implements EventProcessor {
existingObservation?.metadata
? jsonSchema.parse(existingObservation.metadata)
: undefined,
this.event.body.metadata ?? undefined,
this.event.body.metadata ?? undefined
);
const prompt =
@@ -311,7 +313,8 @@ export class ObservationProcessor implements EventProcessor {
};
}
calculateTokenCounts(
async calculateTokenCounts(
projectId: string,
body:
| z.infer<typeof legacyObservationCreateEvent>["body"]
| z.infer<typeof generationCreateEvent>["body"],
@@ -320,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(
@@ -352,7 +385,7 @@ export class ObservationProcessor implements EventProcessor {
outputCost?: Decimal | null;
totalCost?: Decimal | null;
},
tokenCounts: { input?: number; output?: number; total?: number },
tokenCounts: { input?: number; output?: number; total?: number }
): {
inputCost?: Decimal | null;
outputCost?: Decimal | null;
@@ -369,7 +402,7 @@ export class ObservationProcessor implements EventProcessor {
totalCost:
userProvidedCosts.totalCost ??
(userProvidedCosts.inputCost ?? new Decimal(0)).add(
userProvidedCosts.outputCost ?? new Decimal(0),
userProvidedCosts.outputCost ?? new Decimal(0)
),
};
}
@@ -410,7 +443,43 @@ export class ObservationProcessor implements EventProcessor {
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;
@@ -419,7 +488,7 @@ export class ObservationProcessor implements EventProcessor {
existingObservation.projectId !== apiScope.projectId
) {
throw new ForbiddenError(
`Access denied for observation creation ${existingObservation.projectId} `,
`Access denied for observation creation ${existingObservation.projectId} `
);
}
@@ -449,7 +518,7 @@ export class TraceProcessor implements EventProcessor {
}
async process(
apiScope: LegacyIngestionAccessScope,
apiScope: LegacyIngestionAccessScope
): Promise<Trace | Observation | Score> {
const { body } = this.event;
@@ -457,8 +526,8 @@ export class TraceProcessor implements EventProcessor {
const internalId = body.id ?? v4();
logger.info(
`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({
@@ -469,7 +538,7 @@ export class TraceProcessor implements EventProcessor {
if (existingTrace && existingTrace.projectId !== apiScope.projectId) {
throw new ForbiddenError(
`Access denied for trace creation ${existingTrace.projectId}`,
`Access denied for trace creation ${existingTrace.projectId}`
);
}
@@ -477,7 +546,7 @@ export class TraceProcessor implements EventProcessor {
existingTrace?.metadata
? jsonSchema.parse(existingTrace.metadata)
: undefined,
body.metadata ?? undefined,
body.metadata ?? undefined
);
const mergedTags =
@@ -555,12 +624,12 @@ export class ScoreProcessor implements EventProcessor {
auth(apiScope: LegacyIngestionAccessScope) {
if (apiScope.accessLevel !== "scores" && apiScope.accessLevel !== "all")
throw new ForbiddenError(
`Access denied for score creation, ${apiScope.accessLevel}`,
`Access denied for score creation, ${apiScope.accessLevel}`
);
}
async process(
apiScope: LegacyIngestionAccessScope,
apiScope: LegacyIngestionAccessScope
): Promise<Trace | Observation | Score> {
const { body } = this.event;
@@ -578,7 +647,7 @@ export class ScoreProcessor implements EventProcessor {
});
if (existingScore && existingScore.projectId !== apiScope.projectId) {
throw new ForbiddenError(
`Access denied for score creation ${existingScore.projectId}`,
`Access denied for score creation ${existingScore.projectId}`
);
}
@@ -52,7 +52,7 @@ export const handleBatch = async (
authCheck: LegacyIngestionAuthHeaderVerificationResult,
calculateTokenDelegate: (p: TokenCountInput) => number | undefined,
) => {
logger.info(`handling ingestion ${events.length} events`);
logger.debug(`handling ingestion ${events.length} events`);
if (!authCheck.validKey) throw new UnauthorizedError(authCheck.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
@@ -3,54 +3,42 @@ 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 AsyncCallbackFn<T> = () => Promise<T>;
type AsyncCallbackFn<T> = (span: opentelemetry.Span) => Promise<T>;
export async function instrumentAsync<T>(
ctx: SpanCtx,
callback: AsyncCallbackFn<T>
callback: AsyncCallbackFn<T>,
): Promise<T> {
return await getTracer(ctx.traceScope ?? callback.name).startActiveSpan(
ctx.name,
{
root: ctx.rootSpan,
kind: ctx.spanKind,
},
async (span) => {
try {
const result = await callback();
span.end();
return result;
} catch (ex) {
traceException(ex as opentelemetry.Exception, span);
span.end();
throw ex;
}
}
);
}
const activeContext = ctx.traceContext
? opentelemetry.propagation.extract(
opentelemetry.context.active(),
ctx.traceContext,
)
: opentelemetry.context.active();
type SyncCallbackFn<T> = () => T;
export function instrumentSync<T>(
ctx: SpanCtx,
callback: SyncCallbackFn<T>
): T {
return getTracer(ctx.traceScope ?? callback.name).startActiveSpan(
ctx.name,
{
root: ctx.rootSpan,
root: !Boolean(ctx.traceContext) && ctx.rootSpan,
kind: ctx.spanKind,
},
(span) => {
activeContext,
async (span) => {
try {
const result = callback();
const result = await callback(span);
span.end();
return result;
} catch (ex) {
@@ -58,16 +46,62 @@ export function instrumentSync<T>(
span.end();
throw ex;
}
}
},
);
}
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();
@@ -115,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();
@@ -138,7 +172,7 @@ export const recordGauge = (
| {
[tag: string]: string | number;
}
| undefined
| undefined,
) => {
dd.dogstatsd.gauge(stat, value, tags);
};
@@ -146,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);
};
@@ -154,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);
};
@@ -1,3 +1,5 @@
import { type ZodSchema } from "zod";
import { ChatAnthropic } from "@langchain/anthropic";
import {
AIMessage,
@@ -12,20 +14,12 @@ import {
import { IterableReadableStream } from "@langchain/core/utils/stream";
import { ChatOpenAI } from "@langchain/openai";
import {
ChatMessage,
ChatMessageRole,
LLMFunctionCall,
ModelParams,
LLMAdapter,
} from "./types";
import zodToJsonSchema from "zod-to-json-schema";
import { JsonOutputFunctionsParser } from "langchain/output_parsers";
import { ChatMessage, ChatMessageRole, ModelParams, LLMAdapter } from "./types";
type LLMCompletionParams = {
messages: ChatMessage[];
modelParams: ModelParams;
functionCall?: LLMFunctionCall;
structuredOutputSchema?: ZodSchema;
callbacks?: BaseCallbackHandler[];
baseURL?: string;
apiKey?: string;
@@ -51,7 +45,7 @@ export async function fetchLLMCompletion(
export async function fetchLLMCompletion(
params: LLMCompletionParams & {
streaming: false;
functionCall: LLMFunctionCall;
structuredOutputSchema: ZodSchema;
}
): Promise<unknown>;
@@ -121,18 +115,41 @@ export async function fetchLLMCompletion(
throw new Error("This model provider is not supported.");
}
if (params.functionCall) {
const functionCallingModel = chatModel.bind({
functions: [
{
...params.functionCall,
parameters: zodToJsonSchema(params.functionCall.parameters),
},
],
function_call: { name: params.functionCall.name },
});
const outputParser = new JsonOutputFunctionsParser();
return await functionCallingModel.pipe(outputParser).invoke(finalMessages);
if (params.structuredOutputSchema) {
return await (chatModel as ChatOpenAI) // Typecast necessary due to https://github.com/langchain-ai/langchainjs/issues/6795
.withStructuredOutput(params.structuredOutputSchema)
.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) {
+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",
+26 -1
View File
@@ -1,5 +1,26 @@
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",
@@ -23,10 +44,14 @@ const getWinstonLogger = (
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: [new winston.transports.Console()],
transports: [transport],
});
};
@@ -0,0 +1,172 @@
import { ObservationView, Prisma } from "@prisma/client";
import {
datetimeFilterToPrismaSql,
tableColumnsToSqlFilterAndPrefix,
} from "../filterToPrisma";
import { orderByToPrismaSql } from "../orderByToPrisma";
import { observationsTableCols } from "../../observationsTable";
import { TableFilters } from "./types";
type AdditionalObservationFields = {
traceName: string | null;
promptName: string | null;
promptVersion: string | null;
traceTags: Array<string>;
};
type FullObservation = AdditionalObservationFields & ObservationView;
export type FullObservations = Array<FullObservation>;
export type FullObservationsWithScores = Array<
FullObservation & { scores?: Record<string, string[] | number[]> | null }
>;
export type IOAndMetadataOmittedObservations = Array<
Omit<ObservationView, "input" | "output" | "metadata"> &
AdditionalObservationFields
>;
export function parseGetAllGenerationsInput(filters: TableFilters) {
const searchCondition = filters.searchQuery
? Prisma.sql`AND (
o."id" ILIKE ${`%${filters.searchQuery}%`} OR
o."name" ILIKE ${`%${filters.searchQuery}%`} OR
o."model" ILIKE ${`%${filters.searchQuery}%`} OR
t."name" ILIKE ${`%${filters.searchQuery}%`}
)`
: Prisma.empty;
const filterCondition = tableColumnsToSqlFilterAndPrefix(
filters.filter ?? [],
observationsTableCols,
"observations"
);
const orderByCondition = orderByToPrismaSql(
filters.orderBy,
observationsTableCols
);
// to improve query performance, add timeseries filter to observation queries as well
const startTimeFilter = filters.filter?.find(
(f) => f.column === "Start Time" && f.type === "datetime"
);
const datetimeFilter =
startTimeFilter && startTimeFilter.type === "datetime"
? datetimeFilterToPrismaSql(
"start_time",
startTimeFilter.operator,
startTimeFilter.value
)
: Prisma.empty;
return {
searchCondition,
filterCondition,
orderByCondition,
datetimeFilter,
};
}
export function createGenerationsQuery({
projectId,
datetimeFilter = Prisma.empty,
page,
limit,
searchCondition = Prisma.empty,
filterCondition = Prisma.empty,
orderByCondition = Prisma.empty,
selectIOAndMetadata = false,
selectScoreValues = false,
}: {
projectId: string;
datetimeFilter?: Prisma.Sql;
page?: number;
limit?: number;
searchCondition?: Prisma.Sql;
filterCondition?: Prisma.Sql;
orderByCondition?: Prisma.Sql;
selectIOAndMetadata?: boolean;
selectScoreValues?: boolean;
}) {
return Prisma.sql`
WITH scores_avg AS (
SELECT
trace_id,
observation_id,
${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
trace_id,
observation_id,
name,
${selectScoreValues ? Prisma.sql`array_agg(COALESCE(string_value, value::text)) AS "values",` : Prisma.empty}
avg(value) avg_value,
comment
FROM
scores
WHERE
project_id = ${projectId}
${selectScoreValues ? Prisma.empty : Prisma.sql`AND scores."data_type" IN ('NUMERIC', 'BOOLEAN')`}
GROUP BY
trace_id,
observation_id,
name,
comment
ORDER BY
trace_id
) tmp
GROUP BY
trace_id,
observation_id
)
SELECT
${selectScoreValues ? Prisma.sql`s_avg."scores_values" AS "scores",` : Prisma.empty}
o.id,
o.name,
o.model,
o."modelParameters",
o.start_time as "startTime",
o.end_time as "endTime",
${selectIOAndMetadata ? Prisma.sql`o.input, o.output, o.metadata,` : Prisma.empty}
o.trace_id as "traceId",
t.name as "traceName",
o.completion_start_time as "completionStartTime",
o.time_to_first_token as "timeToFirstToken",
o.prompt_tokens as "promptTokens",
o.completion_tokens as "completionTokens",
o.total_tokens as "totalTokens",
o.unit,
o.level,
o.status_message as "statusMessage",
o.version,
o.model_id as "modelId",
o.input_price as "inputPrice",
o.output_price as "outputPrice",
o.total_price as "totalPrice",
o.calculated_input_cost as "calculatedInputCost",
o.calculated_output_cost as "calculatedOutputCost",
o.calculated_total_cost as "calculatedTotalCost",
o."latency",
o.prompt_id as "promptId",
p.name as "promptName",
p.version as "promptVersion",
t.tags as "traceTags"
FROM observations_view o
JOIN traces t ON t.id = o.trace_id AND t.project_id = ${projectId}
LEFT JOIN scores_avg AS s_avg ON s_avg.trace_id = t.id and s_avg.observation_id = o.id
LEFT JOIN prompts p ON p.id = o.prompt_id AND p.project_id = ${projectId}
WHERE
o.project_id = ${projectId}
AND o.type = 'GENERATION'
${datetimeFilter}
${searchCondition}
${filterCondition}
${orderByCondition}
${limit ? Prisma.sql`LIMIT ${limit}` : Prisma.empty}
${page && limit ? Prisma.sql`OFFSET ${page * limit}` : Prisma.empty}
`;
}
@@ -0,0 +1,135 @@
import { Prisma } from "@prisma/client";
import { TableFilters } from "./types";
import {
datetimeFilterToPrismaSql,
tableColumnsToSqlFilterAndPrefix,
} from "../filterToPrisma";
import { tracesTableCols } from "../../tracesTable";
import { orderByToPrismaSql } from "../orderByToPrisma";
export function parseTraceAllFilters(input: TableFilters) {
const filterCondition = tableColumnsToSqlFilterAndPrefix(
input.filter ?? [],
tracesTableCols,
"traces"
);
const orderByCondition = orderByToPrismaSql(input.orderBy, tracesTableCols);
// to improve query performance, add timeseries filter to observation queries as well
const timeseriesFilter = input.filter?.find(
(f) => f.column === "Timestamp" && f.type === "datetime"
);
const observationTimeseriesFilter =
timeseriesFilter && timeseriesFilter.type === "datetime"
? datetimeFilterToPrismaSql(
"start_time",
timeseriesFilter.operator,
timeseriesFilter.value
)
: Prisma.empty;
const searchCondition = input.searchQuery
? Prisma.sql`AND (
t."id" ILIKE ${`%${input.searchQuery}%`} OR
t."external_id" ILIKE ${`%${input.searchQuery}%`} OR
t."user_id" ILIKE ${`%${input.searchQuery}%`} OR
t."name" ILIKE ${`%${input.searchQuery}%`}
)`
: Prisma.empty;
return {
filterCondition,
orderByCondition,
observationTimeseriesFilter,
searchCondition,
};
}
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"
FROM
"observations_view"
WHERE
trace_id = t.id
AND "type" = 'GENERATION'
AND "project_id" = ${projectId}
${observationTimeseriesFilter}
) AS generation_metrics 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",
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"
WHERE
trace_id = t.id
AND "project_id" = ${projectId}
${observationTimeseriesFilter}
) AS observation_metrics 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}
`;
}
@@ -1 +1,9 @@
export { createSessionsAllQuery } from "./createSessionsAllQuery";
export { createTracesQuery, parseTraceAllFilters } from "./createTracesQuery";
export {
createGenerationsQuery,
parseGetAllGenerationsInput,
type FullObservations,
type FullObservationsWithScores,
type IOAndMetadataOmittedObservations,
} from "./createGenerationsQuery";
@@ -0,0 +1,14 @@
import z from "zod";
import { singleFilter } from "../../interfaces/filters";
import { orderBy } from "../../interfaces/orderBy";
import { optionalPaginationZod } from "../../utils/zod";
const TableFilterSchema = z.object({
projectId: z.string(),
filter: z.array(singleFilter).nullish(),
searchQuery: z.string().nullish(),
orderBy: orderBy,
...optionalPaginationZod,
});
export type TableFilters = z.infer<typeof TableFilterSchema>;
+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;
};
};
@@ -15,8 +15,12 @@ export const getBatchExportQueue = () => {
connection: connection,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
removeOnFail: 10_000,
attempts: 2,
backoff: {
type: "exponential",
delay: 5000,
},
},
})
: null;
@@ -18,9 +18,13 @@ export const getIngestionFlushQueue = () => {
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,
removeOnFail: 100_000,
delay: env.LANGFUSE_INGESTION_FLUSH_DELAY_MS,
attempts: env.LANGFUSE_INGESTION_FLUSH_ATTEMPTS,
backoff: {
type: "exponential",
delay: 5000,
},
},
})
: null;
@@ -21,8 +21,12 @@ export class LegacyIngestionQueue {
connection: newRedis,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
removeOnFail: 100_000,
attempts: 5,
backoff: {
type: "exponential",
delay: 5000,
},
},
},
)
@@ -21,7 +21,12 @@ export const getTraceUpsertQueue = () => {
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,
removeOnFail: 100_000,
attempts: 5,
backoff: {
type: "exponential",
delay: 5000,
},
},
})
: null;
@@ -30,7 +35,7 @@ export const getTraceUpsertQueue = () => {
};
export function convertTraceUpsertEventsToRedisEvents(
events: TraceUpsertEventType[]
events: TraceUpsertEventType[],
) {
const uniqueTracesPerProject = events.reduce((acc, event) => {
if (!acc.get(event.projectId)) {
@@ -1,5 +1,9 @@
import type { Readable } from "stream";
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import {
GetObjectCommand,
PutObjectCommand,
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";
@@ -16,20 +20,26 @@ export class S3StorageService {
private bucketName: string;
constructor(params: {
accessKeyId: string;
secretAccessKey: string;
accessKeyId: string | undefined;
secretAccessKey: string | undefined;
bucketName: string;
endpoint: string;
region: string;
endpoint: string | undefined;
region: string | undefined;
}) {
const { accessKeyId, secretAccessKey, bucketName, endpoint, region } =
params;
// Use accessKeyId and secretAccessKey if provided or fallback to default credentials
const credentials =
accessKeyId !== undefined && secretAccessKey !== undefined
? {
accessKeyId,
secretAccessKey,
}
: undefined;
this.client = new S3Client({
credentials: {
accessKeyId,
secretAccessKey,
},
credentials,
endpoint,
region,
});
@@ -57,11 +67,27 @@ export class S3StorageService {
return { signedUrl };
} catch (err) {
logger.error(err);
logger.error(`Failed to upload file to ${fileName}`, err);
throw new Error("Failed to upload to S3 or generate signed URL");
}
}
public async uploadJson(path: string, body: Record<string, unknown>) {
const putCommand = new PutObjectCommand({
Bucket: this.bucketName,
Key: path,
Body: JSON.stringify(body),
ContentType: "application/json",
});
try {
await this.client.send(putCommand);
} catch (err) {
logger.error(`Failed to upload JSON to S3 ${path}`, err);
throw Error("Failed to upload JSON to S3");
}
}
private async getSignedUrl(
fileName: string,
ttlSeconds: number,
@@ -77,6 +103,7 @@ export class S3StorageService {
{ expiresIn: ttlSeconds },
);
} catch (err) {
logger.error(`Failed to generate presigned URL for ${fileName}`, 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"
@@ -56,8 +56,9 @@ const ResetPasswordTemplate = ({ url }: ResetPasswordTemplateProps) => {
</Button>
</Section>
<Text className="text-center text-xs leading-6 text-[#666666]">
If you do not want to change your password or didn&apos;t request
a reset, you can ignore and delete this email.
The link is valid for 10 minutes. If you do not want to change
your password or didn&apos;t request a reset, you can ignore and
delete this email.
</Text>
</Container>
</Body>
@@ -76,7 +77,7 @@ export async function sendResetPasswordVerificationRequest(
to: identifier,
from: provider.from,
subject: `Forgot your password?`,
text: `To reset your Langfuse password, please confirm your email:\n${url}\n\nIf you do not want to change your password or didn't request a reset, you can ignore and delete this email.`,
text: `To reset your Langfuse password, please confirm your email:\n${url}\n\nThe link is valid for 10 minutes. If you do not want to change your password or didn't request a reset, you can ignore and delete this email.`,
html: htmlTemplate,
});
const failed = result.rejected.concat(result.pending).filter(Boolean);
@@ -1,6 +1,8 @@
import { Transform, type TransformCallback } from "stream";
import { stringify } from "./stringify";
const DELIMITER = ",";
export function transformStreamToCsv(): Transform {
let isFirstChunk = true;
let headers: string[] = [];
@@ -15,15 +17,19 @@ export function transformStreamToCsv(): Transform {
if (isFirstChunk) {
// Extract headers from the first object
headers = Object.keys(row);
this.push(headers.join(",") + "\n");
this.push(headers.join(DELIMITER) + "\n");
isFirstChunk = false;
}
// Convert the object to a CSV line and push it
const csvRow = headers.map((header) => {
const field = row[header];
const str = stringify(field);
let str = stringify(field);
// escape and format fields that contain commas
if (str.includes(",")) {
str = `"${str.replace(/"/g, '""')}"`;
}
if (str.startsWith('"') && str.endsWith('"')) {
return str;
} else {
@@ -31,7 +37,7 @@ export function transformStreamToCsv(): Transform {
}
});
this.push(csvRow.join(",") + "\n");
this.push(csvRow.join(DELIMITER) + "\n");
callback();
},
+5 -5
View File
@@ -78,28 +78,28 @@ export const tracesTableCols: ColumnDefinition[] = [
name: "Input Tokens",
id: "inputTokens",
type: "number",
internal: 'tm."promptTokens"',
internal: 'generation_metrics."promptTokens"',
nullable: true,
},
{
name: "Output Tokens",
id: "outputTokens",
type: "number",
internal: 'tm."completionTokens"',
internal: 'generation_metrics."completionTokens"',
nullable: true,
},
{
name: "Total Tokens",
id: "totalTokens",
type: "number",
internal: 'tm."totalTokens"',
internal: 'generation_metrics."totalTokens"',
nullable: true,
},
{
name: "Usage",
id: "usage",
type: "number",
internal: 'tm."totalTokens"',
internal: 'generation_metrics."totalTokens"',
nullable: true,
},
@@ -113,7 +113,7 @@ export const tracesTableCols: ColumnDefinition[] = [
name: "Latency (s)",
id: "latency",
type: "number",
internal: "tl.latency",
internal: "observation_metrics.latency",
},
{
name: "Input Cost ($)",
+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[];
+803 -896
View File
File diff suppressed because it is too large Load Diff
+37 -12
View File
@@ -33,21 +33,43 @@ 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_SENTRY_DSN
ARG NEXT_LANGFUSE_TRACING_SAMPLE_RATE
# Accept build id as NEXT_PUBLIC_BUILD_ID or PORTER_NEXT_PUBLIC_BUILD_ID
ARG PORTER_NEXT_PUBLIC_BUILD_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
# Sentry already needs to be set on build time to upload sourcemaps
# This must not be set for OSS releases as we would share our Sentry secret.
ARG SENTRY_AUTH_TOKEN
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
ARG SENTRY_ORG
ENV SENTRY_ORG=$SENTRY_ORG
ARG SENTRY_PROJECT
ENV SENTRY_PROJECT=$SENTRY_PROJECT
# Accept build id as NEXT_PUBLIC_BUILD_ID
ARG NEXT_PUBLIC_BUILD_ID
ENV NEXT_PUBLIC_BUILD_ID=${PORTER_NEXT_PUBLIC_BUILD_ID:-$NEXT_PUBLIC_BUILD_ID}
ENV NEXT_PUBLIC_BUILD_ID=$NEXT_PUBLIC_BUILD_ID
# Copy source code of isolated subworkspace
COPY --from=pruner /app/out/full/ .
@@ -60,6 +82,9 @@ RUN rm -f ./web/src/middleware.ts
# Uncomment the following line in case you want to disable telemetry during the build.
ENV NEXT_TELEMETRY_DISABLED 1
ENV NEXT_MANUAL_SIG_HANDLE true
# set the CI flag to true to get CI specific logs
ENV CI true
RUN NODE_OPTIONS='--max-old-space-size=4096' turbo run build --filter=web
+21 -20
View File
@@ -11,8 +11,8 @@ import { env } from "./src/env.mjs";
* img-src https to allow loading images from SSO providers
*/
const cspHeader = `
default-src 'self' https://*.langfuse.com https://*.posthog.com https://*.sentry.io wss://*.crisp.chat https://*.crisp.chat;
script-src 'self' 'unsafe-eval' 'unsafe-inline' https://*.langfuse.com https://client.crisp.chat https://settings.crisp.chat https://challenges.cloudflare.com https://*.sentry.io https://ph.langfuse.com https://static.cloudflareinsights.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;
@@ -22,7 +22,7 @@ const cspHeader = `
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
connect-src 'self' https://client.crisp.chat https://storage.crisp.chat wss://client.relay.crisp.chat wss://stream.relay.crisp.chat https://*.ingest.us.sentry.io https://ph.langfuse.com;
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;` : ""}
@@ -55,6 +55,7 @@ const nextConfig = {
],
},
poweredByHeader: false,
basePath: env.NEXT_PUBLIC_BASE_PATH,
/**
* If you have `experimental: { appDir: true }` set, then you must comment the below `i18n` config
@@ -104,22 +105,22 @@ const nextConfig = {
// Required to check authentication status from langfuse.com
...(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined
? [
{
source: "/api/auth/session",
headers: [
{
key: "Access-Control-Allow-Origin",
value: "https://langfuse.com",
},
{ key: "Access-Control-Allow-Credentials", value: "true" },
{ key: "Access-Control-Allow-Methods", value: "GET,POST" },
{
key: "Access-Control-Allow-Headers",
value: "Content-Type, Authorization",
},
],
},
]
{
source: "/api/auth/session",
headers: [
{
key: "Access-Control-Allow-Origin",
value: "https://langfuse.com",
},
{ key: "Access-Control-Allow-Credentials", value: "true" },
{ key: "Access-Control-Allow-Methods", value: "GET,POST" },
{
key: "Access-Control-Allow-Headers",
value: "Content-Type, Authorization",
},
],
},
]
: []),
// all files in /public/generated are public and can be accessed from any origin, e.g. to render an API reference based on our openapi schema
{
@@ -176,7 +177,7 @@ export default withSentryConfig(nextConfig, {
// 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",
// tunnelRoute: "/api/monitoring-tunnel",
// Hides source maps from generated client bundles
hideSourceMaps: true,
+15 -9
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "2.78.0",
"version": "2.81.2",
"private": true,
"license": "MIT",
"engines": {
@@ -27,13 +27,17 @@
"@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",
"@hookform/resolvers": "^3.3.4",
"@langchain/anthropic": "^0.1.21",
"@langchain/core": "^0.1.61",
"@langchain/openai": "^0.0.33",
"@langchain/anthropic": "^0.3.1",
"@langchain/core": "^0.3.3",
"@langchain/openai": "^0.3.0",
"@langfuse/ee": "workspace:*",
"@langfuse/shared": "workspace:*",
"@marsidev/react-turnstile": "^0.5.4",
@@ -44,16 +48,18 @@
"@opentelemetry/context-async-hooks": "^1.25.1",
"@opentelemetry/core": "^1.14.0",
"@opentelemetry/instrumentation": "^0.52.1",
"@opentelemetry/instrumentation-aws-sdk": "^0.44.0",
"@opentelemetry/instrumentation-http": "^0.52.1",
"@opentelemetry/instrumentation-ioredis": "^0.42.0",
"@opentelemetry/instrumentation-winston": "^0.40.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.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
@@ -109,14 +115,14 @@
"ip-address": "^9.0.5",
"js-tiktoken": "^1.0.12",
"kysely": "^0.27.4",
"langchain": "^0.2.6",
"langchain": "^0.3.2",
"lodash": "^4.17.21",
"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",
@@ -155,7 +161,7 @@
"@types/dompurify": "^3.0.5",
"@types/eslint": "^8.56.7",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.7",
"@types/lodash": "^4.17.9",
"@types/node": "20.10.5",
"@types/react": "~18.2.79",
"@types/react-dom": "~18.2.25",
@@ -184,6 +190,6 @@
"initVersion": "7.13.0"
},
"optionalDependencies": {
"crisp-sdk-web": "^1.0.21"
"crisp-sdk-web": "^1.0.25"
}
}
+70 -36
View File
@@ -63,7 +63,7 @@ paths:
content:
application/json:
schema: {}
security: &ref_0
security:
- BasicAuth: []
requestBody:
required: true
@@ -141,7 +141,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
/api/public/dataset-items/{id}:
get:
description: Get a dataset item
@@ -186,7 +187,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
/api/public/dataset-run-items:
post:
description: Create a dataset run item
@@ -226,7 +228,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
requestBody:
required: true
content:
@@ -286,7 +289,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
post:
description: Create a dataset
operationId: datasets_create
@@ -325,7 +329,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
requestBody:
required: true
content:
@@ -376,7 +381,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
/api/public/datasets/{datasetName}/runs/{runName}:
get:
description: Get a dataset run and its items
@@ -426,7 +432,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
/api/public/datasets/{datasetName}/runs:
get:
description: Get dataset runs
@@ -485,7 +492,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
/api/public/health:
get:
description: Check health of API and database
@@ -580,7 +588,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
requestBody:
required: true
content:
@@ -649,8 +658,8 @@ paths:
- name: fromTimestamp
in: query
description: >-
Optional filter to only include traces on or after a certain
datetime (ISO 8601)
Optional filter to only include traces and observations on or after
a certain datetime (ISO 8601)
required: false
schema:
type: string
@@ -659,8 +668,8 @@ paths:
- name: toTimestamp
in: query
description: >-
Optional filter to only include traces before a certain datetime
(ISO 8601)
Optional filter to only include traces and observations before a
certain datetime (ISO 8601)
required: false
schema:
type: string
@@ -698,7 +707,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
/api/public/models:
post:
description: Create a model
@@ -738,7 +748,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
requestBody:
required: true
content:
@@ -797,7 +808,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
/api/public/models/{id}:
get:
description: Get a model
@@ -842,7 +854,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
delete:
description: >-
Delete a model. Cannot delete models managed by Langfuse. You can create
@@ -885,7 +898,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
/api/public/observations/{observationId}:
get:
description: Get a observation
@@ -933,7 +947,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
/api/public/observations:
get:
description: Get a list of observations
@@ -1046,7 +1061,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
/api/public/projects:
get:
description: Get Project associated with API key
@@ -1086,7 +1102,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
/api/public/v2/prompts/{promptName}:
get:
description: Get a prompt
@@ -1148,7 +1165,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
/api/public/v2/prompts:
get:
description: Get a list of prompt names with versions and labels
@@ -1240,7 +1258,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
post:
description: Create a new version for the prompt with the given `name`
operationId: prompts_create
@@ -1279,7 +1298,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
requestBody:
required: true
content:
@@ -1327,7 +1347,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
requestBody:
required: true
content:
@@ -1388,7 +1409,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
/api/public/score-configs/{configId}:
get:
description: Get a score config
@@ -1434,7 +1456,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
/api/public/scores:
post:
description: Create a score
@@ -1474,7 +1497,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
requestBody:
required: true
content:
@@ -1612,7 +1636,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
/api/public/scores/{scoreId}:
get:
description: Get a score
@@ -1658,7 +1683,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
delete:
description: Delete a score
operationId: score_delete
@@ -1699,7 +1725,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
/api/public/sessions:
get:
description: Get sessions
@@ -1775,7 +1802,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
/api/public/sessions/{sessionId}:
get:
description: >-
@@ -1824,7 +1852,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
/api/public/traces/{traceId}:
get:
description: Get a specific trace
@@ -1870,7 +1899,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
/api/public/traces:
get:
description: Get list of traces
@@ -1997,7 +2027,8 @@ paths:
content:
application/json:
schema: {}
security: *ref_0
security:
- BasicAuth: []
components:
schemas:
Trace:
@@ -2696,6 +2727,7 @@ components:
to exact match, use `(?i)^modelname$`
startDate:
type: string
format: date
nullable: true
description: Apply only to generations which are newer than this ISO date.
unit:
@@ -3508,6 +3540,7 @@ components:
properties:
date:
type: string
format: date
countTraces:
type: integer
countObservations:
@@ -3592,6 +3625,7 @@ components:
to exact match, use `(?i)^modelname$`
startDate:
type: string
format: date
nullable: true
description: Apply only to generations which are newer than this ISO date.
unit:
+2 -2
View File
@@ -516,12 +516,12 @@
{
"key": "fromTimestamp",
"value": "",
"description": "Optional filter to only include traces on or after a certain datetime (ISO 8601)"
"description": "Optional filter to only include traces and observations on or after a certain datetime (ISO 8601)"
},
{
"key": "toTimestamp",
"value": "",
"description": "Optional filter to only include traces before a certain datetime (ISO 8601)"
"description": "Optional filter to only include traces and observations before a certain datetime (ISO 8601)"
}
],
"variable": []
+7 -13
View File
@@ -2,13 +2,15 @@ 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(),
// Sentry.debugIntegration(),
Sentry.captureConsoleIntegration({
levels: ["error"],
}),
// Sentry.captureConsoleIntegration({
// levels: ["error"],
// }),
],
// Set tracesSampleRate to 1.0 to capture 100%
@@ -16,24 +18,16 @@ Sentry.init({
// We recommend adjusting this value in production
tracesSampleRate: process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE
? Number(process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE)
: 0.5,
: 0,
// Capture Replay for 100% of all sessions,
// plus for 100% of sessions with an error
replaysSessionSampleRate: process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE
? Number(process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE)
: 0.5,
: 0,
replaysOnErrorSampleRate: 1.0,
debug: false,
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;
},
// ...
// Note: if you want to override the automatic release value, do not set a
+41
View File
@@ -97,3 +97,44 @@ test("Unauthenticated user should be redirected to target URL after login", asyn
await expect(page).toHaveURL(promptUrl);
});
test("Unauthenticated user should not be redirected to non-relative URLs after login", async ({
page,
}) => {
const nonRelativeUrl = "https://example.com";
await page.goto(
`/auth/sign-in?targetPath=${encodeURIComponent(nonRelativeUrl)}`,
);
await page.fill('input[name="email"]', "demo@langfuse.com");
await page.fill('input[type="password"]', "password");
await page.click('button[data-testid="submit-email-password-sign-in-form"]');
// Wait for navigation
await page.waitForTimeout(2000);
// Expect to be redirected to the home page, not the non-relative URL
await expect(page).toHaveURL("/");
// Verify we're logged in
await expect(page.getByRole("button", { name: /Demo User/ })).toBeVisible();
});
test("Unauthenticated user should be redirected to relative URL after login", async ({
page,
}) => {
const relativeUrl = "/support";
await page.goto(
`/auth/sign-in?targetPath=${encodeURIComponent(relativeUrl)}`,
);
await page.fill('input[name="email"]', "demo@langfuse.com");
await page.fill('input[type="password"]', "password");
await page.click('button[data-testid="submit-email-password-sign-in-form"]');
// Wait for navigation
await page.waitForTimeout(2000);
// Expect to be redirected to the relative URL
await expect(page).toHaveURL(relativeUrl);
});
+11 -6
View File
@@ -30,7 +30,17 @@ export const BatchExportTableButton: React.FC<BatchExportTableButtonProps> = (
props,
) => {
const [isExporting, setIsExporting] = React.useState(false);
const createExport = api.batchExport.create.useMutation();
const createExport = api.batchExport.create.useMutation({
onSettled: () => {
setIsExporting(false);
},
onSuccess: () => {
showSuccessToast({
title: "Export queued",
description: "You will receive an email when the export is ready.",
});
},
});
const entitled = useHasOrgEntitlement("batch-export");
const hasAccess = useHasProjectAccess({
projectId: props.projectId,
@@ -49,11 +59,6 @@ export const BatchExportTableButton: React.FC<BatchExportTableButtonProps> = (
orderBy: props.orderByState,
},
});
setIsExporting(false);
showSuccessToast({
title: "Export queued",
description: "You will receive an email when the export is ready.",
});
};
if (!entitled || !hasAccess) return null;
@@ -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-6 w-full 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}
+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
};
+69 -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,52 @@ export const LangfuseIcon = ({
/>
);
const LangfuseLogotypeOrCustomized = ({ size }: { size: "sm" | "xl" }) => {
const uiCustomization = useUiCustomization();
if (uiCustomization?.logoLightModeHref && uiCustomization?.logoDarkModeHref) {
// logo is a url, maximum aspect ratio of 1:3 needs to be supported according to docs
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 +79,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>
);
);
};
@@ -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,6 +30,7 @@ export type ModelParamsContext = {
) => void;
setModelParamEnabled?: (key: keyof UIModelParams, enabled: boolean) => void;
formDisabled?: boolean;
modelParamsDescription?: string;
};
export const ModelParameters: React.FC<
@@ -41,6 +43,7 @@ export const ModelParameters: React.FC<
setModelParamEnabled,
evalModelsOnly,
formDisabled = false,
modelParamsDescription,
}) => {
const projectId = useProjectIdFromURL();
@@ -74,7 +77,22 @@ export const ModelParameters: React.FC<
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"
@@ -128,6 +146,7 @@ type ModelParamsSelectProps = {
options: string[];
updateModelParam: ModelParamsContext["updateModelParamValue"];
disabled?: boolean;
modelParamsDescription?: string;
};
const ModelParamsSelect = ({
title,
@@ -136,6 +155,7 @@ const ModelParamsSelect = ({
options,
updateModelParam,
disabled,
modelParamsDescription,
}: ModelParamsSelectProps) => {
return (
<div className="space-y-2">
@@ -168,6 +188,9 @@ const ModelParamsSelect = ({
))}
</SelectContent>
</Select>
{modelParamsDescription ? (
<FormDescription>{modelParamsDescription}</FormDescription>
) : undefined}
</div>
);
};
@@ -4,13 +4,21 @@ export const FullScreenPage: React.FC<
React.PropsWithChildren<{
mobile?: boolean;
className?: string;
lgHeight?: string;
mobileHeight?: string;
}>
> = ({ children, mobile = true, className }) => {
> = ({
children,
mobile = true,
className,
lgHeight = "lg:h-[calc(100dvh-1.5rem)]",
mobileHeight = "h-[calc(100dvh-6rem)]",
}) => {
return (
<div
className={cn(
"flex flex-col overflow-hidden lg:h-[calc(100dvh-1.5rem)]",
mobile && "h-[calc(100dvh-6rem)]",
`flex flex-col overflow-hidden ${lgHeight}`,
mobile && mobileHeight,
className,
)}
>
+12 -2
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">
@@ -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>
+70 -37
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 = [
@@ -164,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)[] =
@@ -238,13 +252,20 @@ export default function Layout(props: PropsWithChildren) {
session.status === "authenticated" &&
unauthenticatedPaths.includes(router.pathname)
) {
const targetPath = router.query.targetPath as string | undefined;
const queryTargetPath = router.query.targetPath as string | undefined;
const sanitizedTargetPath = targetPath
? DOMPurify.sanitize(targetPath)
const sanitizedTargetPath = queryTargetPath
? DOMPurify.sanitize(queryTargetPath)
: undefined;
void router.replace(sanitizedTargetPath ?? "/");
// only allow relative links
const targetPath =
sanitizedTargetPath?.startsWith("/") &&
!sanitizedTargetPath.startsWith("//")
? sanitizedTargetPath
: "/";
void router.replace(targetPath);
return <Spinner message="Redirecting" />;
}
@@ -267,19 +288,19 @@ export default function Layout(props: PropsWithChildren) {
<link
rel="apple-touch-icon"
sizes="180x180"
href="/apple-touch-icon.png"
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/apple-touch-icon.png`}
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/favicon-32x32.png"
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-32x32.png`}
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/favicon-16x16.png"
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-16x16.png`}
/>
</Head>
<div>
@@ -520,7 +541,7 @@ export default function Layout(props: PropsWithChildren) {
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" />
@@ -563,6 +584,8 @@ const MainNavigation: React.FC<{
{},
);
const uiCustomization = useUiCustomization();
return (
<li className={className}>
<ul role="list" className="-mx-2 space-y-1">
@@ -580,34 +603,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
+29
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[] = [
@@ -40,6 +48,7 @@ export const ROUTES: Route[] = [
pathname: "/",
icon: LangfuseIcon,
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",
+1 -1
View File
@@ -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"
+22 -34
View File
@@ -63,47 +63,35 @@ export function StarTraceToggle({
const [isLoading, setIsLoading] = useState(false);
const mutBookmarkTrace = api.traces.bookmark.useMutation({
// Optimistic update
// Tanstack docs: https://tanstack.com/query/v4/docs/react/guides/optimistic-updates
onMutate: async () => {
// Cancel any outgoing refetches
// (so they don't overwrite our optimistic update)
onMutate: async (newBookmarkState) => {
await utils.traces.all.cancel();
setIsLoading(true);
// Snapshot the previous value
const prev = utils.traces.all.getData(tracesFilter);
const previousData = utils.traces.all.getData(tracesFilter);
return { prev };
utils.traces.all.setData(tracesFilter, (old) => {
if (!old) return old;
return {
...old,
traces: old.traces.map((trace) =>
trace.id === traceId
? { ...trace, bookmarked: newBookmarkState.bookmarked }
: trace,
),
};
});
return { previousData };
},
onError: (err, _newTodo, context) => {
onError: (err, newBookmarkState, context) => {
setIsLoading(false);
// Rollback to the previous value if mutation fails
trpcErrorToast(err);
utils.traces.all.setData(tracesFilter, context?.prev);
if (context?.previousData) {
utils.traces.all.setData(tracesFilter, context.previousData);
}
},
onSettled: () => {
setIsLoading(false);
utils.traces.all.setData(
tracesFilter,
(oldQueryData: RouterOutput["traces"]["all"] | undefined) => {
return {
traces: oldQueryData?.traces
? oldQueryData.traces.map((trace) => {
return {
...trace,
bookmarked:
trace.id === traceId
? !trace.bookmarked
: trace.bookmarked,
};
})
: [],
};
},
);
void utils.traces.all.invalidate();
},
});
@@ -114,16 +102,16 @@ export function StarTraceToggle({
size={size}
disabled={!hasAccess}
isLoading={isLoading}
onClick={(value) => {
onClick={(newValue) => {
capture("table:bookmark_button_click", {
table: "traces",
id: traceId,
value: value,
value: newValue,
});
return mutBookmarkTrace.mutateAsync({
projectId,
traceId,
bookmarked: value,
bookmarked: newValue,
});
}}
/>
@@ -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>
);
}
@@ -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,
@@ -36,6 +39,8 @@ interface DataTableToolbarProps<TData, TValue> {
| ((newState: FilterState) => void);
columnVisibility?: VisibilityState;
setColumnVisibility?: Dispatch<SetStateAction<VisibilityState>>;
columnOrder?: ColumnOrderState;
setColumnOrder?: Dispatch<SetStateAction<ColumnOrderState>>;
rowHeight?: RowHeight;
setRowHeight?: Dispatch<SetStateAction<RowHeight>>;
columnsWithCustomSelect?: string[];
@@ -55,6 +60,8 @@ export function DataTableToolbar<TData, TValue>({
setFilterState,
columnVisibility,
setColumnVisibility,
columnOrder,
setColumnOrder,
rowHeight,
setRowHeight,
columnsWithCustomSelect,
@@ -115,6 +122,8 @@ export function DataTableToolbar<TData, TValue>({
columns={columns}
columnVisibility={columnVisibility}
setColumnVisibility={setColumnVisibility}
columnOrder={columnOrder}
setColumnOrder={setColumnOrder}
/>
)}
{!!rowHeight && !!setRowHeight && (
+73 -36
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,
@@ -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,10 +100,23 @@ 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,
@@ -111,6 +141,9 @@ export function DataTable<TData extends object, TValue>({
columnFilters,
pagination: pagination?.state,
columnVisibility,
columnOrder: columnOrder
? insertArrayAfterKey(columnOrder, flattedColumnsByGroup)
: undefined,
rowSelection,
},
manualFiltering: true,
@@ -171,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}
@@ -181,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;
@@ -219,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;
@@ -302,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
View File
@@ -16,6 +16,7 @@ type ExtendedColumnDef<TData extends RowData, TValue = unknown> = ColumnDef<
description: string;
href?: string;
};
isPinned?: boolean; // if true, column cannot be reordered
};
// limits types of defined tanstack ColumnDef properties to specific subset of tanstack type union
@@ -4,14 +4,6 @@ import TableLink from "@/src/components/table/table-link";
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
import { useMemo, useState } from "react";
import { TokenUsageBadge } from "@/src/components/token-usage-badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/src/components/ui/dropdown-menu";
import { Button } from "@/src/components/ui/button";
import { ChevronDownIcon, Loader } from "lucide-react";
import {
NumberParam,
StringParam,
@@ -27,6 +19,8 @@ import {
type ObservationLevel,
type FilterState,
type ObservationOptions,
BatchExportTableName,
isCloudPlan,
} from "@langfuse/shared";
import { cn } from "@/src/utils/tailwind";
import { LevelColors } from "@/src/components/level-colors";
@@ -50,6 +44,17 @@ import { useDebounce } from "@/src/hooks/useDebounce";
import { type ScoreAggregate } from "@/src/features/scores/lib/types";
import { useIndividualScoreColumns } from "@/src/features/scores/hooks/useIndividualScoreColumns";
import TagList from "@/src/features/tag/components/TagList";
import useColumnOrder from "@/src/features/column-visibility/hooks/useColumnOrder";
import { BatchExportTableButton } from "@/src/components/BatchExportTableButton";
import { useOrganizationPlan } from "@/src/features/entitlements/hooks";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/src/components/ui/dropdown-menu";
import { Button } from "@/src/components/ui/button";
import { ChevronDownIcon, Loader } from "lucide-react";
export type GenerationsTableRow = {
id: string;
@@ -98,6 +103,7 @@ export default function GenerationsTable({
omittedFilter = [],
}: GenerationsTableProps) {
const capture = usePostHogClientCapture();
const plan = useOrganizationPlan();
const [isExporting, setIsExporting] = useState(false);
const [searchQuery, setSearchQuery] = useQueryParam(
"search",
@@ -271,6 +277,7 @@ export default function GenerationsTable({
id: "id",
header: "ID",
size: 100,
isPinned: true,
cell: ({ row }) => {
const observationId = row.getValue("id");
const traceId = row.getValue("traceId");
@@ -675,7 +682,6 @@ export default function GenerationsTable({
defaultHidden: true,
cell: ({ row }) => {
const traceTags: string[] | undefined = row.getValue("traceTags");
console.log(traceTags);
return (
traceTags && (
<div
@@ -698,6 +704,11 @@ export default function GenerationsTable({
columns,
);
const [columnOrder, setColumnOrder] = useColumnOrder<GenerationsTableRow>(
"generationsColumnOrder",
columns,
);
const rows: GenerationsTableRow[] = useMemo(() => {
return generations.isSuccess
? generations.data.generations.map((generation) => {
@@ -750,41 +761,51 @@ export default function GenerationsTable({
columnsWithCustomSelect={["model", "name", "traceName", "promptName"]}
columnVisibility={columnVisibility}
setColumnVisibility={setColumnVisibilityState}
columnOrder={columnOrder}
setColumnOrder={setColumnOrder}
rowHeight={rowHeight}
setRowHeight={setRowHeight}
selectedOption={selectedOption}
setDateRangeAndOption={setDateRangeAndOption}
actionButtons={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-auto whitespace-nowrap">
<span className="hidden @6xl:inline">
{filterState.length > 0 || searchQuery
? "Export selection"
: "Export all"}{" "}
</span>
<span className="@6xl:hidden">Export</span>
{isExporting ? (
<Loader className="ml-2 h-4 w-4 animate-spin" />
) : (
<ChevronDownIcon className="ml-2 h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{Object.entries(exportOptions).map(([key, options]) => (
<DropdownMenuItem
key={key}
className="capitalize"
onClick={() =>
void handleExport(key as BatchExportFileFormat)
}
>
as {options.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
plan && isCloudPlan(plan) ? (
<BatchExportTableButton
{...{ projectId, filterState, orderByState }}
tableName={BatchExportTableName.Generations}
key="batchExport"
/>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-auto whitespace-nowrap">
<span className="hidden @6xl:inline">
{filterState.length > 0 || searchQuery
? "Export selection"
: "Export all"}{" "}
</span>
<span className="@6xl:hidden">Export</span>
{isExporting ? (
<Loader className="ml-2 h-4 w-4 animate-spin" />
) : (
<ChevronDownIcon className="ml-2 h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{Object.entries(exportOptions).map(([key, options]) => (
<DropdownMenuItem
key={key}
className="capitalize"
onClick={() =>
void handleExport(key as BatchExportFileFormat)
}
>
as {options.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
/>
<DataTable
@@ -811,6 +832,8 @@ export default function GenerationsTable({
}}
setOrderBy={setOrderByState}
orderBy={orderByState}
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibilityState}
rowHeight={rowHeight}
@@ -20,6 +20,7 @@ import { cn } from "@/src/utils/tailwind";
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
import useColumnOrder from "@/src/features/column-visibility/hooks/useColumnOrder";
export type ModelTableRow = {
modelId: string;
@@ -258,6 +259,11 @@ export default function ModelTable({ projectId }: { projectId: string }) {
const [columnVisibility, setColumnVisibility] =
useColumnVisibility<ModelTableRow>("modelsColumnVisibility", columns);
const [columnOrder, setColumnOrder] = useColumnOrder<ModelTableRow>(
"modelsColumnOrder",
columns,
);
const convertToTableRow = (model: Model): ModelTableRow => {
return {
modelId: model.id,
@@ -282,6 +288,8 @@ export default function ModelTable({ projectId }: { projectId: string }) {
columns={columns}
columnVisibility={columnVisibility}
setColumnVisibility={setColumnVisibility}
columnOrder={columnOrder}
setColumnOrder={setColumnOrder}
rowHeight={rowHeight}
setRowHeight={setRowHeight}
/>
@@ -309,6 +317,8 @@ export default function ModelTable({ projectId }: { projectId: string }) {
}}
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
rowHeight={rowHeight}
/>
</>
@@ -28,6 +28,7 @@ import {
PopoverTrigger,
} from "@/src/components/ui/popover";
import useLocalStorage from "@/src/components/useLocalStorage";
import useColumnOrder from "@/src/features/column-visibility/hooks/useColumnOrder";
type ScoreConfigTableRow = {
id: string;
@@ -169,6 +170,7 @@ export function ScoreConfigsTable({ projectId }: { projectId: string }) {
accessorKey: "action",
header: "Action",
size: 70,
isPinned: true,
enableHiding: true,
cell: ({ row }) => {
const { id: configId, isArchived, name } = row.original;
@@ -231,12 +233,19 @@ export function ScoreConfigsTable({ projectId }: { projectId: string }) {
columns,
);
const [columnOrder, setColumnOrder] = useColumnOrder<ScoreConfigTableRow>(
"scoreConfigsColumnOrder",
columns,
);
return (
<>
<DataTableToolbar
columns={columns}
columnVisibility={columnVisibility}
setColumnVisibility={setColumnVisibility}
columnOrder={columnOrder}
setColumnOrder={setColumnOrder}
rowHeight={rowHeight}
setRowHeight={setRowHeight}
/>
@@ -278,6 +287,8 @@ export function ScoreConfigsTable({ projectId }: { projectId: string }) {
}}
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
rowHeight={rowHeight}
className="gap-2"
paginationClassName="-mx-2 mb-2"
+16 -3
View File
@@ -26,6 +26,7 @@ import {
import { useQueryParams, withDefault, NumberParam } from "use-query-params";
import TagList from "@/src/features/tag/components/TagList";
import { cn } from "@/src/utils/tailwind";
import useColumnOrder from "@/src/features/column-visibility/hooks/useColumnOrder";
export type ScoresTableRow = {
id: string;
@@ -76,7 +77,7 @@ export default function ScoresTable({
observationId,
omittedFilter = [],
hiddenColumns = [],
tableColumnVisibilityName = "scoresColumnVisibility",
localStorageSuffix = "",
}: {
projectId: string;
userId?: string;
@@ -84,7 +85,7 @@ export default function ScoresTable({
observationId?: string;
omittedFilter?: string[];
hiddenColumns?: string[];
tableColumnVisibilityName?: string;
localStorageSuffix?: string;
}) {
const [paginationState, setPaginationState] = useQueryParams({
pageIndex: withDefault(NumberParam, 0),
@@ -371,7 +372,15 @@ export default function ScoresTable({
);
const [columnVisibility, setColumnVisibility] =
useColumnVisibility<ScoresTableRow>(tableColumnVisibilityName, columns);
useColumnVisibility<ScoresTableRow>(
"scoresColumnVisibility" + localStorageSuffix,
columns,
);
const [columnOrder, setColumnOrder] = useColumnOrder<ScoresTableRow>(
`scoresColumnOrder${localStorageSuffix}`,
columns,
);
const convertToTableRow = (
score: RouterOutput["scores"]["all"]["scores"][0],
@@ -420,6 +429,8 @@ export default function ScoresTable({
setFilterState={useDebounce(setUserFilterState)}
columnVisibility={columnVisibility}
setColumnVisibility={setColumnVisibility}
columnOrder={columnOrder}
setColumnOrder={setColumnOrder}
rowHeight={rowHeight}
setRowHeight={setRowHeight}
selectedOption={selectedOption}
@@ -451,6 +462,8 @@ export default function ScoresTable({
setOrderBy={setOrderByState}
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
rowHeight={rowHeight}
/>
</>
@@ -28,6 +28,7 @@ import { Skeleton } from "@/src/components/ui/skeleton";
import TagList from "@/src/features/tag/components/TagList";
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
import { cn } from "@/src/utils/tailwind";
import useColumnOrder from "@/src/features/column-visibility/hooks/useColumnOrder";
export type SessionTableRow = {
id: string;
@@ -170,6 +171,7 @@ export default function SessionsTable({
{
accessorKey: "bookmarked",
id: "bookmarked",
isPinned: true,
header: undefined,
size: 50,
cell: ({ row }) => {
@@ -194,6 +196,7 @@ export default function SessionsTable({
id: "id",
header: "ID",
size: 200,
isPinned: true,
cell: ({ row }) => {
const value: SessionTableRow["id"] = row.getValue("id");
return value && typeof value === "string" ? (
@@ -449,6 +452,11 @@ export default function SessionsTable({
const [columnVisibility, setColumnVisibility] =
useColumnVisibility<SessionTableRow>("sessionsColumnVisibility", columns);
const [columnOrder, setColumnOrder] = useColumnOrder<SessionTableRow>(
"sessionsColumnOrder",
columns,
);
return (
<>
<DataTableToolbar
@@ -458,6 +466,8 @@ export default function SessionsTable({
columns={columns}
columnVisibility={columnVisibility}
setColumnVisibility={setColumnVisibility}
columnOrder={columnOrder}
setColumnOrder={setColumnOrder}
actionButtons={[
<BatchExportTableButton
{...{ projectId, filterState, orderByState }}
@@ -511,6 +521,8 @@ export default function SessionsTable({
orderBy={orderByState}
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
help={{
description:
"A session is a collection of related traces, such as a conversation or thread. To begin, add a sessionId to the trace.",
+24 -3
View File
@@ -33,6 +33,7 @@ import {
type TraceOptions,
tracesTableColsWithOptions,
type ObservationLevel,
BatchExportTableName,
} from "@langfuse/shared";
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
@@ -46,6 +47,8 @@ import { type ScoreAggregate } from "@/src/features/scores/lib/types";
import { useIndividualScoreColumns } from "@/src/features/scores/hooks/useIndividualScoreColumns";
import { joinTableCoreAndMetrics } from "@/src/components/table/utils/joinTableCoreAndMetrics";
import { Skeleton } from "@/src/components/ui/skeleton";
import useColumnOrder from "@/src/features/column-visibility/hooks/useColumnOrder";
import { BatchExportTableButton } from "@/src/components/BatchExportTableButton";
export type TracesTableRow = {
bookmarked: boolean;
@@ -226,6 +229,7 @@ export default function TracesTable({
id: "select",
accessorKey: "select",
size: 30,
isPinned: true,
header: ({ table }) => (
<Checkbox
checked={
@@ -259,6 +263,7 @@ export default function TracesTable({
header: undefined,
id: "bookmarked",
size: 30,
isPinned: true,
cell: ({ row }) => {
const bookmarked: TracesTableRow["bookmarked"] =
row.getValue("bookmarked");
@@ -281,6 +286,7 @@ export default function TracesTable({
header: "ID",
id: "id",
size: 90,
isPinned: true,
cell: ({ row }) => {
const value: TracesTableRow["id"] = row.getValue("id");
return value && typeof value === "string" ? (
@@ -656,6 +662,7 @@ export default function TracesTable({
accessorKey: "action",
header: "Action",
size: 70,
isPinned: true,
cell: ({ row }) => {
const traceId: TracesTableRow["id"] = row.getValue("id");
return traceId && typeof traceId === "string" ? (
@@ -678,6 +685,11 @@ export default function TracesTable({
columns,
);
const [columnOrder, setColumnOrder] = useColumnOrder<TracesTableRow>(
"tracesColumnOrder",
columns,
);
const rows = useMemo(() => {
return traces.isSuccess
? traceRowData?.rows?.map((trace) => {
@@ -726,7 +738,7 @@ export default function TracesTable({
filterState={userFilterState}
setFilterState={useDebounce(setUserFilterState)}
columnsWithCustomSelect={["name", "tags"]}
actionButtons={
actionButtons={[
Object.keys(selectedRows).filter((traceId) =>
traces.data?.traces.map((t) => t.id).includes(traceId),
).length > 0 ? (
@@ -740,10 +752,17 @@ export default function TracesTable({
setSelectedRows({});
}}
/>
) : null
}
) : null,
<BatchExportTableButton
{...{ projectId, filterState, orderByState }}
tableName={BatchExportTableName.Traces}
key="batchExport"
/>,
]}
columnVisibility={columnVisibility}
setColumnVisibility={setColumnVisibility}
columnOrder={columnOrder}
setColumnOrder={setColumnOrder}
rowHeight={rowHeight}
setRowHeight={setRowHeight}
selectedOption={selectedOption}
@@ -777,6 +796,8 @@ export default function TracesTable({
setRowSelection={setSelectedRows}
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
rowHeight={rowHeight}
/>
</>
@@ -239,7 +239,7 @@ export const ObservationPreview = (props: {
"jobConfigurationId",
"userId",
]}
tableColumnVisibilityName="scoresColumnVisibilityObservationPreview"
localStorageSuffix="ObservationPreview"
/>
)}
</CardContent>
+1 -1
View File
@@ -148,7 +148,7 @@ export const TracePreview = ({
omittedFilter={["Trace ID"]}
traceId={trace.id}
hiddenColumns={["traceName", "jobConfigurationId", "userId"]}
tableColumnVisibilityName="scoresColumnVisibilityTracePreview"
localStorageSuffix="TracePreview"
/>
)}
</CardContent>
+12 -11
View File
@@ -1,6 +1,6 @@
import { Card } from "@/src/components/ui/card";
import { type ObservationReturnType } from "@/src/server/api/routers/traces";
import { type APIScore, type Trace } from "@langfuse/shared";
import { isPresent, type APIScore, type Trace } from "@langfuse/shared";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { SimpleTreeView } from "@mui/x-tree-view/SimpleTreeView";
@@ -41,7 +41,8 @@ const MIN_LABEL_WIDTH = 250;
const TREE_INDENTATION = 12; // default in MUI X TreeView
const PREDEFINED_STEP_SIZES = [
0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 2.5, 3, 4, 5, 6, 7, 8, 9, 10,
0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 2.5, 3, 4, 5, 6, 7, 8, 9, 10, 15, 20, 25,
35, 40, 45, 50, 100, 150, 200, 250, 300, 350, 400, 450, 500,
];
const getNestedObservationKeys = (
@@ -81,7 +82,7 @@ function TreeItemInner({
level = 0,
cardWidth,
}: {
latency: number;
latency?: number;
totalScaleSpan: number;
type: TreeItemType;
startOffset?: number;
@@ -91,7 +92,7 @@ function TreeItemInner({
level?: number;
cardWidth: number;
}) {
const itemWidth = (latency / totalScaleSpan) * SCALE_WIDTH;
const itemWidth = ((latency ?? 0) / totalScaleSpan) * SCALE_WIDTH;
const itemOffsetLabelWidth = itemWidth + startOffset + LABEL_WIDTH;
const customLabelWidth = cardWidth - SCALE_WIDTH - CARD_PADDING;
@@ -163,12 +164,12 @@ function TreeItemInner({
"hidden justify-end text-xs text-muted-foreground group-hover:block",
itemOffsetLabelWidth > SCALE_WIDTH
? "mr-1"
: !!latency
? "-mr-9"
: isPresent(latency)
? `-mr-9`
: "-mr-6",
)}
>
{!!latency ? `${latency.toFixed(2)}s` : "n/a"}
{isPresent(latency) ? `${latency.toFixed(2)}s` : "n/a"}
</span>
</div>
</div>
@@ -203,7 +204,7 @@ function TraceTreeItem({
const latency = endTime
? (endTime.getTime() - startTime.getTime()) / 1000
: 0;
: undefined;
const startOffset =
((startTime.getTime() - traceStartTime.getTime()) / totalScaleSpan / 1000) *
SCALE_WIDTH;
@@ -353,9 +354,9 @@ export function TraceTimelineView({
const totalScaleSpan = stepSize * (SCALE_WIDTH / STEP_SIZE);
return (
<div ref={parentRef} className="w-full">
<div ref={parentRef} className="h-full w-full">
<Card
className="flex max-h-[calc(100dvh-24rem)] flex-col overflow-x-auto overflow-y-hidden"
className="flex max-h-full flex-col overflow-x-auto overflow-y-hidden"
style={{ width: cardWidth }}
>
<div className="grid w-full grid-cols-[1fr,auto] items-center p-2">
@@ -406,7 +407,7 @@ export function TraceTimelineView({
className="absolute -right-2 text-xs text-muted-foreground"
key={index}
>
{step.toFixed(2)}s
{step.toFixed(latency.toString().length >= 8 ? 0 : 2)}s
</span>
) : (
<div
+1 -1
View File
@@ -387,7 +387,7 @@ export function TracePage({ traceId }: { traceId: string }) {
</div>
)}
{selectedTab === "timeline" && (
<div className="mt-5 flex-1 flex-col space-y-5 overflow-hidden">
<div className="mt-5 max-h-[calc(100dvh-16rem)] flex-1 flex-col space-y-5 overflow-hidden">
<TraceTimelineView
key={trace.data.id}
trace={trace.data}
+3 -2
View File
@@ -22,8 +22,9 @@ const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
hasCustomIcon?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
>(({ className, inset, children, hasCustomIcon = false, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
@@ -34,7 +35,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
{!hasCustomIcon && <ChevronRight className="ml-auto h-4 w-4" />}
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =

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