Compare commits

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

* fix

---------

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

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

* add plan label to org switcher

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

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-31 17:04:49 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e215a2db99 chore(deps-dev): bump @types/lodash from 4.17.5 to 4.17.7 in the lodash group (#3169)
chore(deps-dev): bump @types/lodash in the lodash group

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


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

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

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

* also run on main branch

* also run on main branch

* also run on main branch

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-30 21:52:41 +02:00
dependabot[bot]andGitHub 4648a5be8b chore(deps): bump lucide-react from 0.429.0 to 0.436.0 (#3154) 2024-08-30 21:02:28 +02:00
202 changed files with 4687 additions and 2416 deletions
+19 -2
View File
@@ -28,6 +28,9 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
# Use CSP headers to enforce HTTPS, optional
# LANGFUSE_CSP_ENFORCE_HTTPS="true"
# Configure base path for self-hosting, optional
# Note: You need to build the docker image with the base path set and cannot use the pre-built docker image if you set this.
# NEXT_PUBLIC_BASE_PATH="/app"
# Docker only, optional
# PORT=3000
@@ -39,6 +42,10 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
# LANGFUSE_DEFAULT_PROJECT_ID=
# LANGFUSE_DEFAULT_PROJECT_ROLE=
# Logging, optional
# LANGFUSE_LOG_LEVEL=info
# LANGFUSE_LOG_FORMAT=text
# Enable experimental features, optional
# LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=true
@@ -97,6 +104,17 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
# The page size can be adjusted if needed to optimize performance
# DB_EXPORT_PAGE_SIZE=1000
# Automated provisioning of default resources
# LANGFUSE_INIT_ORG_ID=org-id
# LANGFUSE_INIT_ORG_NAME=org-name
# LANGFUSE_INIT_PROJECT_ID=project-id
# LANGFUSE_INIT_PROJECT_NAME=project-name
# LANGFUSE_INIT_PROJECT_PUBLIC_KEY=pk-1234567890
# LANGFUSE_INIT_PROJECT_SECRET_KEY=sk-1234567890
# LANGFUSE_INIT_USER_EMAIL=user@example.com
# LANGFUSE_INIT_USER_NAME=User Name
# LANGFUSE_INIT_USER_PASSWORD=password
### START Enterprise Edition Configuration
@@ -145,7 +163,6 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
# NEXT_SENTRY_PROJECT=
# SENTRY_AUTH_TOKEN=
# SENTRY_CSP_REPORT_URI=
# LANGFUSE_WORKER_BETTERSTACK_TOKEN=
# Cloudflare Turnstile
@@ -201,8 +218,8 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_BATCH_SIZE=
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=
# LANGFUSE_INGESTION_CLICKHOUSE_MAX_ATTEMPTS=
# LANGFUSE_LOG_LEVEL=
# LANGFUSE_LEGACY_INGESTION_WORKER_CONCURRENCY=
# LANGFUSE_ASYNC_INGESTION_PROCESSING="true"
# QUEUE_CONSUMER_LEGACY_INGESTION_QUEUE_IS_ENABLED="true"
## END Langfuse V3 Ingestion
+4
View File
@@ -32,6 +32,10 @@ updates:
patterns:
- "lodash"
- "@types/lodash"
express:
patterns:
- "express"
- "@types/express"
observability:
patterns:
- "dd-trace"
+69
View File
@@ -0,0 +1,69 @@
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=${{ inputs.environment }} \
--build-arg NEXT_PUBLIC_SENTRY_DSN=${{ vars.NEXT_PUBLIC_SENTRY_DSN }} \
--build-arg NEXT_PUBLIC_BUILD_ID=${{ github.sha }} \
--build-arg NEXT_PUBLIC_POSTHOG_KEY=${{ vars.NEXT_PUBLIC_POSTHOG_KEY }} \
--build-arg NEXT_PUBLIC_POSTHOG_HOST=${{ vars.NEXT_PUBLIC_POSTHOG_HOST }} \
--build-arg NEXT_PUBLIC_CRISP_WEBSITE_ID=${{ vars.NEXT_PUBLIC_CRISP_WEBSITE_ID }} \
.
docker push $REGISTRY/$REPOSITORY:$IMAGE_TAG
- name: Render AWS ECS Task Definition
id: render-task-definition
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
container-name: ${{ inputs.service }}
image: ${{ steps.login-ecr.outputs.registry }}/${{ steps.split.outputs._0 }}:${{ github.sha }}
task-definition-family: ${{ inputs.environment }}-${{ inputs.service }}
- name: Update AWS ECS Service
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: ${{ steps.render-task-definition.outputs.task-definition }}
service: ${{ inputs.environment }}-${{ inputs.service }}
cluster: ${{ inputs.environment }}-cluster
wait-for-service-stability: true
+96
View File
@@ -0,0 +1,96 @@
on:
push:
branches:
- main
workflow_dispatch:
inputs:
service:
description: 'Service to be deployed'
type: choice
options:
- web
- web-ingestion
- worker
required: true
environment:
description: 'Environment to deploy to'
type: choice
options:
- staging
# - prod-us
# - prod-eu
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") {
return `["${context.payload.inputs.app}"]`
}
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.result}` ?? '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") {
// TODO: Can we distinguish push branch vs push tag for staging vs. prod?
return `["staging"]`
}
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.result}` ?? '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 }}
+20 -1
View File
@@ -40,6 +40,9 @@ jobs:
- name: Checkout
uses: actions/checkout@v3
- name: Set NEXT_PUBLIC_BUILD_ID
run: echo "NEXT_PUBLIC_BUILD_ID=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Build and run both images from compose
run: |
docker compose -f docker-compose.build.yml up -d
@@ -107,13 +110,26 @@ jobs:
- name: Seed DB
run: |
pnpm run db:migrate
pnpm run db:seed
- name: Build
run: pnpm run build
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
- name: Start Langfuse
run: (pnpm run start&)
env:
LANGFUSE_INIT_ORG_ID: "seed-org-id"
LANGFUSE_INIT_ORG_NAME: "Seed Org"
LANGFUSE_INIT_PROJECT_ID: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a"
LANGFUSE_INIT_PROJECT_NAME: "Seed Project"
LANGFUSE_INIT_PROJECT_PUBLIC_KEY: "pk-lf-1234567890"
LANGFUSE_INIT_PROJECT_SECRET_KEY: "sk-lf-1234567890"
LANGFUSE_INIT_USER_EMAIL: "demo@langfuse.com"
LANGFUSE_INIT_USER_NAME: "Demo User"
LANGFUSE_INIT_USER_PASSWORD: "password"
- name: run tests
run: pnpm --filter=web run test
@@ -317,6 +333,9 @@ jobs:
- name: Checkout
uses: actions/checkout@v3
- name: Set NEXT_PUBLIC_BUILD_ID
run: echo "NEXT_PUBLIC_BUILD_ID=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
@@ -27,3 +27,4 @@ jobs:
PORTER_STACK_NAME: web
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4060 }}
PORTER_NEXT_PUBLIC_BUILD_ID: ${{ steps.vars.outputs.sha_short }}
@@ -27,3 +27,4 @@ jobs:
PORTER_STACK_NAME: web
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4037 }}
PORTER_NEXT_PUBLIC_BUILD_ID: ${{ steps.vars.outputs.sha_short }}
@@ -27,3 +27,4 @@ jobs:
PORTER_STACK_NAME: web
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4054 }}
PORTER_NEXT_PUBLIC_BUILD_ID: ${{ steps.vars.outputs.sha_short }}
+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 -9
View File
@@ -11,24 +11,17 @@ groups:
namespaceExport: Langfuse
allowCustomFetcher: true
- name: fernapi/fern-python-sdk
version: 1.1.0-rc2
version: 2.16.0
output:
location: local-file-system
path: ../../../generated/python
config:
client_class_name: FernLangfuse
improved_imports: false
inline_request_params: false
pydantic_config:
require_optional_fields: false
use_str_enums: false
- name: fernapi/fern-typescript-node-sdk
version: 0.7.1
output:
location: local-file-system
path: ../../../generated/typescript-server
config:
namespaceExport: Langfuse
allowCustomFetcher: true
- name: fernapi/fern-postman
version: 0.0.45
output:
+1 -1
View File
@@ -1,4 +1,4 @@
{
"organization": "finto",
"version": "0.30.3"
"version": "0.41.1"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "2.75.2",
"version": "2.79.0",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
+10 -4
View File
@@ -21,6 +21,10 @@
"import": "./dist/src/server/index.js",
"require": "./dist/src/server/index.js"
},
"./src/server/auth/apiKeys": {
"import": "./dist/src/server/auth/apiKeys.js",
"require": "./dist/src/server/auth/apiKeys.js"
},
"./encryption": {
"import": "./dist/src/encryption/index.js",
"require": "./dist/src/encryption/index.js"
@@ -51,7 +55,7 @@
"dependencies": {
"@anthropic-ai/tokenizer": "^0.0.4",
"@aws-sdk/client-s3": "^3.627.0",
"@aws-sdk/lib-storage": "^3.568.0",
"@aws-sdk/lib-storage": "^3.645.0",
"@aws-sdk/s3-request-presigner": "^3.554.0",
"@clickhouse/client": "^1.4.0",
"@langchain/anthropic": "^0.1.21",
@@ -75,13 +79,15 @@
"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.5",
"@types/lodash": "^4.17.7",
"@types/node": "^20.11.29",
"@types/nodemailer": "^6.4.15",
"@types/pg": "^8.11.6",
@@ -103,7 +109,7 @@
"vitest": "^1.5.3"
},
"peerDependencies": {
"@types/react": "^18.2.79",
"react": "^18.0.0"
"@types/react": "~18.2.79",
"react": "~18.2.0"
}
}
@@ -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)
+46 -46
View File
@@ -12,7 +12,7 @@ import { parseArgs } from "node:util";
import { chunk } from "lodash";
import { v4 } from "uuid";
import { ModelUsageUnit } from "../src";
import { getDisplaySecretKey, hashSecretKey } from "../src/server";
import { getDisplaySecretKey, hashSecretKey, logger } from "../src/server";
import { encrypt } from "../src/encryption";
import { redis } from "../src/server/redis/redis";
@@ -265,11 +265,11 @@ async function main() {
project1,
project2,
promptIds,
configIdsAndNames
configIdsAndNames,
);
console.log(
`Seeding ${traces.length} traces, ${observations.length} observations, and ${scores.length} scores`
logger.info(
`Seeding ${traces.length} traces, ${observations.length} observations, and ${scores.length} scores`,
);
await uploadObjects(
@@ -278,7 +278,7 @@ async function main() {
scores,
sessions,
events,
comments
comments,
);
// If openai key is in environment, add it to the projects LLM API keys
@@ -295,8 +295,8 @@ async function main() {
},
});
} else {
console.warn(
"No OPENAI_API_KEY found in environment. Skipping seeding LLM API key."
logger.warn(
"No OPENAI_API_KEY found in environment. Skipping seeding LLM API key.",
);
}
@@ -431,7 +431,7 @@ async function main() {
for (const datasetItemId of datasetItemIds) {
const relevantObservations = observations.filter(
(o) => o.projectId === project2.id
(o) => o.projectId === project2.id,
);
const observation =
relevantObservations[
@@ -457,13 +457,13 @@ main()
.then(async () => {
await prisma.$disconnect();
redis?.disconnect();
console.log("Disconnected from postgres and redis");
logger.info("Disconnected from postgres and redis");
})
.catch(async (e) => {
console.error(e);
logger.error(e);
await prisma.$disconnect();
redis?.disconnect();
console.log("Disconnected from postgres and redis");
logger.info("Disconnected from postgres and redis");
process.exit(1);
});
@@ -473,7 +473,7 @@ async function uploadObjects(
scores: Prisma.ScoreCreateManyInput[],
sessions: Prisma.TraceSessionCreateManyInput[],
events: Prisma.ObservationCreateManyInput[],
comments: Prisma.CommentCreateManyInput[]
comments: Prisma.CommentCreateManyInput[],
) {
let promises: Prisma.PrismaPromise<unknown>[] = [];
@@ -487,14 +487,14 @@ async function uploadObjects(
},
create: chunk[0]!,
update: {},
})
}),
);
});
for (let i = 0; i < promises.length; i++) {
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
console.log(
`Seeding of Sessions ${((i + 1) / promises.length) * 100}% complete`
logger.info(
`Seeding of Sessions ${((i + 1) / promises.length) * 100}% complete`,
);
await promises[i];
}
@@ -505,13 +505,13 @@ async function uploadObjects(
promises.push(
prisma.trace.createMany({
data: chunk,
})
}),
);
});
for (let i = 0; i < promises.length; i++) {
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
console.log(
`Seeding of Traces ${((i + 1) / promises.length) * 100}% complete`
logger.info(
`Seeding of Traces ${((i + 1) / promises.length) * 100}% complete`,
);
await promises[i];
}
@@ -521,14 +521,14 @@ async function uploadObjects(
promises.push(
prisma.observation.createMany({
data: chunk,
})
}),
);
});
for (let i = 0; i < promises.length; i++) {
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
console.log(
`Seeding of Observations ${((i + 1) / promises.length) * 100}% complete`
logger.info(
`Seeding of Observations ${((i + 1) / promises.length) * 100}% complete`,
);
await promises[i];
}
@@ -538,14 +538,14 @@ async function uploadObjects(
promises.push(
prisma.observation.createMany({
data: chunk,
})
}),
);
});
for (let i = 0; i < promises.length; i++) {
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
console.log(
`Seeding of Events ${((i + 1) / promises.length) * 100}% complete`
logger.info(
`Seeding of Events ${((i + 1) / promises.length) * 100}% complete`,
);
await promises[i];
}
@@ -555,13 +555,13 @@ async function uploadObjects(
promises.push(
prisma.score.createMany({
data: chunk,
})
}),
);
});
for (let i = 0; i < promises.length; i++) {
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
console.log(
`Seeding of Scores ${((i + 1) / promises.length) * 100}% complete`
logger.info(
`Seeding of Scores ${((i + 1) / promises.length) * 100}% complete`,
);
await promises[i];
}
@@ -571,13 +571,13 @@ async function uploadObjects(
promises.push(
prisma.comment.createMany({
data: chunk,
})
}),
);
});
for (let i = 0; i < promises.length; i++) {
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
console.log(
`Seeding of Comments ${((i + 1) / promises.length) * 100}% complete`
logger.info(
`Seeding of Comments ${((i + 1) / promises.length) * 100}% complete`,
);
await promises[i];
}
@@ -598,7 +598,7 @@ function createObjects(
dataType: ScoreDataType;
categories: ConfigCategory[] | null;
}[]
>
>,
) {
const traces: Prisma.TraceCreateManyInput[] = [];
const observations: Prisma.ObservationCreateManyInput[] = [];
@@ -612,7 +612,7 @@ function createObjects(
// print progress to console with a progress bar that refreshes every 10 iterations
// random date within last 90 days, with a linear bias towards more recent dates
const traceTs = new Date(
Date.now() - Math.floor(Math.random() ** 1.5 * 90 * 24 * 60 * 60 * 1000)
Date.now() - Math.floor(Math.random() ** 1.5 * 90 * 24 * 60 * 60 * 1000),
);
const envTag = envTags[Math.floor(Math.random() * envTags.length)];
@@ -753,11 +753,11 @@ function createObjects(
for (let j = 0; j < Math.floor(Math.random() * 10) + 1; j++) {
// add between 1 and 30 ms to trace timestamp
const spanTsStart = new Date(
traceTs.getTime() + Math.floor(Math.random() * 30)
traceTs.getTime() + Math.floor(Math.random() * 30),
);
// random duration of upto 5000ms
const spanTsEnd = new Date(
spanTsStart.getTime() + Math.floor(Math.random() * 5000)
spanTsStart.getTime() + Math.floor(Math.random() * 5000),
);
const span = {
@@ -792,22 +792,22 @@ function createObjects(
const generationTsStart = new Date(
spanTsStart.getTime() +
Math.floor(
Math.random() * (spanTsEnd.getTime() - spanTsStart.getTime())
)
Math.random() * (spanTsEnd.getTime() - spanTsStart.getTime()),
),
);
const generationTsEnd = new Date(
generationTsStart.getTime() +
Math.floor(
Math.random() *
(spanTsEnd.getTime() - generationTsStart.getTime())
)
(spanTsEnd.getTime() - generationTsStart.getTime()),
),
);
// somewhere in the middle
const generationTsCompletionStart = new Date(
generationTsStart.getTime() +
Math.floor(
(generationTsEnd.getTime() - generationTsStart.getTime()) / 3
)
(generationTsEnd.getTime() - generationTsStart.getTime()) / 3,
),
);
const promptTokens = Math.floor(Math.random() * 1000) + 300;
@@ -828,7 +828,7 @@ function createObjects(
const promptId =
promptIds.get(projectId)![
Math.floor(
Math.random() * Math.floor(promptIds.get(projectId)!.length / 2)
Math.random() * Math.floor(promptIds.get(projectId)!.length / 2),
)
];
@@ -910,8 +910,8 @@ function createObjects(
const eventTs = new Date(
spanTsStart.getTime() +
Math.floor(
Math.random() * (spanTsEnd.getTime() - spanTsStart.getTime())
)
Math.random() * (spanTsEnd.getTime() - spanTsStart.getTime()),
),
);
events.push({
@@ -933,7 +933,7 @@ function createObjects(
}
// find unique sessions by id and projectid
const uniqueSessions: Prisma.TraceSessionCreateManyInput[] = Array.from(
new Set(sessions.map((session) => JSON.stringify(session)))
new Set(sessions.map((session) => JSON.stringify(session))),
).map((session) => JSON.parse(session) as Prisma.TraceSessionCreateManyInput);
return {
@@ -954,7 +954,7 @@ async function generatePromptsForProject(projects: Project[]) {
projects.map(async (project) => {
const promptIdsForProject = await generatePrompts(project);
promptIds.set(project.id, promptIdsForProject);
})
}),
);
return promptIds;
}
@@ -1140,7 +1140,7 @@ async function generateConfigsForProject(projects: Project[]) {
projects.map(async (project) => {
const configNameAndId = await generateConfigs(project);
projectIdsToConfigs.set(project.id, configNameAndId);
})
}),
);
return projectIdsToConfigs;
}
+28 -7
View File
@@ -1,7 +1,7 @@
// This file exports the prisma db connection, the Prisma Object, and the Typescript types.
// This is not imported in the index.ts file of this package, as we must not import this into FE code.
import { PrismaClient } from "@prisma/client";
import { Prisma, PrismaClient } from "@prisma/client";
import { env } from "process";
import kyselyExtension from "prisma-extension-kysely";
import {
@@ -11,17 +11,38 @@ import {
PostgresQueryCompiler,
} from "kysely";
import { DB } from ".";
import { logger } from "./server";
// Instantiated according to the Prisma documentation
// https://www.prisma.io/docs/orm/more/help-and-troubleshooting/help-articles/nextjs-prisma-client-dev-practices
const prismaClientSingleton = () => {
return new PrismaClient({
log:
env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["error", "warn"],
const client = new PrismaClient<
Prisma.PrismaClientOptions,
"warn" | "error" | "query"
>({
log: [
{ emit: "event", level: "query" },
{ emit: "event", level: "error" },
{ emit: "event", level: "warn" },
],
});
if (env.NODE_ENV === "development") {
client.$on("query", (event) => {
logger.info(`prisma:query ${event.query}, ${event.duration}ms`);
});
}
client.$on("warn", (event) => {
logger.warn(`prisma:warn ${event.message}`);
});
client.$on("error", (event) => {
logger.error(`prisma:error ${event.message}`);
});
return client;
};
const kyselySingleton = (prismaClient: PrismaClient) => {
@@ -38,7 +59,7 @@ const kyselySingleton = (prismaClient: PrismaClient) => {
createQueryCompiler: () => new PostgresQueryCompiler(),
},
}),
})
}),
);
};
declare global {
+6 -1
View File
@@ -21,7 +21,7 @@ const EnvSchema = z.object({
.string()
.length(
64,
"ENCRYPTION_KEY must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32"
"ENCRYPTION_KEY must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32",
)
.optional(),
LANGFUSE_CACHE_PROMPT_ENABLED: z.enum(["true", "false"]).default("false"),
@@ -38,6 +38,11 @@ const EnvSchema = z.object({
.number()
.positive()
.default(60 * 10),
SALT: z.string().optional(), // used by components imported by web package
LANGFUSE_LOG_LEVEL: z
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
.optional(),
LANGFUSE_LOG_FORMAT: z.enum(["text", "json"]).default("text"),
});
export const env = EnvSchema.parse(process.env);
@@ -19,6 +19,7 @@ export enum BatchExportFileFormat {
export enum BatchExportTableName {
Sessions = "sessions",
Traces = "traces",
}
export const exportOptions: Record<
@@ -83,13 +83,13 @@ export const ScoreBodyWithoutConfig = z.discriminatedUnion("dataType", [
z.object({
value: z.number(),
dataType: z.literal("NUMERIC"),
})
}),
),
BaseScoreBody.merge(
z.object({
value: z.string(),
dataType: z.literal("CATEGORICAL"),
})
}),
),
BaseScoreBody.merge(
z.object({
@@ -97,7 +97,7 @@ export const ScoreBodyWithoutConfig = z.discriminatedUnion("dataType", [
message: "Value must be either 0 or 1",
}),
dataType: z.literal("BOOLEAN"),
})
}),
),
]);
@@ -161,7 +161,7 @@ export const ScorePropsAgainstConfig = z.union([
*/
export const filterAndValidateDbScoreList = (
scores: Score[],
onParseError?: (error: z.ZodError) => void
onParseError?: (error: z.ZodError) => void,
): APIScore[] =>
scores.reduce((acc, ts) => {
const result = APIScoreSchema.safeParse(ts);
@@ -198,14 +198,14 @@ export const PostScoresBody = z.discriminatedUnion("dataType", [
value: z.number(),
dataType: z.literal("NUMERIC"),
configId: z.string().nullish(),
})
}),
),
BaseScoreBody.merge(
z.object({
value: z.string(),
dataType: z.literal("CATEGORICAL"),
configId: z.string().nullish(),
})
}),
),
BaseScoreBody.merge(
z.object({
@@ -215,14 +215,14 @@ export const PostScoresBody = z.discriminatedUnion("dataType", [
}),
dataType: z.literal("BOOLEAN"),
configId: z.string().nullish(),
})
}),
),
BaseScoreBody.merge(
z.object({
value: z.union([z.string(), z.number()]),
dataType: z.undefined(),
configId: z.string().nullish(),
})
}),
),
]);
@@ -256,7 +256,7 @@ const LegacyGetScoreResponseDataV1 = z.intersection(
trace: z.object({
userId: z.string().nullish(),
}),
})
}),
);
export const GetScoresResponse = z.object({
data: z.array(LegacyGetScoreResponseDataV1),
@@ -265,7 +265,7 @@ export const GetScoresResponse = z.object({
export const legacyFilterAndValidateV1GetScoreList = (
scores: unknown[],
onParseError?: (error: z.ZodError) => void
onParseError?: (error: z.ZodError) => void,
): z.infer<typeof LegacyGetScoreResponseDataV1>[] =>
scores.reduce(
(acc: z.infer<typeof LegacyGetScoreResponseDataV1>[], ts) => {
@@ -278,7 +278,7 @@ export const legacyFilterAndValidateV1GetScoreList = (
}
return acc;
},
[] as z.infer<typeof LegacyGetScoreResponseDataV1>[]
[] as z.infer<typeof LegacyGetScoreResponseDataV1>[],
);
// GET /scores/{scoreId}
+1 -4
View File
@@ -1,15 +1,12 @@
export * from "./constants";
export * from "./queries";
export * from "./interfaces/filters";
export * from "./interfaces/orderBy";
export * from "./interfaces/cloudConfigSchema";
export * from "./interfaces/parseDbOrg";
export * from "./tableDefinitions";
export * from "./types";
export * from "./filterToPrisma";
export * from "./orderByToPrisma";
export * from "./tracesTable";
export * from "./server/auth/auth";
export * from "./server/auth/apiKeys";
export * from "./observationsTable";
export * from "./utils/zod";
export * from "./utils/json";
+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,
},
{
@@ -0,0 +1,81 @@
import { PrismaClient } from "@prisma/client";
import { compare, hash } from "bcryptjs";
import { randomUUID } from "crypto";
import * as crypto from "crypto";
import { env } from "../../env";
export function getDisplaySecretKey(secretKey: string) {
return secretKey.slice(0, 6) + "..." + secretKey.slice(-4);
}
export async function hashSecretKey(key: string) {
// legacy, uses bcrypt, transformed into hashed key upon first use
const hashedKey = await hash(key, 11);
return hashedKey;
}
async function generateKeySet() {
return {
pk: `pk-lf-${randomUUID()}`,
sk: `sk-lf-${randomUUID()}`,
};
}
export async function verifySecretKey(key: string, hashedKey: string) {
const isValid = await compare(key, hashedKey);
return isValid;
}
export function createShaHash(privateKey: string, salt: string): string {
const hash = crypto
.createHash("sha256")
.update(privateKey)
.update(crypto.createHash("sha256").update(salt, "utf8").digest("hex"))
.digest("hex");
return hash;
}
export async function createAndAddApiKeysToDb(p: {
prisma: PrismaClient;
projectId: string;
note?: string;
predefinedKeys?: {
secretKey: string;
publicKey: string;
};
}) {
const salt = env.SALT;
if (!salt) {
throw new Error("SALT is not set");
}
const { pk, sk } = p.predefinedKeys
? { pk: p.predefinedKeys.publicKey, sk: p.predefinedKeys.secretKey }
: await generateKeySet();
const hashedSk = await hashSecretKey(sk);
const displaySk = getDisplaySecretKey(sk);
const hashFromProvidedKey = createShaHash(sk, salt);
const apiKey = await p.prisma.apiKey.create({
data: {
projectId: p.projectId,
publicKey: pk,
hashedSecretKey: hashedSk,
displaySecretKey: displaySk,
fastHashedSecretKey: hashFromProvidedKey,
note: p.note,
},
});
return {
id: apiKey.id,
createdAt: apiKey.createdAt,
note: apiKey.note,
publicKey: apiKey.publicKey,
secretKey: sk,
displaySecretKey: displaySk,
};
}
-81
View File
@@ -1,81 +0,0 @@
import { compare, hash } from "bcryptjs";
import { randomUUID } from "crypto";
import * as crypto from "crypto";
import type { OAuthConfig, OAuthUserConfig } from "next-auth/providers/oauth";
export function generateSecretKey() {
return `sk-lf-${randomUUID()}`;
}
export function generatePublicKey() {
return `pk-lf-${randomUUID()}`;
}
export function getDisplaySecretKey(secretKey: string) {
return secretKey.slice(0, 6) + "..." + secretKey.slice(-4);
}
export async function hashSecretKey(key: string) {
// legacy, uses bcrypt, transformed into hashed key upon first use
const hashedKey = await hash(key, 11);
return hashedKey;
}
export async function generateKeySet() {
const pk = generatePublicKey();
const sk = generateSecretKey();
const hashedSk = await hashSecretKey(sk);
const displaySk = getDisplaySecretKey(sk);
return {
pk,
sk,
hashedSk,
displaySk,
};
}
export async function verifySecretKey(key: string, hashedKey: string) {
const isValid = await compare(key, hashedKey);
return isValid;
}
export function createShaHash(privateKey: string, salt: string): string {
const hash = crypto
.createHash("sha256")
.update(privateKey)
.update(crypto.createHash("sha256").update(salt, "utf8").digest("hex"))
.digest("hex");
return hash;
}
export interface CustomSSOUser extends Record<string, any> {
email: string;
id: string;
name: string;
verified: boolean;
}
export function CustomSSOProvider<P extends CustomSSOUser>(
options: OAuthUserConfig<P>
): OAuthConfig<P> {
return {
id: "custom",
name: "CustomSSOProvider",
type: "oauth",
wellKnown: `${options.issuer}/.well-known/openid-configuration`,
authorization: { params: { scope: "openid email profile" } }, // overridden by options.authorization to be able to set custom scopes, deep merged with this default
checks: ["pkce", "state"],
idToken: true,
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
image: null,
};
},
options,
};
}
@@ -0,0 +1,31 @@
import type { OAuthConfig, OAuthUserConfig } from "next-auth/providers/oauth";
interface CustomSSOUser extends Record<string, any> {
email: string;
id: string;
name: string;
verified: boolean;
}
export function CustomSSOProvider<P extends CustomSSOUser>(
options: OAuthUserConfig<P>
): OAuthConfig<P> {
return {
id: "custom",
name: "CustomSSOProvider",
type: "oauth",
wellKnown: `${options.issuer}/.well-known/openid-configuration`,
authorization: { params: { scope: "openid email profile" } }, // overridden by options.authorization to be able to set custom scopes, deep merged with this default
checks: ["pkce", "state"],
idToken: true,
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
image: null,
};
},
options,
};
}
@@ -1,8 +1,9 @@
import { Prisma } from "@prisma/client";
import { ColumnDefinition, type TableNames } from "./tableDefinitions";
import { FilterState } from "./types";
import { filterOperators, timeFilter } from "./interfaces/filters";
import { ColumnDefinition, type TableNames } from "../tableDefinitions";
import { FilterState } from "../types";
import { filterOperators, timeFilter } from "../interfaces/filters";
import { z } from "zod";
import { logger } from "./index";
const operatorReplacements = {
"any of": "IN",
@@ -51,7 +52,7 @@ export function tableColumnsToSqlFilter(
c.name === filter.column || c.id === filter.column
);
if (!col) {
console.error("Invalid filter column", filter.column);
logger.error("Invalid filter column", filter.column);
throw new Error("Invalid filter column: " + filter.column);
}
const colPrisma = Prisma.raw(col.internal);
+6 -1
View File
@@ -3,7 +3,8 @@ export * from "./services/email/organizationInvitation/sendMembershipInvitationE
export * from "./services/email/batchExportSuccess/sendBatchExportSuccessEmail";
export * from "./services/email/passwordReset/sendResetPasswordVerificationRequest";
export * from "./services/PromptService";
export * from "./auth/auth";
export * from "./auth/apiKeys";
export * from "./auth/customSsoProvider";
export * from "./llm/fetchLLMCompletion";
export * from "./llm/types";
export * from "./utils/DatabaseReadStream";
@@ -24,4 +25,8 @@ export * from "./auth/types";
export * from "./ingestion/legacy/index";
export * from "./queues";
export * from "./ingestion/legacy/EventProcessor";
export * from "./orderByToPrisma";
export * from "./filterToPrisma";
export * from "./instrumentation";
export * from "./logger";
export * from "./queries";
@@ -18,6 +18,8 @@ import { mergeJson } from "../../../utils/json";
import { jsonSchema } from "../../../utils/zod";
import { prisma } from "../../../db";
import { LegacyIngestionAccessScope } from ".";
import { logger } from "../../logger";
import { ApiAccessScope } from "../../auth/types";
export interface EventProcessor {
auth(apiScope: LegacyIngestionAccessScope): void;
@@ -47,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;
@@ -119,7 +121,8 @@ export class ObservationProcessor implements EventProcessor {
// Token counts
const [newInputCount, newOutputCount] =
"usage" in this.event.body
? this.calculateTokenCounts(
? await this.calculateTokenCounts(
apiScope.projectId,
this.event.body,
this.calculateTokenDelegate,
internalModel ?? undefined,
@@ -187,8 +190,9 @@ export class ObservationProcessor implements EventProcessor {
: undefined;
// Only null if promptName and promptVersion are set but prompt is not found
if (prompt === null)
console.warn("Prompt not found for observation", this.event.body);
if (prompt === null) {
logger.warn("Prompt not found for observation", this.event.body);
}
const observationId = this.event.body.id ?? v4();
@@ -309,7 +313,8 @@ export class ObservationProcessor implements EventProcessor {
};
}
calculateTokenCounts(
async calculateTokenCounts(
projectId: string,
body:
| z.infer<typeof legacyObservationCreateEvent>["body"]
| z.infer<typeof generationCreateEvent>["body"],
@@ -318,29 +323,59 @@ export class ObservationProcessor implements EventProcessor {
text: unknown;
}) => number | undefined,
model?: Model,
existingObservation?: Observation
existingObservation?: Omit<Observation, "input" | "output">
) {
const newPromptTokens =
body.usage?.input ??
((body.input || existingObservation?.input) && model && model.tokenizerId
? calculateTokenDelegate({
model: model,
text: body.input ?? existingObservation?.input,
})
: undefined);
let newPromptTokens = body.usage?.input;
if (newPromptTokens === undefined && model && model.tokenizerId) {
if (body.input) {
newPromptTokens = calculateTokenDelegate({
model: model,
text: body.input,
});
} else {
logger.info(
`No input provided, trying to calculate for id: ${existingObservation?.id}`
);
const observationInput = await prisma.observation.findFirst({
where: { id: existingObservation?.id, projectId: projectId },
select: {
input: true,
},
});
const newCompletionTokens =
body.usage?.output ??
((body.output || existingObservation?.output) &&
model &&
model.tokenizerId
? calculateTokenDelegate({
model: model,
text: body.output ?? existingObservation?.output,
})
: undefined);
newPromptTokens = calculateTokenDelegate({
model: model,
text: observationInput?.input,
});
}
}
return [newPromptTokens, newCompletionTokens];
let newCompletionTokens = body.usage?.output;
if (newCompletionTokens === undefined && model && model.tokenizerId) {
if (body.output) {
newCompletionTokens = calculateTokenDelegate({
model: model,
text: body.output,
});
} else {
logger.info(
`No output provided, trying to calculate for id: ${existingObservation?.id}`
);
const observationOutput = await prisma.observation.findFirst({
where: { id: existingObservation?.id, projectId: projectId },
select: {
output: true,
},
});
newCompletionTokens = calculateTokenDelegate({
model: model,
text: observationOutput?.output,
});
}
}
return [newPromptTokens ?? undefined, newCompletionTokens ?? undefined];
}
static calculateTokenCosts(
@@ -408,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;
@@ -455,11 +526,8 @@ export class TraceProcessor implements EventProcessor {
const internalId = body.id ?? v4();
console.log(
"Trying to create trace, project ",
apiScope.projectId,
", id:",
internalId
logger.debug(
`Trying to create trace, project ${apiScope.projectId}, id: ${internalId}`
);
const existingTrace = await prisma.trace.findFirst({
@@ -619,7 +687,7 @@ export class SdkLogProcessor implements EventProcessor {
process() {
try {
console.log("SDK Log", this.event);
logger.info("SDK Log", this.event);
return undefined;
} catch (error) {
return undefined;
@@ -8,10 +8,11 @@ import { IngestionUtils } from "../IngestionUtils";
import { IngestionEventType } from "../types";
import { redis } from "../../redis/redis";
import { env } from "../../../env";
import { logger } from "../../logger";
export async function enqueueIngestionEvents(
projectId: string,
events: IngestionEventType[]
events: IngestionEventType[],
) {
const ingestionFlushQueue = getIngestionFlushQueue();
@@ -31,8 +32,8 @@ export async function enqueueIngestionEvents(
event,
redis,
ingestionFlushQueue,
batchTimestamp
)
batchTimestamp,
),
);
}
@@ -44,11 +45,11 @@ async function enqueueSingleIngestionEvent(
event: IngestionEventType,
redis: Redis,
ingestionFlushQueue: IngestionFlushQueue,
batchTimestamp: string
batchTimestamp: string,
): Promise<void> {
if (!("id" in event.body && event.body.id)) {
console.warn(
`Received ingestion event without id: ${JSON.stringify(event)}`
logger.warn(
`Received ingestion event without id: ${JSON.stringify(event)}`,
);
return;
@@ -19,6 +19,7 @@ import { redis } from "../../redis/redis";
import { backOff } from "exponential-backoff";
import { Model } from "../../..";
import { enqueueIngestionEvents } from "./enqueueIngestionEvents";
import { logger } from "../../logger";
export type BatchResult = {
result: unknown;
@@ -49,9 +50,9 @@ type LegacyIngestionAuthHeaderVerificationResult =
export const handleBatch = async (
events: z.infer<typeof ingestionApiSchema>["batch"],
authCheck: LegacyIngestionAuthHeaderVerificationResult,
calculateTokenDelegate: (p: TokenCountInput) => number | undefined
calculateTokenDelegate: (p: TokenCountInput) => number | undefined,
) => {
console.log(`handling ingestion ${events.length} events`);
logger.debug(`handling ingestion ${events.length} events`);
if (!authCheck.validKey) throw new UnauthorizedError(authCheck.error);
@@ -69,7 +70,7 @@ export const handleBatch = async (
return await handleSingleEvent(
singleEvent,
authCheck.scope,
calculateTokenDelegate
calculateTokenDelegate,
);
});
results.push({
@@ -79,7 +80,7 @@ export const handleBatch = async (
}); // Push each result into the array
} catch (error) {
// Handle or log the error if `handleSingleEvent` fails
console.error("Error handling event:", error);
logger.error("Error handling event:", error);
// Decide how to handle the error: rethrow, continue, or push an error object to results
// For example, push an error object:
errors.push({
@@ -93,9 +94,9 @@ export const handleBatch = async (
if (env.CLICKHOUSE_URL) {
try {
await enqueueIngestionEvents(authCheck.scope.projectId, events);
console.log(`Added ${events.length} ingestion events to queue`);
logger.info(`Added ${events.length} ingestion events to queue`);
} catch (err) {
console.error("Error adding ingestion events to queue", err);
logger.error("Error adding ingestion events to queue", err);
}
}
@@ -107,10 +108,10 @@ async function retry<T>(request: () => Promise<T>): Promise<T> {
numOfAttempts: env.LANGFUSE_ASYNC_INGESTION_PROCESSING === "true" ? 5 : 3,
retry: (e: Error, attemptNumber: number) => {
if (e instanceof UnauthorizedError || e instanceof ForbiddenError) {
console.log("not retrying auth error");
logger.info("not retrying auth error");
return false;
}
console.log(`retrying processing events ${attemptNumber}`);
logger.info(`retrying processing events ${attemptNumber}`);
return true;
},
});
@@ -122,7 +123,7 @@ const handleSingleEvent = async (
calculateTokenDelegate: (p: {
model: Model;
text: unknown;
}) => number | undefined
}) => number | undefined,
) => {
const { body } = event;
let restEvent = body;
@@ -137,8 +138,8 @@ const handleSingleEvent = async (
restEvent = rest;
}
console.log(
`handling single event ${event.id} of type ${event.type}: ${JSON.stringify({ body: restEvent })}`
logger.info(
`handling single event ${event.id} of type ${event.type}: ${JSON.stringify({ body: restEvent })}`,
);
const cleanedEvent = ingestionEvent.parse(cleanEvent(event));
@@ -159,7 +160,7 @@ const handleSingleEvent = async (
case eventTypes.GENERATION_UPDATE:
processor = new ObservationProcessor(
cleanedEvent,
calculateTokenDelegate
calculateTokenDelegate,
);
break;
case eventTypes.SCORE_CREATE: {
@@ -201,7 +202,7 @@ export function cleanEvent(obj: unknown): unknown {
}
export const isNotNullOrUndefined = <T>(
val?: T | null
val?: T | null,
): val is Exclude<T, null | undefined> => !isUndefinedOrNull(val);
export const isUndefinedOrNull = <T>(val?: T | null): val is undefined | null =>
@@ -209,7 +210,7 @@ export const isUndefinedOrNull = <T>(val?: T | null): val is undefined | null =>
export const sendToWorkerIfEnvironmentConfigured = async (
batchResults: BatchResult[],
projectId: string
projectId: string,
): Promise<void> => {
const traceEvents: TraceUpsertEventType[] = batchResults
.filter((result) => result.type === eventTypes.TRACE_CREATE) // we only have create, no update.
@@ -219,17 +220,17 @@ export const sendToWorkerIfEnvironmentConfigured = async (
"id" in result.result
? // ingestion API only gets traces for one projectId
{ traceId: result.result.id as string, projectId }
: null
: null,
)
.filter(isNotNullOrUndefined);
try {
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION && redis) {
console.log(`Sending ${traceEvents.length} events to worker via Redis`);
logger.info(`Sending ${traceEvents.length} events to worker via Redis`);
const queue = getTraceUpsertQueue();
if (!queue) {
console.error("TraceUpsertQueue not initialized");
logger.error("TraceUpsertQueue not initialized");
return;
}
@@ -239,7 +240,7 @@ export const sendToWorkerIfEnvironmentConfigured = async (
env.LANGFUSE_WORKER_PASSWORD &&
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
) {
console.log(`Sending ${traceEvents.length} events to worker via HTTP`);
logger.info(`Sending ${traceEvents.length} events to worker via HTTP`);
const body: EventBodyType = {
name: EventName.TraceUpsert,
payload: traceEvents,
@@ -253,7 +254,7 @@ export const sendToWorkerIfEnvironmentConfigured = async (
Authorization:
"Basic " +
Buffer.from(
"admin" + ":" + env.LANGFUSE_WORKER_PASSWORD
"admin" + ":" + env.LANGFUSE_WORKER_PASSWORD,
).toString("base64"),
},
body: JSON.stringify(body),
@@ -262,6 +263,6 @@ export const sendToWorkerIfEnvironmentConfigured = async (
}
}
} catch (error) {
console.error("Error sending events to worker", error);
logger.error("Error sending events to worker", error);
}
};
@@ -8,7 +8,7 @@ export async function findModel(p: {
unit?: string;
startTime?: Date;
};
existingDbObservation?: Observation;
existingDbObservation?: Omit<Observation, "input" | "output">;
}): Promise<Model | null> {
const { event, existingDbObservation } = p;
// either get the model from the existing observation
@@ -15,7 +15,7 @@ type ValidateAndInflateScoreParams = {
};
export async function validateAndInflateScore(
params: ValidateAndInflateScoreParams
params: ValidateAndInflateScoreParams,
): Promise<Score> {
const { body, projectId } = params;
@@ -29,7 +29,7 @@ export async function validateAndInflateScore(
if (!config || !validateDbScoreConfigSafe(config).success)
throw new LangfuseNotFoundError(
"The configId you provided does not match a valid config in this project"
"The configId you provided does not match a valid config in this project",
);
validateConfigAgainstBody(body, config as ValidatedScoreConfig);
@@ -47,7 +47,7 @@ export async function validateAndInflateScore(
if (!validation.success) {
throw new InvalidRequestError(
`Ingested score value type not valid against provided data type. Provide numeric values for numeric and boolean scores, and string values for categorical scores.`
`Ingested score value type not valid against provided data type. Provide numeric values for numeric and boolean scores, and string values for categorical scores.`,
);
}
@@ -62,7 +62,7 @@ function inferDataType(value: string | number): ScoreDataType {
function mapStringValueToNumericValue(
config: ValidatedScoreConfig,
label: string
label: string,
): number | null {
return (
config.categories?.find((category) => category.label === label)?.value ??
@@ -71,7 +71,7 @@ function mapStringValueToNumericValue(
}
function inflateScoreBody(
params: ValidateAndInflateScoreParams & { config?: ValidatedScoreConfig }
params: ValidateAndInflateScoreParams & { config?: ValidatedScoreConfig },
): Score {
const { body, projectId, scoreId, config } = params;
@@ -105,25 +105,25 @@ function inflateScoreBody(
function validateConfigAgainstBody(
body: any,
config: ValidatedScoreConfig
config: ValidatedScoreConfig,
): void {
const { maxValue, minValue, categories, dataType: configDataType } = config;
if (body.dataType && body.dataType !== configDataType) {
throw new InvalidRequestError(
`Data type mismatch based on config: expected ${configDataType}, got ${body.dataType}`
`Data type mismatch based on config: expected ${configDataType}, got ${body.dataType}`,
);
}
if (config.isArchived) {
throw new InvalidRequestError(
"Config is archived and cannot be used to create new scores. Please restore the config first."
"Config is archived and cannot be used to create new scores. Please restore the config first.",
);
}
if (config.name !== body.name) {
throw new InvalidRequestError(
`Name mismatch based on config: expected ${config.name}, got ${body.name}`
`Name mismatch based on config: expected ${config.name}, got ${body.name}`,
);
}
@@ -136,7 +136,7 @@ function validateConfigAgainstBody(
if (!dataTypeValidation.success) {
throw new InvalidRequestError(
`Ingested score body not valid against provided config data type.`
`Ingested score body not valid against provided config data type.`,
);
}
@@ -154,7 +154,7 @@ function validateConfigAgainstBody(
.join(", ");
throw new InvalidRequestError(
`Ingested score body not valid against provided config: ${errorDetails}`
`Ingested score body not valid against provided config: ${errorDetails}`,
);
}
}
@@ -3,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);
};
@@ -135,6 +135,37 @@ export async function fetchLLMCompletion(
return await functionCallingModel.pipe(outputParser).invoke(finalMessages);
}
/*
Workaround OpenAI o1 while in beta:
This is a temporary workaround to avoid sending system messages to OpenAI's O1 models.
O1 models do not support in beta:
- system messages
- top_p
- max_tokens at all, one has to use max_completion_tokens instead
- temperature different than 1
Reference: https://platform.openai.com/docs/guides/reasoning/beta-limitations
*/
if (modelParams.model.startsWith("o1-")) {
return await new ChatOpenAI({
openAIApiKey: apiKey,
modelName: modelParams.model,
temperature: 1,
maxTokens: undefined,
topP: undefined,
callbacks,
maxRetries,
configuration: {
baseURL,
},
})
.pipe(new StringOutputParser())
.invoke(
finalMessages.filter((message) => message._getType() !== "system")
);
}
if (streaming) {
return chatModel.pipe(new BytesOutputParser()).stream(finalMessages);
}
+7 -1
View File
@@ -4,7 +4,7 @@ import z from "zod";
export type PromptVariable = { name: string; value: string; isUsed: boolean };
export type ChatMessage = {
role: ChatMessageRole;
role: ChatMessageRole | string; // Users may ingest any string as role via API/SDK
content: string;
};
@@ -22,6 +22,8 @@ export enum ChatMessageRole {
Assistant = "assistant",
}
export const ChatMessageDefaultRoleSchema = z.nativeEnum(ChatMessageRole);
export type ModelParams = {
provider: string;
adapter: LLMAdapter;
@@ -52,6 +54,10 @@ export const openAIModels = [
"gpt-4o-2024-05-13",
"gpt-4o-mini",
"gpt-4o-mini-2024-07-18",
"o1-preview",
"o1-preview-2024-09-12",
"o1-mini",
"o1-mini-2024-09-12",
"gpt-4-turbo-preview",
"gpt-4-1106-preview",
"gpt-4-0613",
+58
View File
@@ -0,0 +1,58 @@
import { env } from "../env";
import winston from "winston";
import Transport from "winston-transport";
import { getCurrentSpan } from "./instrumentation";
class TracedTransport extends Transport {
constructor(opts: Transport.TransportStreamOptions = {}) {
super(opts);
}
log(info: Record<string, any>, callback: () => void): void {
setImmediate(() => {
this.emit("logged", info);
});
const currentSpan = getCurrentSpan();
info.trace_id = currentSpan?.spanContext().traceId;
info.span_id = currentSpan?.spanContext().spanId;
console.log(JSON.stringify(info));
callback();
}
}
const getWinstonLogger = (
nodeEnv: "development" | "production" | "test",
minLevel = "info",
) => {
const textLoggerFormat = winston.format.combine(
winston.format.errors({ stack: true }),
winston.format.timestamp(),
winston.format.align(),
winston.format.printf((info) => {
const logMessage = `${info.timestamp} ${info.level} ${info.message}`;
return info.stack ? `${logMessage}\n${info.stack}` : logMessage;
}),
);
const jsonLoggerFormat = winston.format.combine(
winston.format.errors({ stack: true }),
winston.format.timestamp(),
winston.format.json(),
);
const format =
env.LANGFUSE_LOG_FORMAT === "text" ? textLoggerFormat : jsonLoggerFormat;
const transport =
env.LANGFUSE_LOG_FORMAT === "text"
? new winston.transports.Console()
: new TracedTransport();
return winston.createLogger({
level: minLevel,
format: format,
transports: [transport],
});
};
export const logger = getWinstonLogger(env.NODE_ENV, env.LANGFUSE_LOG_LEVEL);
@@ -2,8 +2,9 @@ import { z } from "zod";
import { Prisma } from "@prisma/client";
import type { ColumnDefinition } from "./tableDefinitions/types";
import type { OrderByState } from "./interfaces/orderBy";
import type { ColumnDefinition } from "../tableDefinitions/types";
import type { OrderByState } from "../interfaces/orderBy";
import { logger } from "./logger";
/**
* Convert orderBy to SQL ORDER BY clause
@@ -13,7 +14,7 @@ import type { OrderByState } from "./interfaces/orderBy";
*/
export function orderByToPrismaSql(
orderBy: OrderByState,
tableColumns: ColumnDefinition[]
tableColumns: ColumnDefinition[],
): Prisma.Sql {
if (!orderBy) {
return Prisma.sql`ORDER BY t.timestamp DESC`;
@@ -22,11 +23,11 @@ export function orderByToPrismaSql(
const col = tableColumns.find(
// TODO: Only use id instead of name.
// It's less error-prone & decouples data fetching from the human-readable UI labels
(c) => c.name === orderBy.column || c.id === orderBy.column
(c) => c.name === orderBy.column || c.id === orderBy.column,
);
if (!col) {
console.log("Invalid filter column", orderBy.column);
logger.warn("Invalid filter column", orderBy.column);
throw new Error("Invalid filter column: " + orderBy.column);
}
@@ -34,12 +35,12 @@ export function orderByToPrismaSql(
const orderByOrder = z.enum(["ASC", "DESC"]);
const order = orderByOrder.safeParse(orderBy.order);
if (!order.success) {
console.log("Invalid order", orderBy.order);
logger.warn("Invalid order", orderBy.order);
throw new Error("Invalid order: " + orderBy.order);
}
// Both column and order are safe, can use raw SQL
return Prisma.raw(
`ORDER BY ${col.internal} ${order.data} ${col.nullable ? (orderBy.order === "DESC" ? "NULLS LAST" : "NULLS FIRST") : ""}`
`ORDER BY ${col.internal} ${order.data} ${col.nullable ? (orderBy.order === "DESC" ? "NULLS LAST" : "NULLS FIRST") : ""}`,
);
}
@@ -2,10 +2,10 @@ import { z } from "zod";
import { Prisma } from "@prisma/client";
import { tableColumnsToSqlFilterAndPrefix } from "../filterToPrisma";
import { singleFilter } from "../interfaces/filters";
import { orderBy } from "../interfaces/orderBy";
import { singleFilter } from "../../interfaces/filters";
import { orderBy } from "../../interfaces/orderBy";
import { orderByToPrismaSql } from "../orderByToPrisma";
import { sessionsViewCols } from "../tableDefinitions/index";
import { sessionsViewCols } from "../../tableDefinitions";
const GetSessionTableSQLParamsSchema = z.object({
projectId: z.string(),
@@ -22,7 +22,7 @@ export const createSessionsAllQuery = (
options?: {
ignoreOrderBy?: boolean; // used by session.metrics and session.all.totalCount
sessionIdList?: string[]; // used by session.metrics
}
},
): Prisma.Sql => {
const { projectId, filter, orderBy, page, limit } =
GetSessionTableSQLParamsSchema.parse(params);
@@ -30,7 +30,7 @@ export const createSessionsAllQuery = (
const filterCondition = tableColumnsToSqlFilterAndPrefix(
filter ?? [],
sessionsViewCols,
"sessions"
"sessions",
);
const orderByCondition = orderByToPrismaSql(orderBy, sessionsViewCols);
@@ -0,0 +1,89 @@
import { Prisma } from "@prisma/client";
export function createTracesQuery({
select,
projectId,
observationTimeseriesFilter = Prisma.empty,
page,
limit,
searchCondition = Prisma.empty,
filterCondition = Prisma.empty,
orderByCondition = Prisma.empty,
selectScoreValues = false,
}: {
select: Prisma.Sql;
projectId: string;
observationTimeseriesFilter?: Prisma.Sql;
page?: number;
limit?: number;
searchCondition?: Prisma.Sql;
filterCondition?: Prisma.Sql;
orderByCondition?: Prisma.Sql;
selectScoreValues?: boolean;
}) {
return Prisma.sql`
SELECT
${select}
FROM
"traces" AS t
LEFT JOIN LATERAL (
SELECT
SUM(prompt_tokens) AS "promptTokens",
SUM(completion_tokens) AS "completionTokens",
SUM(total_tokens) AS "totalTokens",
SUM(calculated_total_cost) AS "calculatedTotalCost",
SUM(calculated_input_cost) AS "calculatedInputCost",
SUM(calculated_output_cost) AS "calculatedOutputCost",
COALESCE(
MAX(CASE WHEN level = 'ERROR' THEN 'ERROR' END),
MAX(CASE WHEN level = 'WARNING' THEN 'WARNING' END),
MAX(CASE WHEN level = 'DEFAULT' THEN 'DEFAULT' END),
'DEBUG'
) AS "level"
FROM
"observations_view"
WHERE
trace_id = t.id
AND "type" = 'GENERATION'
AND "project_id" = ${projectId}
${observationTimeseriesFilter}
) AS tm ON true
LEFT JOIN LATERAL (
SELECT
COUNT(*) AS "observationCount",
EXTRACT(EPOCH FROM COALESCE(MAX("end_time"), MAX("start_time"))) - EXTRACT(EPOCH FROM MIN("start_time"))::double precision AS "latency"
FROM
"observations"
WHERE
trace_id = t.id
AND "project_id" = ${projectId}
${observationTimeseriesFilter}
) AS tl ON true
LEFT JOIN LATERAL (
SELECT
${selectScoreValues ? Prisma.sql`jsonb_object_agg(name::text, "values") AS "scores_values",` : Prisma.empty}
jsonb_object_agg(name::text, avg_value::double precision) AS "scores_avg"
FROM (
SELECT
name,
${selectScoreValues ? Prisma.sql`array_agg(COALESCE(string_value, value::text)) AS "values",` : Prisma.empty}
AVG(value) avg_value
FROM
scores
WHERE
trace_id = t.id
AND t."project_id" = ${projectId}
${selectScoreValues ? Prisma.empty : Prisma.sql`AND scores."data_type" IN ('NUMERIC', 'BOOLEAN')`}
GROUP BY
name
) tmp
) AS s_avg ON true
WHERE
t."project_id" = ${projectId}
${searchCondition}
${filterCondition}
${orderByCondition}
${limit ? Prisma.sql`LIMIT ${limit}` : Prisma.empty}
${page && limit ? Prisma.sql`OFFSET ${page * limit}` : Prisma.empty}
`;
}
@@ -1 +1,2 @@
export { createSessionsAllQuery } from "./createSessionsAllQuery";
export { createTracesQuery } from "./createTracesQuery";
+5 -1
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { ingestionBatchEvent } from ".";
import { ingestionBatchEvent, TCarrier } from ".";
export enum EventName {
TraceUpsert = "TraceUpsert",
@@ -80,23 +80,27 @@ export type TQueueJobTypes = {
id: string;
payload: TraceUpsertEventType;
name: QueueJobs.TraceUpsert;
_tracecontext?: TCarrier;
};
[QueueName.EvaluationExecution]: {
timestamp: Date;
id: string;
payload: EvalExecutionEventType;
name: QueueJobs.EvaluationExecution;
_tracecontext?: TCarrier;
};
[QueueName.BatchExport]: {
timestamp: Date;
id: string;
payload: BatchExportJobType;
name: QueueJobs.BatchExportJob;
_tracecontext?: TCarrier;
};
[QueueName.LegacyIngestionQueue]: {
timestamp: Date;
id: string;
payload: LegacyIngestionEventType;
name: QueueJobs.LegacyIngestionJob;
_tracecontext?: TCarrier;
};
};
@@ -2,28 +2,32 @@ import { Queue } from "bullmq";
import { QueueName, TQueueJobTypes } from "../queues";
import { createNewRedisInstance } from "./redis";
let legacyIngestionQueue: Queue<
TQueueJobTypes[QueueName.LegacyIngestionQueue]
> | null = null;
export class LegacyIngestionQueue {
private static instance: Queue<
TQueueJobTypes[QueueName.LegacyIngestionQueue]
> | null = null;
export const getLegacyIngestionQueue = () => {
if (legacyIngestionQueue) return legacyIngestionQueue;
public static getInstance(): Queue<
TQueueJobTypes[QueueName.LegacyIngestionQueue]
> | null {
if (LegacyIngestionQueue.instance) return LegacyIngestionQueue.instance;
const newRedis = createNewRedisInstance();
const newRedis = createNewRedisInstance({ enableOfflineQueue: false });
legacyIngestionQueue = newRedis
? new Queue<TQueueJobTypes[QueueName.LegacyIngestionQueue]>(
QueueName.LegacyIngestionQueue,
{
connection: newRedis,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
attempts: 5,
LegacyIngestionQueue.instance = newRedis
? new Queue<TQueueJobTypes[QueueName.LegacyIngestionQueue]>(
QueueName.LegacyIngestionQueue,
{
connection: newRedis,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
attempts: 5,
},
},
}
)
: null;
)
: null;
return legacyIngestionQueue;
};
return LegacyIngestionQueue.instance;
}
}
+8 -3
View File
@@ -1,11 +1,15 @@
import Redis from "ioredis";
import Redis, { RedisOptions } from "ioredis";
import { env } from "../../env";
import { logger } from "../logger";
export const createNewRedisInstance = () => {
export const createNewRedisInstance = (
additionalOptions: Partial<RedisOptions> = {},
) => {
return env.REDIS_CONNECTION_STRING
? new Redis(env.REDIS_CONNECTION_STRING, {
maxRetriesPerRequest: null,
enableAutoPipelining: env.REDIS_ENABLE_AUTO_PIPELINING === "true",
...additionalOptions,
})
: env.REDIS_HOST
? new Redis({
@@ -14,6 +18,7 @@ export const createNewRedisInstance = () => {
password: String(env.REDIS_AUTH),
maxRetriesPerRequest: null, // Set to `null` to disable retrying
enableAutoPipelining: env.REDIS_ENABLE_AUTO_PIPELINING === "true",
...additionalOptions,
})
: null;
};
@@ -22,7 +27,7 @@ const createRedisClient = () => {
try {
return createNewRedisInstance();
} catch (e) {
console.error(e, "Failed to connect to redis");
logger.error("Failed to connect to redis", e);
return null;
}
};
@@ -1,6 +1,7 @@
import { Prompt, PrismaClient } from "@prisma/client";
import { Redis } from "ioredis";
import { env } from "../../env";
import { logger } from "../logger";
export class PromptService {
private cacheEnabled: boolean;
@@ -11,7 +12,7 @@ export class PromptService {
private redis: Redis | null,
private metricIncrementer?: // used for otel metrics
(name: string, value?: number) => void,
cacheEnabled?: boolean // used for testing
cacheEnabled?: boolean, // used for testing
) {
this.cacheEnabled =
Boolean(redis) &&
@@ -25,7 +26,7 @@ export class PromptService {
const cachedPrompt = await this.getCachedPrompt(params);
this.incrementMetric(
cachedPrompt ? Metrics.PromptCacheHit : Metrics.PromptCacheMiss
cachedPrompt ? Metrics.PromptCacheHit : Metrics.PromptCacheMiss,
);
if (cachedPrompt) {
@@ -117,7 +118,7 @@ export class PromptService {
}
public async lockCache(
params: Pick<PromptParams, "projectId" | "promptName">
params: Pick<PromptParams, "projectId" | "promptName">,
): Promise<void> {
if (!this.cacheEnabled) return;
@@ -133,7 +134,7 @@ export class PromptService {
}
public async unlockCache(
params: Pick<PromptParams, "projectId" | "promptName">
params: Pick<PromptParams, "projectId" | "promptName">,
): Promise<void> {
if (!this.cacheEnabled) return;
@@ -149,7 +150,7 @@ export class PromptService {
}
private async isCacheLocked(
params: Pick<PromptParams, "projectId" | "promptName">
params: Pick<PromptParams, "projectId" | "promptName">,
): Promise<boolean> {
const lockKey = this.getLockKey(params);
@@ -163,14 +164,14 @@ export class PromptService {
}
private getLockKey(
params: Pick<PromptParams, "projectId" | "promptName">
params: Pick<PromptParams, "projectId" | "promptName">,
): string {
// Important to *pre*fix LOCK as otherwise it would be deleted by deleteKeysByPrefix
return `LOCK:${this.getCacheKeyPrefix(params)}`;
}
public async invalidateCache(
params: Pick<PromptParams, "projectId" | "promptName">
params: Pick<PromptParams, "projectId" | "promptName">,
): Promise<void> {
if (!this.cacheEnabled) return;
@@ -187,7 +188,7 @@ export class PromptService {
await this.redis?.del([...(keys ?? []), keyIndexKey]);
this.logInfo(
`Cache invalidated for prefix ${cacheKeyPrefix} in ${Date.now() - startTime}ms`
`Cache invalidated for prefix ${cacheKeyPrefix} in ${Date.now() - startTime}ms`,
);
} catch (e) {
this.logError("Error deleting keys for prefix", cacheKeyPrefix, e);
@@ -203,23 +204,23 @@ export class PromptService {
}
private getCacheKeyPrefix(
params: Pick<PromptParams, "projectId" | "promptName">
params: Pick<PromptParams, "projectId" | "promptName">,
): string {
return `prompt:${params.projectId}:${params.promptName}`;
}
private getKeyIndexKey(
params: Pick<PromptParams, "projectId" | "promptName">
params: Pick<PromptParams, "projectId" | "promptName">,
): string {
return `prompt_key_index:${params.projectId}:${params.promptName}`;
}
private logError(message: string, ...args: any[]) {
console.error(`[PromptService] ${message}`, ...args);
logger.error(`[PromptService] ${message}`, ...args);
}
private logInfo(message: string, ...args: any[]) {
console.log(`[PromptService] ${message}`, ...args);
logger.info(`[PromptService] ${message}`, ...args);
}
private incrementMetric(name: Metrics, value: number = 1) {
@@ -2,6 +2,7 @@ import type { Readable } from "stream";
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { logger } from "../logger";
type UploadFile = {
fileName: string;
@@ -56,15 +57,14 @@ export class S3StorageService {
return { signedUrl };
} catch (err) {
console.error(err);
logger.error(err);
throw new Error("Failed to upload to S3 or generate signed URL");
}
}
private async getSignedUrl(
fileName: string,
ttlSeconds: number
ttlSeconds: number,
): Promise<string> {
try {
return await getSignedUrl(
@@ -74,7 +74,7 @@ export class S3StorageService {
Key: fileName,
ResponseContentDisposition: `attachment; filename="${fileName}"`,
}),
{ expiresIn: ttlSeconds }
{ expiresIn: ttlSeconds },
);
} catch (err) {
throw Error("Failed to generate signed URL");
@@ -59,6 +59,10 @@ export const BatchExportSuccessEmailTemplate = ({
download. The download link will expire in{" "}
<strong>{expiresInHours} hours</strong>.
</Text>
<Text className="text-sm leading-6 text-black">
Please note data exports do not reflect custom column ordering or
visibility.
</Text>
<Section className="mb-4 mt-8 text-center">
<Button
className="rounded bg-black px-5 py-3 text-center text-xs font-semibold text-white no-underline"
@@ -3,6 +3,7 @@ import { parseConnectionUrl } from "nodemailer/lib/shared/index.js";
import { render } from "@react-email/render";
import { BatchExportSuccessEmailTemplate } from "./BatchExportSuccessEmailTemplate";
import { logger } from "../../../logger";
type SendBatchExportSuccessParams = {
env: Partial<
@@ -24,8 +25,7 @@ export const sendBatchExportSuccessEmail = async ({
expiresInHours,
}: SendBatchExportSuccessParams) => {
if (!env.EMAIL_FROM_ADDRESS || !env.SMTP_CONNECTION_URL) {
console.error("Missing environment variables for sending email.");
logger.error("Missing environment variables for sending email.");
return;
}
@@ -38,7 +38,7 @@ export const sendBatchExportSuccessEmail = async ({
userName,
batchExportName,
expiresInHours,
})
}),
);
await mailer.sendMail({
@@ -51,6 +51,6 @@ export const sendBatchExportSuccessEmail = async ({
html: htmlTemplate,
});
} catch (error) {
console.error(error);
logger.error(error);
}
};
@@ -3,6 +3,7 @@ import { parseConnectionUrl } from "nodemailer/lib/shared/index.js";
import { render } from "@react-email/render";
import MembershipInvitationTemplate from "./MembershipInvitationEmailTemplate";
import { logger } from "../../../logger";
const langfuseUrls = {
US: "https://us.cloud.langfuse.com",
@@ -34,8 +35,8 @@ export const sendMembershipInvitationEmail = async ({
orgName,
}: SendMembershipInvitationParams) => {
if (!env.EMAIL_FROM_ADDRESS || !env.SMTP_CONNECTION_URL) {
console.error(
"Missing environment variables for sending membership invitation email."
logger.error(
"Missing environment variables for sending membership invitation email.",
);
return;
}
@@ -49,8 +50,8 @@ export const sendMembershipInvitationEmail = async ({
const authUrl = getAuthURL();
if (!authUrl) {
console.error(
"Missing NEXTAUTH_URL or NEXT_PUBLIC_LANGFUSE_CLOUD_REGION environment variable."
logger.error(
"Missing NEXTAUTH_URL or NEXT_PUBLIC_LANGFUSE_CLOUD_REGION environment variable.",
);
return;
}
@@ -67,7 +68,7 @@ export const sendMembershipInvitationEmail = async ({
inviteLink: authUrl,
emailFromAddress: env.EMAIL_FROM_ADDRESS,
langfuseCloudRegion: env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION,
})
}),
);
await mailer.sendMail({
@@ -77,6 +78,6 @@ export const sendMembershipInvitationEmail = async ({
html: htmlTemplate,
});
} catch (error) {
console.error(error);
logger.error(error);
}
};
+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[];
+559 -457
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -41,8 +41,15 @@ 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
ARG NEXT_PUBLIC_SENTRY_ENVIRONMENT
ARG NEXT_PUBLIC_SENTRY_DSN
ARG NEXT_PUBLIC_BASE_PATH
# Accept build id as NEXT_PUBLIC_BUILD_ID or PORTER_NEXT_PUBLIC_BUILD_ID
ARG PORTER_NEXT_PUBLIC_BUILD_ID
ARG NEXT_PUBLIC_BUILD_ID
ENV NEXT_PUBLIC_BUILD_ID=${PORTER_NEXT_PUBLIC_BUILD_ID:-$NEXT_PUBLIC_BUILD_ID}
# Copy source code of isolated subworkspace
COPY --from=pruner /app/out/full/ .
@@ -56,7 +63,7 @@ RUN rm -f ./web/src/middleware.ts
ENV NEXT_TELEMETRY_DISABLED 1
ENV NEXT_MANUAL_SIG_HANDLE true
RUN turbo run build --filter=web
RUN NODE_OPTIONS='--max-old-space-size=4096' turbo run build --filter=web
# Production image, copy all the files and run next
FROM --platform=${TARGETPLATFORM:-linux/amd64} base AS runner
+51 -45
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
{
@@ -149,39 +150,44 @@ const nextConfig = {
},
};
const sentryOptions = {
// Additional config options for the Sentry Webpack plugin. Keep in mind that
// the following options are set automatically, and overriding them is not
// recommended:
// release, url, authToken, configFile, stripPrefix,
// urlPrefix, include, ignore
export default withSentryConfig(nextConfig, {
// For all available options, see:
// https://github.com/getsentry/sentry-webpack-plugin#options
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
silent: true, // Suppresses all logs
authToken: env.SENTRY_AUTH_TOKEN,
// Only print logs for uploading source maps in CI
silent: !process.env.CI,
// For all available options, see:
// https://github.com/getsentry/sentry-webpack-plugin#options.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// See the sections below for information on the following options:
// 'Configure Source Maps':
// - disableServerWebpackPlugin
// - disableClientWebpackPlugin
// - hideSourceMaps
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Automatically annotate React components to show their full name in breadcrumbs and session replay
reactComponentAnnotation: {
enabled: true,
},
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// side errors will fail.
// tunnelRoute: "/api/monitoring-tunnel",
// Hides source maps from generated client bundles
hideSourceMaps: true,
// - widenClientFileUpload
// 'Configure Legacy Browser Support':
// - transpileClientSDK
// 'Configure Serverside Auto-instrumentation':
// - autoInstrumentServerFunctions
// - excludeServerRoutes
// 'Configure Tunneling':
// - tunnelRoute
// An auth token is required for uploading source maps.
authToken: env.SENTRY_AUTH_TOKEN,
tunnelRoute: "/api/monitoring-tunnel",
};
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
export default withSentryConfig(nextConfig, sentryOptions);
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
// See the following for more information:
// https://docs.sentry.io/product/crons/
// https://vercel.com/docs/cron-jobs
automaticVercelMonitors: false,
});
+15 -9
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "2.75.2",
"version": "2.79.0",
"private": true,
"license": "MIT",
"engines": {
@@ -27,6 +27,10 @@
"@baselime/trpc-opentelemetry-middleware": "^0.1.2",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lint": "^6.8.0",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@headlessui/react": "1.7.18",
"@headlessui/tailwindcss": "0.2.1",
"@heroicons/react": "^2.1.3",
@@ -47,12 +51,14 @@
"@opentelemetry/instrumentation-http": "^0.52.1",
"@opentelemetry/instrumentation-ioredis": "^0.42.0",
"@opentelemetry/instrumentation-undici": "^0.4.0",
"@opentelemetry/instrumentation-winston": "^0.36.0",
"@opentelemetry/resources": "^1.25.1",
"@opentelemetry/sdk-metrics": "1.23.0",
"@opentelemetry/sdk-node": "^0.50.0",
"@opentelemetry/sdk-trace-base": "^1.25.1",
"@opentelemetry/sdk-trace-node": "^1.25.1",
"@opentelemetry/semantic-conventions": "^1.25.1",
"@opentelemetry/winston-transport": "^0.2.0",
"@prisma/instrumentation": "^5.13.0",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
@@ -110,12 +116,12 @@
"kysely": "^0.27.4",
"langchain": "^0.2.6",
"lodash": "^4.17.21",
"lucide-react": "^0.429.0",
"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",
@@ -154,10 +160,10 @@
"@types/dompurify": "^3.0.5",
"@types/eslint": "^8.56.7",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.5",
"@types/lodash": "^4.17.7",
"@types/node": "20.10.5",
"@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25",
"@types/react": "~18.2.79",
"@types/react-dom": "~18.2.25",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^6.21.0",
@@ -172,10 +178,10 @@
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.6.6",
"tailwindcss": "^3.4.4",
"tailwindcss": "^3.4.10",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.18.0",
"tsx": "^4.19.0",
"typescript": "^5.4.5",
"wait-for-expect": "^3.0.2"
},
@@ -183,6 +189,6 @@
"initVersion": "7.13.0"
},
"optionalDependencies": {
"crisp-sdk-web": "^1.0.21"
"crisp-sdk-web": "^1.0.25"
}
}
+5 -11
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%
@@ -26,14 +28,6 @@ Sentry.init({
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
+161
View File
@@ -548,6 +548,167 @@ describe("Authenticate API calls", () => {
});
});
describe("invalidates api keys in redis", () => {
const redis = new Redis("redis://:myredissecret@127.0.0.1:6379", {
maxRetriesPerRequest: null,
});
beforeEach(async () => {
// if we do not remove the key, it will remain in the cache and
// calling the test twice will not add the key to the cache
const keys = await redis.keys("api-key*");
console.log("before each deleting keys", keys);
if (keys.length > 0) {
console.log("before each deleting keys. actually deleting", keys);
await redis.del(keys);
}
});
afterEach(async () => {
// if we do not remove the key, it will remain in the cache and
// calling the test twice will not add the key to the cache
const keys = await redis.keys("api-key*");
console.log("after each deleting keys", keys);
if (keys.length > 0) {
await redis.del(keys);
}
});
afterAll(async () => {
redis.disconnect();
});
it("should invalidate organization API keys in redis", async () => {
await createAPIKey();
// put keys into cache
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
});
expect(apiKey).not.toBeNull();
const cachedKey = await redis.get(
`api-key:${apiKey?.fastHashedSecretKey}`,
);
expect(cachedKey).not.toBeNull();
await new ApiAuthService(prisma, redis).invalidateOrgApiKeys(
"seed-org-id",
);
const invalidatedCachedKey = await redis.get(
`api-key:${apiKey?.fastHashedSecretKey}`,
);
expect(invalidatedCachedKey).toBeNull();
});
it("if no keys in redis, invalidating org keys should do nothing", async () => {
await createAPIKey();
await prisma.apiKey.update({
where: { publicKey: "pk-lf-1234567890" },
data: {
fastHashedSecretKey: Math.random().toString(36).substring(2, 15),
},
});
await new ApiAuthService(prisma, redis).invalidateOrgApiKeys(
"seed-org-id",
);
const keys = await redis.keys("api-key*");
expect(keys.length).toBe(0);
});
it("if no keys in redis, invalidating org keys without fast hash should do nothing", async () => {
await createAPIKey();
await new ApiAuthService(prisma, redis).invalidateOrgApiKeys(
"seed-org-id",
);
const keys = await redis.keys("api-key*");
expect(keys.length).toBe(0);
});
it("should invalidate project API keys in redis", async () => {
await createAPIKey();
// put keys into cache
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
});
expect(apiKey).not.toBeNull();
const cachedKey = await redis.get(
`api-key:${apiKey?.fastHashedSecretKey}`,
);
expect(cachedKey).not.toBeNull();
await new ApiAuthService(prisma, redis).invalidateProjectApiKeys(
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
);
const invalidatedCachedKey = await redis.get(
`api-key:${apiKey?.fastHashedSecretKey}`,
);
expect(invalidatedCachedKey).toBeNull();
});
it("if no keys in redis, invalidating project keys should do nothing", async () => {
await createAPIKey();
await prisma.apiKey.update({
where: { publicKey: "pk-lf-1234567890" },
data: {
fastHashedSecretKey: Math.random().toString(36).substring(2, 15),
},
});
await new ApiAuthService(prisma, redis).invalidateProjectApiKeys(
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
);
const keys = await redis.keys("api-key*");
expect(keys.length).toBe(0);
});
it("if no keys in redis, invalidating project keys without fast hash should do nothing", async () => {
await createAPIKey();
await prisma.apiKey.update({
where: { publicKey: "pk-lf-1234567890" },
data: {
fastHashedSecretKey: Math.random().toString(36).substring(2, 15),
},
});
await new ApiAuthService(prisma, redis).invalidateProjectApiKeys(
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
);
const keys = await redis.keys("api-key*");
expect(keys.length).toBe(0);
});
});
const createAPIKey = async () => {
const seedApiKey = {
id: "seed-api-key",
@@ -1,4 +1,4 @@
import { orderByToPrismaSql } from "@langfuse/shared";
import { orderByToPrismaSql } from "@langfuse/shared/src/server";
import { tracesTableCols } from "@langfuse/shared";
// The test for the orderByToPrisma function
+1 -42
View File
@@ -1,7 +1,4 @@
import { hash } from "bcryptjs";
import { env } from "@/src/env.mjs";
import { getDisplaySecretKey, hashSecretKey } from "@langfuse/shared";
import { prisma } from "@langfuse/shared/src/db";
import { type z } from "zod";
@@ -24,10 +21,7 @@ export const pruneDatabase = async () => {
await prisma.llmApiKeys.deleteMany();
};
export function createBasicAuthHeader(
username: string,
password: string,
): string {
function createBasicAuthHeader(username: string, password: string): string {
const base64Credentials = Buffer.from(`${username}:${password}`).toString(
"base64",
);
@@ -97,38 +91,3 @@ export async function makeZodVerifiedAPICall<T extends z.ZodTypeAny>(
}
return { body: resBody, status };
}
export const setupUserAndProject = async () => {
const user = await prisma.user.create({
data: {
id: "user-1",
name: "Demo User",
email: "demo@langfuse.com",
password: await hash("password", 12),
},
});
const project = await prisma.project.create({
data: {
id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
name: "llm-app",
apiKeys: {
create: [
{
note: "seeded key",
hashedSecretKey: await hashSecretKey("sk-lf-1234567890"),
displaySecretKey: getDisplaySecretKey("sk-lf-1234567890"),
publicKey: "pk-lf-1234567890",
},
],
},
projectMembers: {
create: {
role: "OWNER",
userId: user.id,
},
},
},
});
return { user, project };
};
@@ -9,27 +9,40 @@ import type { MessagesContext } from "./types";
type ChatMessageProps = Pick<
MessagesContext,
"deleteMessage" | "updateMessage"
"deleteMessage" | "updateMessage" | "availableRoles"
> & { message: ChatMessageWithId };
export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
message,
updateMessage,
deleteMessage,
availableRoles,
}) => {
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
const [textAreaRows, setTextAreaRows] = useState(1);
const [roleIndex, setRoleIndex] = useState(1);
const toggleRole = () => {
if (message.role === ChatMessageRole.System) return;
updateMessage(
message.id,
"role",
message.role === ChatMessageRole.User
? ChatMessageRole.Assistant
: ChatMessageRole.User,
);
// if user has set custom roles, available roles will be non-empty and we toggle through custom and default roles (assistant, user)
if (!!availableRoles && Boolean(availableRoles.length)) {
let randomRole = availableRoles[roleIndex % availableRoles.length];
if (randomRole === message.role) {
randomRole = availableRoles[(roleIndex + 1) % availableRoles.length];
}
updateMessage(message.id, "role", randomRole);
setRoleIndex(roleIndex + 1);
} else {
// if user has not set custom roles, we toggle through default roles (assistant, user)
updateMessage(
message.id,
"role",
message.role === ChatMessageRole.User
? ChatMessageRole.Assistant
: ChatMessageRole.User,
);
}
};
const handleContentChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
@@ -64,7 +77,7 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
<Textarea
ref={textAreaRef}
id={message.id}
className="height-[auto] min-h-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
};
+2 -1
View File
@@ -2,6 +2,7 @@ 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";
export const LangfuseIcon = ({
size = 32,
@@ -12,7 +13,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"
@@ -75,6 +75,20 @@ export const ModelParameters: React.FC<
options={availableModels}
updateModelParam={updateModelParamValue}
/>
{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"
+16 -1
View File
@@ -2,6 +2,7 @@ import {
BadgeCheck,
Github,
HardDriveDownload,
Info,
Map,
Newspaper,
} from "lucide-react";
@@ -20,6 +21,7 @@ import { api } from "@/src/utils/api";
import { Button } from "@/src/components/ui/button";
import { useIsEeEnabled } from "@/src/ee/utils/useIsEeEnabled";
import { env } from "@/src/env.mjs";
import { cn } from "@/src/utils/tailwind";
export const VersionLabel = ({ className }: { className?: string }) => {
const checkUpdate = api.public.checkUpdate.useQuery(undefined, {
@@ -30,6 +32,7 @@ export const VersionLabel = ({ className }: { className?: string }) => {
onError: (error) => console.error("checkUpdate error", error), // do not render default error message
});
const isEeVersion = useIsEeEnabled();
const isLangfuseCloud = Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
const hasUpdate =
!env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION &&
@@ -46,8 +49,9 @@ export const VersionLabel = ({ className }: { className?: string }) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="xs" className={className}>
<Button variant="ghost" size="xs" className={cn("text-xs",className)}>
{VERSION}
{!isLangfuseCloud && (isEeVersion ? " EE" : " OSS")}
{hasUpdate && <ArrowUp className={`ml-1 h-3 w-3 ${color}`} />}
</Button>
</DropdownMenuTrigger>
@@ -96,6 +100,17 @@ export const VersionLabel = ({ className }: { className?: string }) => {
Roadmap
</Link>
</DropdownMenuItem>
{!isLangfuseCloud && !isEeVersion && (
<DropdownMenuItem asChild>
<Link
href="https://langfuse.com/docs/deployment/feature-overview"
target="_blank"
>
<Info size={16} className="mr-2" />
Compare Versions
</Link>
</DropdownMenuItem>
)}
{hasUpdate && (
<>
<DropdownMenuSeparator />
@@ -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>
+18 -4
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)[] =
@@ -520,7 +534,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" />
+29 -1
View File
@@ -13,18 +13,23 @@ import {
TerminalIcon,
Lightbulb,
Grid2X2,
Sparkle,
} from "lucide-react";
import { LangfuseIcon } from "@/src/components/LangfuseLogo";
import { type ReactNode } from "react";
import { VersionLabel } from "@/src/components/VersionLabel";
import { type Entitlement } from "@/src/features/entitlements/constants/entitlements";
import { type UiCustomizationOption } from "@/src/ee/features/ui-customization/useUiCustomization";
import { type User } from "next-auth";
import { type OrganizationScope } from "@/src/features/rbac/constants/organizationAccessRights";
import { UsageTracker } from "@/src/ee/features/billing/components/UsageTracker";
export type Route = {
name: string;
featureFlag?: Flag;
label?: string | ReactNode;
projectRbacScope?: ProjectScope;
organizationRbacScope?: OrganizationScope;
icon?: LucideIcon | typeof LangfuseIcon; // ignored for nested routes
pathname?: string; // link, ignored if children
children?: Array<Route>; // folder
@@ -32,6 +37,9 @@ export type Route = {
newTab?: boolean; // open in new tab
entitlement?: Entitlement; // entitlement required
customizableHref?: UiCustomizationOption; // key of useUiCustomization object to use to replace the href
show?: (p: {
organization: User["organizations"][number] | undefined;
}) => boolean;
};
export const ROUTES: Route[] = [
@@ -39,7 +47,7 @@ export const ROUTES: Route[] = [
name: "Langfuse",
pathname: "/",
icon: LangfuseIcon,
label: <VersionLabel />,
label: <VersionLabel className="-ml-3" />,
},
{
name: "Projects",
@@ -125,6 +133,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"
@@ -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
@@ -50,6 +50,7 @@ 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";
export type GenerationsTableRow = {
id: string;
@@ -271,6 +272,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 +677,6 @@ export default function GenerationsTable({
defaultHidden: true,
cell: ({ row }) => {
const traceTags: string[] | undefined = row.getValue("traceTags");
console.log(traceTags);
return (
traceTags && (
<div
@@ -698,6 +699,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,6 +756,8 @@ export default function GenerationsTable({
columnsWithCustomSelect={["model", "name", "traceName", "promptName"]}
columnVisibility={columnVisibility}
setColumnVisibility={setColumnVisibilityState}
columnOrder={columnOrder}
setColumnOrder={setColumnOrder}
rowHeight={rowHeight}
setRowHeight={setRowHeight}
selectedOption={selectedOption}
@@ -811,6 +819,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}
/>
</>
@@ -186,7 +186,6 @@ export const ObservationPreview = (props: {
source="generation"
generation={observationWithInputAndOutput.data}
analyticsEventName="trace_detail:test_in_playground_button_click"
fullWidth
/>
)}
{observationWithInputAndOutput.data ? (
@@ -240,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>
@@ -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";
@@ -81,7 +81,7 @@ function TreeItemInner({
level = 0,
cardWidth,
}: {
latency: number;
latency?: number;
totalScaleSpan: number;
type: TreeItemType;
startOffset?: number;
@@ -91,7 +91,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 +163,12 @@ function TreeItemInner({
"hidden justify-end text-xs text-muted-foreground group-hover:block",
itemOffsetLabelWidth > SCALE_WIDTH
? "mr-1"
: !!latency
: 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 +203,7 @@ function TraceTreeItem({
const latency = endTime
? (endTime.getTime() - startTime.getTime()) / 1000
: 0;
: undefined;
const startOffset =
((startTime.getTime() - traceStartTime.getTime()) / totalScaleSpan / 1000) *
SCALE_WIDTH;
+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 =
+1 -1
View File
@@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
"flex h-8 w-full rounded-md border border-input bg-background px-2 py-1 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50",
"flex h-8 w-full min-w-14 rounded-md border border-input bg-background px-2 py-1 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v2.75.2";
export const VERSION = "v2.79.0";
+4 -1
View File
@@ -8,9 +8,11 @@ import dd from "dd-trace";
import opentelemetry from "@opentelemetry/api";
// import { BullMQInstrumentation } from "@appsignal/opentelemetry-instrumentation-bullmq";
import { UndiciInstrumentation } from "@opentelemetry/instrumentation-undici";
import { logger } from "@langfuse/shared/src/server";
import { WinstonInstrumentation } from "@opentelemetry/instrumentation-winston";
if (!process.env.VERCEL && process.env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) {
console.log("Initializing otel tracing");
logger.info("Initializing otel tracing");
const contextManager = new AsyncHooksContextManager().enable();
opentelemetry.context.setGlobalContextManager(contextManager);
@@ -51,6 +53,7 @@ if (!process.env.VERCEL && process.env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) {
new IORedisInstrumentation(),
new HttpInstrumentation(),
new PrismaInstrumentation(),
new WinstonInstrumentation({ disableLogSending: true }),
getNodeAutoInstrumentations(),
new UndiciInstrumentation(),
// new BullMQInstrumentation(),
@@ -26,6 +26,7 @@ import {
import { env } from "@/src/env.mjs";
import { useHasOrganizationAccess } from "@/src/features/rbac/utils/checkOrganizationAccess";
import { Alert, AlertDescription, AlertTitle } from "@/src/components/ui/alert";
import { MAX_OBSERVATIONS_FREE_PLAN } from "@/src/ee/features/billing/constants";
export const BillingSettings = () => {
const router = useRouter();
@@ -72,7 +73,8 @@ const OrganizationUsageChart = () => {
},
);
const planLimit =
organization?.cloudConfig?.monthlyObservationLimit ?? 50_000;
organization?.cloudConfig?.monthlyObservationLimit ??
MAX_OBSERVATIONS_FREE_PLAN;
const plan: Plan = organization?.plan ?? "cloud:hobby";
const planLabel = planLabels[plan];
@@ -83,7 +85,7 @@ const OrganizationUsageChart = () => {
<>
<Text>
{usage.data.billingPeriod
? `Observations in billing period`
? `Observations in current billing period`
: "Observations / last 30d"}
</Text>
<Metric>{numberFormatter(usage.data.countObservations, 0)}</Metric>
@@ -0,0 +1,72 @@
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/src/components/ui/hover-card";
import { api } from "@/src/utils/api";
import { useQueryProjectOrOrganization } from "@/src/features/projects/hooks";
import { AlertTriangle } from "lucide-react";
import { cn } from "@/src/utils/tailwind";
import { useHasOrganizationAccess } from "@/src/features/rbac/utils/checkOrganizationAccess";
import { MAX_OBSERVATIONS_FREE_PLAN } from "@/src/ee/features/billing/constants";
import { useHasOrgEntitlement } from "@/src/features/entitlements/hooks";
export const UsageTracker = () => {
const { organization } = useQueryProjectOrOrganization();
const hasEntitlement = useHasOrgEntitlement("cloud-billing");
const hasAccess = useHasOrganizationAccess({
organizationId: organization?.id,
scope: "langfuseCloudBilling:CRUD",
});
const usageQuery = api.cloudBilling.getUsage.useQuery(
{
orgId: organization?.id!,
},
{
enabled: !!organization && hasAccess && hasEntitlement,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
},
);
if (
usageQuery.isLoading ||
!usageQuery.data ||
!hasAccess ||
!hasEntitlement
) {
return null;
}
const usage = usageQuery.data.countObservations || 0;
const percentage = (usage / MAX_OBSERVATIONS_FREE_PLAN) * 100;
if (percentage < 80) {
return null;
}
return (
<HoverCard>
<HoverCardTrigger>
<AlertTriangle
className={cn(
"h-4 w-4",
percentage >= 100 ? "text-destructive" : "text-dark-yellow",
)}
/>
</HoverCardTrigger>
<HoverCardContent className="w-80">
<div className="flex justify-between space-x-4">
<div className="space-y-1">
<h4 className="text-sm font-semibold">Free Plan Usage Limit</h4>
<p className="text-sm font-normal">
{`You've used ${usage.toLocaleString()} out of ${MAX_OBSERVATIONS_FREE_PLAN.toLocaleString()} included observations (${percentage.toFixed(2)}%) over the last 30 days. Please upgrade your plan to avoid interruptions.`}
</p>
</div>
</div>
</HoverCardContent>
</HoverCard>
);
};
+1
View File
@@ -0,0 +1 @@
export const MAX_OBSERVATIONS_FREE_PLAN = 50_000;
@@ -214,18 +214,6 @@ export const cloudBillingRouter = createTRPCRouter({
session: ctx.session,
});
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
thirtyDaysAgo.setHours(0, 0, 0, 0);
let billingPeriod: {
start: Date;
end: Date;
} | null = null;
let upcomingInvoice: {
usdAmount: number;
date: Date;
} | null = null;
const organization = await ctx.prisma.organization.findUnique({
where: {
id: input.orgId,
@@ -249,35 +237,49 @@ export const cloudBillingRouter = createTRPCRouter({
parsedOrg.cloudConfig.stripe.activeSubscriptionId,
);
if (subscription) {
billingPeriod = {
const billingPeriod = {
start: new Date(subscription.current_period_start * 1000),
end: new Date(subscription.current_period_end * 1000),
};
const stripeInvoice = await stripeClient.invoices.retrieveUpcoming({
subscription: parsedOrg.cloudConfig.stripe.activeSubscriptionId,
});
upcomingInvoice = {
const upcomingInvoice = {
usdAmount: stripeInvoice.amount_due / 100,
date: new Date(stripeInvoice.period_end * 1000),
};
const usage = stripeInvoice.lines.data.reduce((acc, line) => {
if (line.quantity) {
return acc + line.quantity;
}
return acc;
}, 0);
return {
countObservations: usage,
billingPeriod,
upcomingInvoice,
};
}
}
// For non-Stripe subscriptions, we can only get usage from the LangFuse API
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
thirtyDaysAgo.setHours(0, 0, 0, 0);
const usage = await ctx.prisma.observation.count({
where: {
project: {
orgId: input.orgId,
},
startTime: {
gte: billingPeriod?.start ?? thirtyDaysAgo,
gte: thirtyDaysAgo,
},
},
});
return {
countObservations: usage,
billingPeriod,
upcomingInvoice,
};
}),
});
@@ -22,6 +22,7 @@ import { Label } from "@/src/components/ui/label";
import TableLink from "@/src/components/table/table-link";
import EvalLogTable from "@/src/ee/features/evals/components/eval-log";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { FullScreenPage } from "@/src/components/layouts/full-screen-page";
export const EvalConfigDetail = () => {
const router = useRouter();
@@ -85,7 +86,7 @@ export const EvalConfigDetail = () => {
/>
{existingEvalConfig && (
<>
<div className="my-5 flex items-center gap-4 rounded-md border p-2 ">
<div className="my-5 flex items-center gap-4 rounded-md border p-2">
<Label>Eval Template</Label>
<TableLink
path={`/project/${projectId}/evals/templates/${existingEvalConfig.evalTemplateId}`}
@@ -110,10 +111,15 @@ export const EvalConfigDetail = () => {
/>
</TabsContent>
<TabsContent value="logs">
<EvalLogTable
projectId={projectId}
jobConfigurationId={existingEvalConfig.id}
/>
<FullScreenPage
lgHeight="lg:h-[calc(100dvh-13rem)]"
mobileHeight="h-[calc(100dvh-17rem)]"
>
<EvalLogTable
projectId={projectId}
jobConfigurationId={existingEvalConfig.id}
/>
</FullScreenPage>
</TabsContent>
</Tabs>
</>
@@ -5,6 +5,7 @@ import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
import TableLink from "@/src/components/table/table-link";
import { type LangfuseColumnDef } from "@/src/components/table/types";
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
import useColumnOrder from "@/src/features/column-visibility/hooks/useColumnOrder";
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
import { type RouterOutputs, api } from "@/src/utils/api";
import { createColumnHelper } from "@tanstack/react-table";
@@ -155,6 +156,11 @@ export default function EvalLogTable({
const [columnVisibility, setColumnVisibility] =
useColumnVisibility<JobExecutionRow>("evalLogColumnVisibility", columns);
const [columnOrder, setColumnOrder] = useColumnOrder<JobExecutionRow>(
"evalLogColumnOrder",
columns,
);
const convertToTableRow = (
jobConfig: RouterOutputs["evals"]["getLogs"]["data"][number],
): JobExecutionRow => {
@@ -173,11 +179,13 @@ export default function EvalLogTable({
};
return (
<div>
<>
<DataTableToolbar
columns={columns}
columnVisibility={columnVisibility}
setColumnVisibility={setColumnVisibility}
columnOrder={columnOrder}
setColumnOrder={setColumnOrder}
rowHeight={rowHeight}
setRowHeight={setRowHeight}
/>
@@ -205,7 +213,9 @@ export default function EvalLogTable({
}}
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
/>
</div>
</>
);
}
@@ -32,7 +32,7 @@ type JumpToPlaygroundButtonProps = (
analyticsEventName: "trace_detail:test_in_playground_button_click";
}
) & {
fullWidth?: boolean;
variant?: "outline" | "secondary";
};
export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
@@ -61,17 +61,14 @@ export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
return (
<Button
variant={props.fullWidth ? "secondary" : "outline"}
variant={props.variant ?? "secondary"}
title="Test in LLM playground"
size={!props.fullWidth ? "icon" : undefined}
onClick={handleClick}
asChild
>
<Link href={`/project/${projectId}/playground`}>
<Terminal className="h-4 w-4" />
{props.fullWidth ? (
<span className="ml-2">Test in playground</span>
) : null}
<span className="ml-2">Test in playground</span>
</Link>
</Button>
);
@@ -26,6 +26,7 @@ import {
import type { MessagesContext } from "@/src/components/ChatMessages/types";
import type { ModelParamsContext } from "@/src/components/ModelParameters";
import { env } from "@/src/env.mjs";
type PlaygroundContextType = {
promptVariables: PromptVariable[];
@@ -270,11 +271,14 @@ async function* getChatCompletionStream(
messages,
modelParams: getFinalModelParams(modelParams),
});
const result = await fetch("/api/chatCompletion", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
const result = await fetch(
`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/chatCompletion`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body,
},
);
if (!result.ok) {
const errorData = await result.json();
+23
View File
@@ -171,6 +171,15 @@ export const env = createEnv({
SENTRY_AUTH_TOKEN: z.string().optional(),
SENTRY_CSP_REPORT_URI: z.string().optional(),
LANGFUSE_RATE_LIMITS_ENABLED: z.enum(["true", "false"]).default("true"),
LANGFUSE_INIT_ORG_ID: z.string().optional(),
LANGFUSE_INIT_ORG_NAME: z.string().optional(),
LANGFUSE_INIT_PROJECT_ID: z.string().optional(),
LANGFUSE_INIT_PROJECT_NAME: z.string().optional(),
LANGFUSE_INIT_PROJECT_PUBLIC_KEY: z.string().optional(),
LANGFUSE_INIT_PROJECT_SECRET_KEY: z.string().optional(),
LANGFUSE_INIT_USER_EMAIL: z.string().email().optional(),
LANGFUSE_INIT_USER_NAME: z.string().optional(),
LANGFUSE_INIT_USER_PASSWORD: z.string().optional(),
},
/**
@@ -194,6 +203,8 @@ export const env = createEnv({
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(),
NEXT_PUBLIC_CRISP_WEBSITE_ID: z.string().optional(),
NEXT_PUBLIC_BUILD_ID: z.string().optional(),
NEXT_PUBLIC_BASE_PATH: z.string().optional(),
},
/**
@@ -206,6 +217,7 @@ export const env = createEnv({
NEXT_PUBLIC_DEMO_ORG_ID: process.env.NEXT_PUBLIC_DEMO_ORG_ID,
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_BUILD_ID: process.env.NEXT_PUBLIC_BUILD_ID,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
NEXTAUTH_COOKIE_DOMAIN: process.env.NEXTAUTH_COOKIE_DOMAIN,
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
@@ -321,6 +333,17 @@ export const env = createEnv({
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
SENTRY_CSP_REPORT_URI: process.env.SENTRY_CSP_REPORT_URI,
LANGFUSE_RATE_LIMITS_ENABLED: process.env.LANGFUSE_RATE_LIMITS_ENABLED,
// provisioning
LANGFUSE_INIT_ORG_ID: process.env.LANGFUSE_INIT_ORG_ID,
LANGFUSE_INIT_ORG_NAME: process.env.LANGFUSE_INIT_ORG_NAME,
LANGFUSE_INIT_PROJECT_ID: process.env.LANGFUSE_INIT_PROJECT_ID,
LANGFUSE_INIT_PROJECT_NAME: process.env.LANGFUSE_INIT_PROJECT_NAME,
LANGFUSE_INIT_PROJECT_PUBLIC_KEY: process.env.LANGFUSE_INIT_PROJECT_PUBLIC_KEY,
LANGFUSE_INIT_PROJECT_SECRET_KEY: process.env.LANGFUSE_INIT_PROJECT_SECRET_KEY,
LANGFUSE_INIT_USER_EMAIL: process.env.LANGFUSE_INIT_USER_EMAIL,
LANGFUSE_INIT_USER_NAME: process.env.LANGFUSE_INIT_USER_NAME,
LANGFUSE_INIT_USER_PASSWORD: process.env.LANGFUSE_INIT_USER_PASSWORD,
NEXT_PUBLIC_BASE_PATH: process.env.NEXT_PUBLIC_BASE_PATH,
},
// Skip validation in Docker builds
// DOCKER_BUILD is set in Dockerfile
+1
View File
@@ -8,6 +8,7 @@ export type AuditableResource =
| "comment"
| "datasetItem"
| "dataset"
| "datasetRun"
| "trace"
| "project"
| "observation"
@@ -3,6 +3,7 @@ import { Button } from "@/src/components/ui/button";
import { useEffect, useState } from "react";
import { z } from "zod";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { env } from "@/src/env.mjs";
export function RequestResetPasswordEmailButton({
email,
@@ -33,7 +34,7 @@ export function RequestResetPasswordEmailButton({
try {
const res = await signIn("email", {
email: email,
callbackUrl: "/auth/reset-password",
callbackUrl: `${env.NEXT_PUBLIC_BASE_PATH ?? ""}/auth/reset-password`,
redirect: false,
});
if (res?.error) {
@@ -3,6 +3,7 @@ import { createUserEmailPassword } from "@/src/features/auth-credentials/lib/cre
import { signupSchema } from "@/src/features/auth/lib/signupSchema";
import { getSsoAuthProviderIdForDomain } from "@/src/ee/features/multi-tenant-sso/utils";
import type { NextApiRequest, NextApiResponse } from "next";
import { logger } from "@langfuse/shared/src/server";
/*
* Sign-up endpoint (email/password users), creates user in database.
@@ -32,7 +33,7 @@ export async function signupApiHandler(
// parse and type check the request body with zod
const validBody = signupSchema.safeParse(req.body);
if (!validBody.success) {
console.log("Signup: Invalid body", validBody.error);
logger.warn("Signup: Invalid body", validBody.error);
res.status(422).json({ message: validBody.error });
return;
}
@@ -52,8 +53,8 @@ export async function signupApiHandler(
}
// EE: check if custom SSO configuration is enabled for this domain
const customSsoProvider = await getSsoAuthProviderIdForDomain(domain);
if (customSsoProvider) {
const multiTenantSsoProvider = await getSsoAuthProviderIdForDomain(domain);
if (multiTenantSsoProvider) {
res.status(422).json({
message: "You must sign in via SSO for this domain.",
});
@@ -69,11 +70,12 @@ export async function signupApiHandler(
);
} catch (error) {
if (error instanceof Error) {
console.log(
logger.warn(
"Signup: Error creating user",
error.message,
body.email.toLowerCase(),
body.name,
error,
);
res.status(422).json({ message: error.message });
}
@@ -1,5 +1,6 @@
import { env } from "@/src/env.mjs";
import { prisma, Role } from "@langfuse/shared/src/db";
import { logger } from "@langfuse/shared/src/server";
export async function createProjectMembershipsOnSignup(user: {
id: string;
@@ -81,7 +82,7 @@ export async function createProjectMembershipsOnSignup(user: {
// Invites do not work for users without emails (some future SSO users)
if (user.email) await processMembershipInvitations(user.email, user.id);
} catch (e) {
console.error("Error assigning project access to new user", e);
logger.error("Error assigning project access to new user", e);
}
}
@@ -0,0 +1,48 @@
import { type LangfuseColumnDef } from "@/src/components/table/types";
import useLocalStorage from "@/src/components/useLocalStorage";
// returns deep copy of local storage object
const readStoredColumnOrder = (localStorageKey: string): string[] => {
if (typeof window === "undefined") {
return [];
}
try {
const storedValue = localStorage.getItem(localStorageKey);
return storedValue ? JSON.parse(storedValue) : [];
} catch (error) {
console.error("Error reading from local storage", error);
return [];
}
};
function useColumnOrder<TData>(
localStorageKey: string,
columns: LangfuseColumnDef<TData>[],
) {
const initialColumnOrder = () => {
const storedColumnOrder = readStoredColumnOrder(localStorageKey);
const columnIds = columns.map((c) => c.accessorKey);
// if new column has been added to table, insert it at it's default position
if (columnIds.length > storedColumnOrder.length) {
const newColumnOrder = [...storedColumnOrder];
columnIds.forEach((id) => {
if (!newColumnOrder.includes(id)) {
const index = columnIds.indexOf(id);
newColumnOrder.splice(index, 0, id);
}
});
return newColumnOrder;
}
return storedColumnOrder;
};
const [columnOrder, setColumnOrder] = useLocalStorage<string[]>(
localStorageKey,
initialColumnOrder(),
);
return [columnOrder, setColumnOrder] as const;
}
export default useColumnOrder;
@@ -3,7 +3,10 @@ import { compactNumberFormatter } from "@/src/utils/numbers";
import { cn } from "@/src/utils/tailwind";
import { AreaChart, type CustomTooltipProps, LineChart } from "@tremor/react";
import { Tooltip } from "@/src/features/dashboard/components/Tooltip";
import { type DashboardDateRangeAggregationOption } from "@/src/utils/date-range-utils";
import {
dashboardDateRangeAggregationSettings,
type DashboardDateRangeAggregationOption,
} from "@/src/utils/date-range-utils";
export type TimeSeriesChartDataPoint = {
ts: number;
@@ -45,13 +48,11 @@ export function BaseTimeSeriesChart(props: {
date: number,
agg: DashboardDateRangeAggregationOption,
) => {
const showMinutes: DashboardDateRangeAggregationOption[] = [
"5 min",
"30 min",
"1 hour",
"3 hours",
];
if (showMinutes.includes(agg)) {
const showMinutes = ["minute", "hour"].includes(
dashboardDateRangeAggregationSettings[agg].date_trunc,
);
if (showMinutes) {
return new Date(date).toLocaleTimeString("en-US", {
year: "2-digit",
month: "numeric",
@@ -23,6 +23,7 @@ import useColumnVisibility from "@/src/features/column-visibility/hooks/useColum
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import useColumnOrder from "@/src/features/column-visibility/hooks/useColumnOrder";
type RowData = {
id: string;
@@ -86,6 +87,7 @@ export function DatasetItemsTable({
header: "Item id",
id: "id",
size: 90,
isPinned: true,
cell: ({ row }) => {
const id: string = row.getValue("id");
return (
@@ -268,12 +270,19 @@ export function DatasetItemsTable({
columns,
);
const [columnOrder, setColumnOrder] = useColumnOrder<RowData>(
"datasetItemsColumnOrder",
columns,
);
return (
<>
<DataTableToolbar
columns={columns}
columnVisibility={columnVisibility}
setColumnVisibility={setColumnVisibility}
columnOrder={columnOrder}
setColumnOrder={setColumnOrder}
rowHeight={rowHeight}
setRowHeight={setRowHeight}
actionButtons={menuItems}
@@ -304,6 +313,8 @@ export function DatasetItemsTable({
}}
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
rowHeight={rowHeight}
/>
</>
@@ -20,6 +20,7 @@ import {
} from "@/src/features/scores/components/ScoreDetailColumnHelpers";
import { type ScoreAggregate } from "@/src/features/scores/lib/types";
import { useIndividualScoreColumns } from "@/src/features/scores/hooks/useIndividualScoreColumns";
import useColumnOrder from "@/src/features/column-visibility/hooks/useColumnOrder";
export type DatasetRunItemRowData = {
id: string;
@@ -222,6 +223,11 @@ export function DatasetRunItemsTable(
columns,
);
const [columnOrder, setColumnOrder] = useColumnOrder<DatasetRunItemRowData>(
"datasetRunsItemsColumnOrder",
columns,
);
const rows = useMemo(() => {
return runItems.isSuccess
? runItems.data.runItems.map((item) => {
@@ -255,6 +261,8 @@ export function DatasetRunItemsTable(
columns={columns}
columnVisibility={columnVisibility}
setColumnVisibility={setColumnVisibility}
columnOrder={columnOrder}
setColumnOrder={setColumnOrder}
rowHeight={rowHeight}
setRowHeight={setRowHeight}
/>
@@ -282,6 +290,8 @@ export function DatasetRunItemsTable(
}}
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
rowHeight={rowHeight}
/>
</>

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