Compare commits

...
76 Commits
Author SHA1 Message Date
Max Deichmann 22a391e315 chore: release v2.54.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20, 12) (push) Waiting to run
CI/CD / tests-web (20, 15) (push) Waiting to run
CI/CD / tests-worker (20, 12) (push) Waiting to run
CI/CD / tests-worker (20, 15) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
Snyk Container / snyk (push) Waiting to run
2024-06-12 19:11:33 +02:00
Max DeichmannandGitHub 46b19279c4 fix: clean up broken migration history (#2329) 2024-06-12 19:03:45 +02:00
Hassieb PakzadandGitHub 716ed04e60 perf: use composite index for prompt.metrics (#2328) 2024-06-12 17:13:28 +02:00
marliessophieandGitHub 87798edd95 feat(analytics): add posthoc event for trace timeline (#2327)
* feat(analytics): add posthoc event for trace timeline

* style: don't round corners in trace timeline

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

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

Co-authored-by: aablsk <alex.bielski@chaptr.xyz>
Co-authored-by: aablsk <70068680+aablsk@users.noreply.github.com>
2024-06-12 02:43:13 +02:00
Marc Klingen ed087ae60b feat(ui): add link to roadmap and icon to EE badge 2024-06-12 02:08:10 +02:00
Marc Klingen 229320a3db nit: copy 2024-06-12 01:37:19 +02:00
Marc Klingen 38b3e8aaa4 chore(auth): add console.warn for sign out when missing db user 2024-06-12 01:06:01 +02:00
Marc Klingen 2785aa6a32 chore: release v2.53.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20) (push) Waiting to run
CI/CD / tests-worker (20) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
Snyk Container / snyk (push) Waiting to run
2024-06-12 00:11:19 +02:00
Marc Klingen 06c0392e79 chore: remove unused imports 2024-06-12 00:00:34 +02:00
Marc KlingenandGitHub f9a3107149 chore(ee): remove unnecessry condition from ee check (#2317) 2024-06-11 23:53:25 +02:00
Marc KlingenandGitHub d987c46fed perf(ui): split loading of metrics on prompts table into separate trpc route (#2316) 2024-06-11 23:52:40 +02:00
Marc KlingenandGitHub 8fdd4a7ca7 refactor: extract joinTableCoreAndMetrics to own file (#2315) 2024-06-11 21:44:46 +00:00
marliessophieandGitHub 535e6ecb66 feat(trace timeline view): add timeline view for traces and observations (#2283)
* add: MUI X tree dependency

* feat: initial timeline view

* add: alert and beta badge

* add: icons

* refactor: extract `TreeItemInner`

* feat: add drawer with detail content

* push

* improve styling

* style overflow

* use localStorage to perserve view

* sort nested observations

* rename to `TraceTimelineView`

* refactor: extract common functionality

* push

* fix: label width

* push

* push

* push

* push

* fix: hover for dark mode

* constant for step size

* fix: scroll behavior mobile

* Revert "add: MUI X tree dependency"

This reverts commit d4179dd7c1a99458b8f511834ec730121688e9ee.

* Revert "Revert "add: MUI X tree dependency""

This reverts commit 8dfe26ebc14ab2afabe9d5ac0a35d156bc10437c.

* push master pnpm equivalent

* reinstall: MUI X tree view
2024-06-11 22:03:40 +02:00
Marc Klingen 204691900d chore: release v2.52.1
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20) (push) Waiting to run
CI/CD / tests-worker (20) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
Snyk Container / snyk (push) Waiting to run
2024-06-11 20:30:53 +02:00
Marc KlingenandGitHub a1db67580d feat(ee): license key verification (#2313) 2024-06-11 20:30:21 +02:00
Hassieb PakzadandGitHub 03094953b8 fix: add anchor tags to settings page (#2312) 2024-06-11 19:20:00 +02:00
Marc KlingenandGitHub ba3e6eec47 docs(api): add docs to api reference for /ingestion route (#2311) 2024-06-11 17:49:14 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Max Deichmann
93de36d25f chore(deps): bump the sentry group across 1 directory with 4 updates (#2274)
* chore(deps): bump the sentry group across 1 directory with 4 updates

Bumps the sentry group with 4 updates in the / directory: [@sentry/nextjs](https://github.com/getsentry/sentry-javascript), [@sentry/node](https://github.com/getsentry/sentry-javascript), [@sentry/profiling-node](https://github.com/getsentry/sentry-javascript) and [@sentry/types](https://github.com/getsentry/sentry-javascript).


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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>

* configure

* push

* push

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Max Deichmann <m.deichmann@tum.de>
2024-06-11 14:47:45 +00:00
Marc KlingenandGitHub 0f66f37b32 chore: use docker hub image and pin version 2 in docker compose (#2310) 2024-06-11 16:16:04 +02:00
Hassieb Pakzad b4e3634a14 chore: release v2.52.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20) (push) Waiting to run
CI/CD / tests-worker (20) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
Snyk Container / snyk (push) Waiting to run
2024-06-11 16:06:56 +02:00
Hassieb PakzadandGitHub 29f1072da4 feat: adds custom LLM endpoints (#2190) 2024-06-11 16:04:28 +02:00
Marc KlingenandGitHub 5673849f7b fix(ui): render 429 errors by proxy in toast correctly (#2308) 2024-06-11 13:46:13 +00:00
Hassieb PakzadandGitHub 9cd7e68efc fix: fern api types for public scores endpoint (#2307) 2024-06-11 15:01:15 +02:00
Max DeichmannandGitHub eb84df07bf chore: upgrade busybox and ssl_client (#2306)
upgrade
2024-06-11 14:02:38 +02:00
dependabot[bot]andGitHub b4ef5887de chore(deps): bump the patches group across 1 directory with 12 updates (#2302) 2024-06-11 13:44:17 +02:00
Marc KlingenandGitHub aff5e16351 fix(api): get scores, filter timestamp of scores instead of related trace (#2305) 2024-06-11 11:09:20 +00:00
Max DeichmannandGitHub 00bb0ce5f8 chore: braces version 3.0.2 (#2301)
chore: braces 3.0.2
2024-06-11 10:43:27 +00:00
Max DeichmannandGitHub eaf87083c6 chore: explicitly set the node base image version (#2300)
push
2024-06-11 10:18:48 +00:00
Richard KrümmelandGitHub bfe9269a95 fix: update number formatting to "en-US" locale (#2297) 2024-06-11 12:05:55 +02:00
Max DeichmannandGitHub 54e7bc2298 chore: upgrade release-it (#2299) 2024-06-11 12:00:39 +02:00
Max DeichmannandGitHub a7720b0e8f chore: add snyk image scan (#2296) 2024-06-11 11:49:36 +02:00
Hassieb Pakzad e1ca51ec33 chore: release v2.51.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20) (push) Waiting to run
CI/CD / tests-worker (20) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-06-11 10:27:18 +02:00
eb26a9861d fix(dataset-router): handle empty run items array (#2293)
* fix(dataset-router): handle empty run items array

* add note

---------

Co-authored-by: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com>
2024-06-11 09:42:40 +02:00
b1692e1786 chore: add devcontainer config to simplify contributing to Langfuse (#2267)
---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-06-10 23:42:44 +02:00
6b7709f8e7 fix(ui): order tooltip items on dashboard by value desc (#2287)
---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-06-10 23:39:23 +02:00
6a5a233749 feat(auth): add custom OIDC IDP provider (#2285)
---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-06-10 23:02:24 +02:00
Hassieb PakzadandGitHub b262d7ff4a fix: skip git hooks in release-it (#2289) 2024-06-10 18:29:11 +00:00
Hassieb Pakzad 2b7aa21fa5 chore: release v2.50.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20) (push) Waiting to run
CI/CD / tests-worker (20) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-06-10 19:52:25 +02:00
Hassieb PakzadandGitHub a60bf7ce55 chore: adds git hooks to protect main branch (#2288) 2024-06-10 19:49:27 +02:00
Hassieb Pakzad 0d1f948625 feat: adds filter by score IDs to list scores endpoint 2024-06-10 19:06:26 +02:00
Hassieb Pakzad cf42dd726d chore: release v2.49.2
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20) (push) Waiting to run
CI/CD / tests-worker (20) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-06-10 16:22:01 +02:00
Hassieb PakzadandGitHub 8aeec63c07 perf: use raw query for trace duration on dataset run table (#2286) 2024-06-10 16:19:31 +02:00
Hassieb Pakzad 0cd3fcfbeb chore: release v2.49.1
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20) (push) Waiting to run
CI/CD / tests-worker (20) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-06-10 12:57:14 +02:00
Hassieb PakzadandGitHub a880bdb3b2 perf: boost observationCounts on prompts table (#2284) 2024-06-10 12:48:39 +02:00
Max DeichmannandGitHub 5c6d3e14f2 refactor: add tokenisation to worker (#2281) 2024-06-09 11:48:21 +00:00
Max DeichmannandGitHub 6ccc57312c fix: shared dependencies and improve docker ci (#2273) 2024-06-07 18:45:05 +02:00
Max DeichmannandGitHub c4831d1b25 fix: add lodash to worker (#2270) 2024-06-07 15:45:15 +02:00
Marc Klingen cc08408f0a fix(ui): show title when trying to deleting built-in models 2024-06-07 02:57:18 +02:00
6a41aadc98 fix(ui): handle nested menu states in sidebar (#2230)
* chore: update MainNavigation component to use nested navigation literals

* simplify

---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-06-06 22:42:51 +00:00
9d6efbae19 fix(ui): improve ux across delete buttons (#2258)
* improve ux across delete buttons

* updated delete button on models screen

* trpc errors are logged to console by default

* revert to pointer-events-none on disabled button

---------

Co-authored-by: Mish Ushakov <mishushakov@users.noreply.github.com>
Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-06-07 00:26:04 +02:00
Max DeichmannandGitHub 74be1e87c1 refactor: move ingestion types to shared (#2264)
push
2024-06-06 17:56:35 +00:00
Hassieb Pakzad dd823da1da chore: release v2.49.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20) (push) Waiting to run
CI/CD / tests-worker (20) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-06-06 17:52:18 +02:00
992f0affbc chore: remove trace fkey constraint from datasetrunitems (#2262)
Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-06-06 17:49:17 +02:00
Mish UshakovandGitHub b8c192324c docs: Updated contributing guide (#2259) 2024-06-06 14:28:57 +02:00
Marc KlingenandGitHub 1416e760ff chore: remove dangling worker/generated/types.ts (#2257) 2024-06-06 14:11:01 +02:00
Marc KlingenandGitHub 4266b46dd9 chore(db): drop legacy pricings table, replaced by models (#2256) 2024-06-06 11:36:08 +02:00
Marc KlingenandGitHub 1933f8f201 feat: add token/character count support for gemini-1.5-flash and gemini-1.5-pro (#2255) 2024-06-06 11:21:42 +02:00
Marc KlingenandGitHub 1e35eb3a66 fix(ui): encode prompt name on link from traces (#2253) 2024-06-06 08:30:52 +00:00
Marc KlingenandGitHub 5478c5e317 feat(ui): use dropdownmenu for version modal (#2252) 2024-06-06 08:20:01 +00:00
Marc KlingenandGitHub 140452c89e feat(ui): add upgrade popover to version number (#2251) 2024-06-06 00:48:23 +00:00
Marc KlingenandGitHub 7ac92e83da feat(ui): use pagination instead of virtualization on sessions view (#2248) 2024-06-05 22:21:01 +00:00
Marc Klingen 8a65bad7d4 chore: release v2.48.1
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20) (push) Waiting to run
CI/CD / tests-worker (20) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-06-05 23:04:25 +02:00
Marc Klingen 6c6348bfbe chore(rbac): grant new scoreConfigs:CUD scope to MEMBER role 2024-06-05 23:02:42 +02:00
Marlies Mayerhofer 9ff7fc942d fix: add maintainers heading in contributing file 2024-06-05 22:53:30 +02:00
Marlies Mayerhofer a3f59e7e95 feat: enhance robustness of multi select item search 2024-06-05 22:46:48 +02:00
Max DeichmannandGitHub 445bdcb65c chore: log level worker (#2246)
* push

* push
2024-06-05 20:34:12 +02:00
Max DeichmannandGitHub 8230aa79b3 chore: remove comments (#2245) 2024-06-05 20:11:10 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
57ca7fb478 chore(deps-dev): bump @typescript-eslint/parser from 7.7.0 to 7.12.0 (#2222)
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 7.7.0 to 7.12.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.12.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  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-06-05 20:05:17 +02:00
marliessophieandGitHub 2db88101e1 feat: add score and score config user tracking events (#2240)
* feat: add score and score config user tracking events

* add: icon for annotation button
2024-06-05 13:12:22 +00:00
Hassieb PakzadandGitHub 669167002a fix: parse object type message content (#2239) 2024-06-05 12:48:30 +00:00
marliessophieandGitHub 2f6ade9354 fix(annotation): improve keyboard navigation experience in drawer (#2236) 2024-06-05 13:41:04 +02:00
164 changed files with 5672 additions and 2891 deletions
+14
View File
@@ -0,0 +1,14 @@
{
"name": "langfuse dev",
"image": "mcr.microsoft.com/devcontainers/javascript-node:20-bookworm",
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"version": "latest",
"moby": true
}
},
"forwardPorts": [3000],
"onCreateCommand": "npm install -g pnpm@8.15.5",
"postCreateCommand": "cp .env.dev.example .env && pnpm run dx-f"
}
-4
View File
@@ -34,10 +34,6 @@ S3_SECRET_ACCESS_KEY=
S3_BUCKET_NAME=
S3_REGION=
# Prompt playground
OPENAI_API_KEY=""
ANTHROPIC_API_KEY=""
# Set during docker build of application
# Used to disable environment verification at build time
# DOCKER_BUILD=1
-5
View File
@@ -13,11 +13,6 @@ NEXTAUTH_URL="http://localhost:3000"
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES="false"
SALT="salt"
# Prompt playground
OPENAI_API_KEY=""
ANTHROPIC_API_KEY=""
# Redis
REDIS_HOST="127.0.0.1"
REDIS_PORT=6379
+6 -5
View File
@@ -43,6 +43,7 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
# AUTH_GOOGLE_CLIENT_ID=
# AUTH_GOOGLE_CLIENT_SECRET=
# AUTH_GOOGLE_ALLOW_ACCOUNT_LINKING=false
# AUTH_GOOGLE_ALLOWED_DOMAINS=langfuse.com,google.com # optional allowlist of workspace domains that can sign in via Google
# AUTH_GITHUB_CLIENT_ID=
# AUTH_GITHUB_CLIENT_SECRET=
# AUTH_GITHUB_ALLOW_ACCOUNT_LINKING=false
@@ -62,7 +63,11 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
# AUTH_COGNITO_CLIENT_SECRET=
# AUTH_COGNITO_ISSUER=
# AUTH_COGNITO_ALLOW_ACCOUNT_LINKING=false
# AUTH_CUSTOM_CLIENT_ID=
# AUTH_CUSTOM_CLIENT_SECRET=
# AUTH_CUSTOM_ISSUER=
# AUTH_CUSTOM_NAME=
# AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING=false
# Transactional email, optional
# Defines the email address to use as the from address.
@@ -123,10 +128,6 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
# NEXT_PUBLIC_TURNSTILE_SITE_KEY=
# TURNSTILE_SECRET_KEY=
# Prompt playground
# OPENAI_API_KEY=""
# ANTHROPIC_API_KEY=""
# Betterstack
# LANGFUSE_TEAM_BETTERSTACK_TOKEN=
+27 -12
View File
@@ -40,25 +40,34 @@ jobs:
- name: Checkout
uses: actions/checkout@v3
- name: Build Web Docker image
uses: docker/build-push-action@v4
with:
context: .
file: ./web/Dockerfile
push: false
- name: Build and run both images from compose
run: |
docker compose -f docker-compose.build.yml up -d
sleep 5 # Wait for PostgreSQL to accept connections
- name: Build Worker Docker image
uses: docker/build-push-action@v4
with:
context: .
file: ./worker/Dockerfile
push: false
- name: Check server health
run: |
timeout 10 bash -c 'until curl -f http://localhost:3000/api/public/health; do sleep 2; done'
- name: Check worker health
run: |
timeout 10 bash -c 'until curl -f http://localhost:3030/api/health; do sleep 2; done'
- name: Ensure no unhealthy status
run: |
if docker-compose ps | grep "(unhealthy)"; then
echo "One or more services are unhealthy"
exit 1
else
echo "All services are healthy"
fi
tests-web:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20]
postgres-version: [12, 15]
steps:
- name: Set Swap Space
uses: pierotofy/set-swap-space@master
@@ -89,6 +98,9 @@ jobs:
run: |
docker compose -f docker-compose.dev.yml up -d
sleep 5 # Wait for PostgreSQL to accept connections
docker compose ps
env:
POSTGRES_VERSION: ${{ matrix.postgres-version }}
- name: Seed DB
run: |
@@ -109,6 +121,7 @@ jobs:
strategy:
matrix:
node-version: [20]
postgres-version: [12, 15]
steps:
- name: Set Swap Space
uses: pierotofy/set-swap-space@master
@@ -139,6 +152,7 @@ jobs:
run: |
docker compose -f docker-compose.dev.yml up -d
sleep 5 # Wait for PostgreSQL to accept connections
docker compose ps
- name: Seed DB
run: |
@@ -176,6 +190,7 @@ jobs:
- name: Run + migrate
run: |
docker compose -f docker-compose.dev.yml up -d
docker compose ps
sleep 5 # Wait for PostgreSQL to accept connections
- name: Seed DB
+39
View File
@@ -0,0 +1,39 @@
name: Snyk Container
on: push
jobs:
snyk:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Build a Docker image
run: docker compose -f docker-compose.build.yml up -d
- name: Run Snyk to check Docker image for vulnerabilities (langfuse-server)
continue-on-error: true
uses: snyk/actions/docker@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
image: langfuse-server
args: --file=web/Dockerfile
- name: Upload result to GitHub Code Scanning
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: snyk.sarif
category: web
- name: Run Snyk to check Docker image for vulnerabilities (langfuse-worker)
continue-on-error: true
uses: snyk/actions/docker@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
image: langfuse-worker
args: --file=worker/Dockerfile
- name: Upload result to GitHub Code Scanning
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: snyk.sarif
category: worker
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
# Get the current branch
current_branch=$(git rev-parse --abbrev-ref HEAD)
# Define the protected branch
protected_branch="main"
# Check if the current branch is the protected branch
if [ "$current_branch" = "$protected_branch" ]; then
echo "🚨 You are about to commit to the $protected_branch branch. Are you sure? (y/n)"
read -r answer < /dev/tty
if [ "$answer" != "${answer#[Yy]}" ]; then
exit 0 # Commit will proceed
else
echo "Commit to $protected_branch branch has been canceled."
exit 1 # Commit will be blocked
fi
fi
# If not the protected branch, proceed with the commit
exit 0
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
# Get the current branch
current_branch=$(git rev-parse --abbrev-ref HEAD)
# Define the protected branch
protected_branch="main"
# Check if the current branch is the protected branch
if [ "$current_branch" = "$protected_branch" ]; then
echo "🚨 You are about to push to the $protected_branch branch. Are you sure? (y/n)"
read -r answer < /dev/tty
if [ "$answer" != "${answer#[Yy]}" ]; then
exit 0 # Push will proceed
else
echo "Push to $protected_branch branch has been canceled."
exit 1 # Push will be blocked
fi
fi
# If not the protected branch, proceed with the push
exit 0
+15 -1
View File
@@ -151,6 +151,8 @@ Requirements
- Node.js 20 as specified in the [.nvmrc](.nvmrc)
- Docker to run the database locally
**Note:** You can also simply run Langfuse in a **GitHub Codespace** via the provided devcontainer. To do this, click on the green "Code" button in the top right corner of the repository and select "Open with Codespaces".
**Steps**
1. Fork the repository and clone it locally
@@ -191,6 +193,16 @@ Requirements
pnpm run dev
```
7. Open the web app in the browser:
http://localhost:3000
8. Log in as a test user (after you ran `db:seed` command):
Username: demo@langfuse.com
Password: password
## Monorepo quickstart
- Available packages and their dependencies
@@ -358,7 +370,9 @@ The background color of the following component will be `hsl(var(--primary))` an
By following these guidelines, you can ensure that any contributions to our theme are consistent, maintainable, and aligned with our design system.
## Using secrets stored in 1Password
## Maintainers
### Using secrets stored in 1Password
When applying changes to non-local environments, you may need to use secrets stored in 1Password. We use the 1Password CLI for this purpose.
+10
View File
@@ -20,6 +20,11 @@ services:
- LANGFUSE_WORKER_HOST=${LANGFUSE_WORKER_HOST:-worker}
- LANGFUSE_WORKER_PASSWORD=${LANGFUSE_WORKER_PASSWORD:-mybasicauthsecret}
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/public/health"]
interval: 30s
timeout: 10s
retries: 3
worker:
build:
@@ -41,6 +46,11 @@ services:
- REDIS_AUTH=${REDIS_AUTH:-myredissecret}
- LANGFUSE_WORKER_PASSWORD=${LANGFUSE_WORKER_PASSWORD:-mybasicauthsecret}
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3030/api/health"]
interval: 30s
timeout: 10s
retries: 3
redis:
image: redis:7.2.4
+1 -3
View File
@@ -1,5 +1,3 @@
version: "3.5"
services:
redis:
image: redis:7.2.4
@@ -10,7 +8,7 @@ services:
- 6379:6379
db:
image: postgres
image: postgres:${POSTGRES_VERSION:-latest}
restart: always
command: ["postgres", "-c", "log_statement=all"]
environment:
+1 -1
View File
@@ -2,7 +2,7 @@ version: "3.5"
services:
langfuse-server:
image: ghcr.io/langfuse/langfuse:latest
image: langfuse/langfuse:2
depends_on:
db:
condition: service_healthy
+1 -1
View File
@@ -33,7 +33,7 @@
"@repo/eslint-config": "*",
"@repo/typescript-config": "*",
"@types/node": "^20.11.29",
"@typescript-eslint/parser": "^7.7.0",
"@typescript-eslint/parser": "^7.12.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-standard": "^17.1.0",
+7
View File
@@ -11,6 +11,7 @@ import { encrypt, decrypt } from "@langfuse/shared/encryption";
import { SsoProviderSchema } from "./types";
import { type NextApiRequest, type NextApiResponse } from "next";
import { env } from "../env";
import { CustomSSOProvider } from "@langfuse/shared/src/server/auth";
// Local cache for SSO configurations
let cachedSsoConfigs: {
@@ -173,6 +174,12 @@ const dbToNextAuthProvider = (provider: SsoProviderSchema): Provider | null => {
...provider.authConfig,
clientSecret: decrypt(provider.authConfig.clientSecret),
});
else if (provider.authProvider === "custom")
return CustomSSOProvider({
id: getAuthProviderIdForSsoConfig(provider), // use the domain as the provider id as we use domain-specific credentials
...provider.authConfig,
clientSecret: decrypt(provider.authConfig.clientSecret),
});
else {
// Type check to ensure we handle all providers
// eslint-disable-next-line no-unused-vars
+15
View File
@@ -76,12 +76,26 @@ export const CognitoProviderSchema = base.extend({
.nullish(),
});
export const CustomProviderSchema = base.extend({
authProvider: z.literal("custom"),
authConfig: z
.object({
name: z.string(),
clientId: z.string(),
clientSecret: z.string(),
issuer: z.string(),
allowDangerousEmailAccountLinking: z.boolean().optional().default(false),
})
.nullish(),
});
export type GoogleProviderSchema = z.infer<typeof GoogleProviderSchema>;
export type GithubProviderSchema = z.infer<typeof GithubProviderSchema>;
export type Auth0ProviderSchema = z.infer<typeof Auth0ProviderSchema>;
export type OktaProviderSchema = z.infer<typeof OktaProviderSchema>;
export type AzureAdProviderSchema = z.infer<typeof AzureAdProviderSchema>;
export type CognitoProviderSchema = z.infer<typeof CognitoProviderSchema>;
export type CustomProviderSchema = z.infer<typeof CustomProviderSchema>;
export const SsoProviderSchema = z.discriminatedUnion("authProvider", [
GoogleProviderSchema,
@@ -90,6 +104,7 @@ export const SsoProviderSchema = z.discriminatedUnion("authProvider", [
OktaProviderSchema,
AzureAdProviderSchema,
CognitoProviderSchema,
CustomProviderSchema,
]);
export type SsoProviderSchema = z.infer<typeof SsoProviderSchema>;
+44 -14
View File
@@ -7,28 +7,52 @@ service:
base-path: /api/public
endpoints:
batch:
docs: Batched ingestion for Langfuse Tracing
docs: Batched ingestion for Langfuse Tracing. If you want to use tracing via the API, such as to build your own Langfuse client implementation, this is the only API route you need to implement.
method: POST
path: /ingestion
request:
name: IngestionRequest
body:
properties:
batch: list<IngestionEvent>
response: IngestionResponse # will be reportet as 200 response, but endpoint returns 207
batch:
type: list<IngestionEvent>
docs: "Batch of tracing events to be ingested. Discriminated by attribute `type`."
metadata:
type: optional<unknown>
docs: Optional. Metadata field used by the Langfuse SDKs for debugging.
response:
type: IngestionResponse
status-code: 207
types:
IngestionEvent:
discriminant: "type"
union:
trace-create: TraceEvent
score-create: ScoreEvent
event-create: CreateEventEvent
generation-create: CreateGenerationEvent
generation-update: UpdateGenerationEvent
span-create: CreateSpanEvent
span-update: UpdateSpanEvent
sdk-log: SDKLogEvent
trace-create:
type: TraceEvent
docs: Creates a new trace. Upserts on id for updates if trace with id exists.
score-create:
type: ScoreEvent
docs: Creates a new score. Upserts on id for updates if score with id exists.
span-create:
type: CreateSpanEvent
docs: Creates a new span.
span-update:
type: UpdateSpanEvent
docs: Updates span based on id.
generation-create:
type: CreateGenerationEvent
docs: Creates a new generation.
generation-update:
type: UpdateGenerationEvent
docs: Updates a generation based on id.
event-create:
type: CreateEventEvent
docs: Creates an event.
sdk-log:
type: SDKLogEvent
docs: Langfuse SDKs only, used for debugging purposes.
# both are legacy
observation-create:
@@ -162,9 +186,15 @@ types:
BaseEvent:
properties:
id: string
timestamp: string
metadata: unknown
id:
type: string
docs: UUID v4 that identifies the event
timestamp:
type: string
docs: "Datetime (ISO 8601) of event creation in client. Should be as close to actual event creation in client as possible, this timestamp will be used for ordering of events in future release. Resolution: milliseconds (required), microseconds (optimal)."
metadata:
type: optional<unknown>
docs: Optional. Metadata field used by the Langfuse SDKs for debugging.
TraceEvent:
extends: BaseEvent
+4 -1
View File
@@ -31,7 +31,7 @@ service:
type: optional<datetime>
docs: Retrieve only scores newer than this datetime (ISO 8601).
source:
type: commons.ScoreSource
type: optional<commons.ScoreSource>
docs: Retrieve only scores from a specific source.
operator:
type: optional<string>
@@ -39,6 +39,9 @@ service:
value:
type: optional<double>
docs: Retrieve only scores with <operator> value.
scoreIds:
type: optional<string>
docs: Comma-separated list of score IDs to limit the results to.
response: Scores
get-by-id:
docs: Get a score
+1 -1
View File
@@ -1,4 +1,4 @@
{
"organization": "finto",
"version": "0.21.0"
"version": "0.30.3"
}
+15 -5
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "2.48.0",
"version": "2.54.0",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
@@ -17,26 +17,36 @@
"db:seed": "turbo run db:seed",
"db:seed:examples": "turbo run db:seed:examples",
"nuke": "bash ./scripts/nuke.sh",
"dx": "pnpm i && pnpm --filter=shared run db:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
"dx": "pnpm i && pnpm run infra:dev:up && pnpm --filter=shared run db:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
"dx-f": "pnpm i && pnpm run infra:dev:up && pnpm --filter=shared run db:reset -f && pnpm --filter=shared run db:seed:examples && pnpm run dev",
"build": "turbo run build",
"start": "turbo run start",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test",
"models:migrate": "turbo run models:migrate",
"release": "dotenv -e ../.env -- release-it"
"release": "dotenv -e ../.env -- release-it",
"prepare": "husky"
},
"devDependencies": {
"@release-it/bumper": "^6.0.1",
"braces": "3.0.3",
"dotenv-cli": "^7.4.2",
"husky": "^9.0.11",
"prettier": "^3.2.5",
"release-it": "^17.2.1",
"release-it": "^17.3.0",
"turbo": "^1.13.3"
},
"release-it": {
"git": {
"commitMessage": "chore: release v${version}",
"tagName": "v${version}"
"tagName": "v${version}",
"commitArgs": [
"--no-verify"
],
"pushArgs": [
"--no-verify"
]
},
"plugins": {
"@release-it/bumper": {
+1 -1
View File
@@ -9,7 +9,7 @@
],
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.7.0",
"@typescript-eslint/parser": "^7.12.0",
"@vercel/style-guide": "^6.0.0",
"eslint-config-next": "^14.2.3",
"eslint-config-prettier": "^9.1.0",
+5 -3
View File
@@ -52,6 +52,8 @@
"bcryptjs": "^2.4.3",
"kysely": "^0.27.3",
"langchain": "^0.1.36",
"lodash": "^4.17.21",
"next-auth": "^4.24.7",
"prisma-extension-kysely": "^2.1.0",
"zod": "^3.22.4",
"zod-to-json-schema": "^3.22.5"
@@ -59,17 +61,17 @@
"devDependencies": {
"@repo/eslint-config": "*",
"@repo/typescript-config": "*",
"@types/lodash": "^4.17.4",
"@types/lodash": "^4.17.5",
"@types/node": "^20.11.29",
"@types/pg": "^8.11.6",
"@types/uuid": "^9.0.8",
"@typescript-eslint/parser": "^7.7.0",
"@typescript-eslint/parser": "^7.12.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-standard": "^17.1.0",
"eslint-plugin-prettier": "^5.1.3",
"kysely-codegen": "^0.11.0",
"nodemon": "^3.0.3",
"nodemon": "^3.1.3",
"prettier": "^3.2.5",
"prisma": "^5.13.0",
"prisma-erd-generator": "^1.11.2",
+5 -20
View File
@@ -36,17 +36,6 @@ export const ScoreDataType = {
BOOLEAN: "BOOLEAN"
} as const;
export type ScoreDataType = (typeof ScoreDataType)[keyof typeof ScoreDataType];
export const PricingUnit = {
PER_1000_TOKENS: "PER_1000_TOKENS",
PER_1000_CHARS: "PER_1000_CHARS"
} as const;
export type PricingUnit = (typeof PricingUnit)[keyof typeof PricingUnit];
export const TokenType = {
PROMPT: "PROMPT",
COMPLETION: "COMPLETION",
TOTAL: "TOTAL"
} as const;
export type TokenType = (typeof TokenType)[keyof typeof TokenType];
export const DatasetStatus = {
ACTIVE: "ACTIVE",
ARCHIVED: "ARCHIVED"
@@ -163,6 +152,7 @@ export type EvalTemplate = {
version: number;
prompt: string;
model: string;
provider: string;
model_params: unknown;
vars: Generated<string[]>;
output_schema: unknown;
@@ -210,8 +200,12 @@ export type LlmApiKeys = {
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
provider: string;
adapter: string;
display_secret_key: string;
secret_key: string;
base_url: string | null;
custom_models: Generated<string[]>;
with_default_models: Generated<boolean>;
project_id: string;
};
export type MembershipInvitation = {
@@ -310,14 +304,6 @@ export type PosthogIntegration = {
enabled: boolean;
created_at: Generated<Timestamp>;
};
export type Pricing = {
id: string;
model_name: string;
pricing_unit: Generated<PricingUnit>;
price: string;
currency: Generated<string>;
token_type: TokenType;
};
export type Project = {
id: string;
created_at: Generated<Timestamp>;
@@ -471,7 +457,6 @@ export type DB = {
observations: Observation;
observations_view: ObservationView;
posthog_integrations: PosthogIntegration;
pricings: Pricing;
project_memberships: ProjectMembership;
projects: Project;
prompts: Prompt;
@@ -1,7 +0,0 @@
-- Google Vertex uses @ to separate model name and version
UPDATE "public"."models" SET "match_pattern" = '(?i)^(claude-3-haiku(-|@)?20240307)$' WHERE "id" = 'cltr0w45b000008k1407o9qv1';
UPDATE "public"."models" SET "match_pattern" = '(?i)^(claude-3-opus(-|@)?20240229)$' WHERE "id" = 'cltgy0iuw000008le3vod1hhy';
UPDATE "public"."models" SET "match_pattern" = '(?i)^(claude-3-sonnet(-|@)?20240229)$' WHERE "id" = 'cltgy0pp6000108le56se7bl3';
@@ -0,0 +1,14 @@
/*
Warnings:
- You are about to drop the `pricings` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropTable
DROP TABLE "pricings";
-- DropEnum
DROP TYPE "PricingUnit";
-- DropEnum
DROP TYPE "TokenType";
@@ -0,0 +1,3 @@
-- DropForeignKey
ALTER TABLE "dataset_run_items" DROP CONSTRAINT "dataset_run_items_trace_id_fkey";
ALTER TABLE "dataset_run_items" DROP CONSTRAINT "dataset_run_items_observation_id_fkey";
@@ -0,0 +1,8 @@
-- does not include pricing yet, will be added as soon as it is calculated at ingestion time
-- remove model if added by faulty migration, context: https://github.com/langfuse/langfuse/issues/2266
DELETE FROM models WHERE id in ('clx30djsn0000w9mzebiv41we', 'clx30hkrx0000w9mz7lqi0ial');
INSERT INTO "models" ("id", "model_name", "match_pattern", "unit") VALUES ('clx30djsn0000w9mzebiv41we', 'gemini-1.5-flash', '(?i)^(gemini-1.5-flash)(@[a-zA-Z0-9]+)?$', 'CHARACTERS');
INSERT INTO "models" ("id", "model_name", "match_pattern", "unit") VALUES ('clx30hkrx0000w9mz7lqi0ial', 'gemini-1.5-pro', '(?i)^(gemini-1.5-pro)(@[a-zA-Z0-9]+)?$', 'CHARACTERS');
@@ -0,0 +1,7 @@
-- Google Vertex uses @ to separate model name and version
UPDATE "models" SET "match_pattern" = '(?i)^(claude-3-haiku(-|@)?20240307)$' WHERE "id" = 'cltr0w45b000008k1407o9qv1';
UPDATE "models" SET "match_pattern" = '(?i)^(claude-3-opus(-|@)?20240229)$' WHERE "id" = 'cltgy0iuw000008le3vod1hhy';
UPDATE "models" SET "match_pattern" = '(?i)^(claude-3-sonnet(-|@)?20240229)$' WHERE "id" = 'cltgy0pp6000108le56se7bl3';
@@ -0,0 +1,24 @@
BEGIN;
ALTER TABLE "llm_api_keys"
ADD COLUMN "base_url" TEXT,
ADD COLUMN "adapter" TEXT,
ADD COLUMN "custom_models" TEXT[] NOT NULL DEFAULT '{}'::TEXT[],
ADD COLUMN "with_default_models" BOOLEAN NOT NULL DEFAULT true;
UPDATE "llm_api_keys"
SET "adapter" = "provider";
ALTER TABLE "llm_api_keys"
ALTER COLUMN "adapter" SET NOT NULL;
ALTER TABLE "eval_templates"
ADD COLUMN "provider" TEXT;
UPDATE "eval_templates"
SET "provider" = 'openai';
ALTER TABLE "eval_templates"
ALTER COLUMN "provider" SET NOT NULL;
COMMIT;
@@ -10,7 +10,7 @@ INSERT INTO score_configs (id,
max_value,
description)
SELECT
gen_random_uuid () AS id,
md5(random()::text || clock_timestamp()::text || s.project_id::text)::uuid AS id,
s.project_id,
'manual-score',
'NUMERIC',
@@ -24,6 +24,7 @@ FROM ( SELECT DISTINCT
scores
WHERE
name = 'manual-score'
AND config_id IS NULL
AND source = 'ANNOTATION') s
WHERE
NOT EXISTS (
@@ -47,4 +48,4 @@ FROM
WHERE
scores.project_id = pc.project_id
AND scores.name = 'manual-score';
COMMIT;
COMMIT;
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX CONCURRENTLY "observations_project_id_prompt_id_idx" ON "observations"("project_id", "prompt_id");
+29 -50
View File
@@ -144,9 +144,13 @@ model LlmApiKeys {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
provider String
displaySecretKey String @map("display_secret_key")
secretKey String @map("secret_key")
provider String
adapter String // This controls the interface that is used to connect with the LLM, e.g. 'openai' or 'anthropic'
displaySecretKey String @map("display_secret_key")
secretKey String @map("secret_key")
baseURL String? @map("base_url")
customModels String[] @default([]) @map("custom_models")
withDefaultModels Boolean @default(true) @map("with_default_models")
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@ -233,9 +237,8 @@ model Trace {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
DatasetRunItems DatasetRunItems[]
DatasetItem DatasetItem[]
JobExecution JobExecution[]
DatasetItem DatasetItem[]
JobExecution JobExecution[]
@@index([projectId])
@@index([sessionId])
@@ -298,21 +301,20 @@ model Observation {
// GENERATION ONLY
model String?
internalModel String? @map("internal_model")
internalModel String? @map("internal_model")
modelParameters Json?
input Json?
output Json?
promptTokens Int @default(0) @map("prompt_tokens")
completionTokens Int @default(0) @map("completion_tokens")
totalTokens Int @default(0) @map("total_tokens")
promptTokens Int @default(0) @map("prompt_tokens")
completionTokens Int @default(0) @map("completion_tokens")
totalTokens Int @default(0) @map("total_tokens")
unit String?
inputCost Decimal? @map("input_cost")
outputCost Decimal? @map("output_cost")
totalCost Decimal? @map("total_cost")
completionStartTime DateTime? @map("completion_start_time")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
inputCost Decimal? @map("input_cost")
outputCost Decimal? @map("output_cost")
totalCost Decimal? @map("total_cost")
completionStartTime DateTime? @map("completion_start_time")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
derivedDatasetItems DatasetItem[]
datasetRunItems DatasetRunItems[]
promptId String? @map("prompt_id")
prompt Prompt? @relation(fields: [promptId], onDelete: SetNull, references: [id])
@@ -331,6 +333,7 @@ model Observation {
@@index(parentObservationId)
@@index(model)
@@index(internalModel)
@@index([projectId, promptId])
@@index(promptId)
@@index([projectId, startTime, type])
@@map("observations")
@@ -467,29 +470,6 @@ enum ScoreDataType {
BOOLEAN
}
enum PricingUnit {
PER_1000_TOKENS
PER_1000_CHARS
}
enum TokenType {
PROMPT
COMPLETION
TOTAL
}
model Pricing {
id String @id @default(cuid())
modelName String @map("model_name")
pricingUnit PricingUnit @default(PER_1000_TOKENS) @map("pricing_unit")
price Decimal
currency String @default("USD")
tokenType TokenType @map("token_type")
@@index(modelName)
@@map("pricings")
}
model CronJobs {
name String @id
lastRun DateTime? @map("last_run")
@@ -566,17 +546,15 @@ model DatasetRuns {
}
model DatasetRunItems {
id String @id @default(cuid())
datasetRunId String @map("dataset_run_id")
datasetRun DatasetRuns @relation(fields: [datasetRunId], references: [id], onDelete: Cascade)
datasetItemId String @map("dataset_item_id")
datasetItem DatasetItem @relation(fields: [datasetItemId], references: [id], onDelete: Cascade)
traceId String @map("trace_id")
trace Trace @relation(fields: [traceId], references: [id], onDelete: Cascade)
observationId String? @map("observation_id")
observation Observation? @relation(fields: [observationId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
id String @id @default(cuid())
datasetRunId String @map("dataset_run_id")
datasetRun DatasetRuns @relation(fields: [datasetRunId], references: [id], onDelete: Cascade)
datasetItemId String @map("dataset_item_id")
datasetItem DatasetItem @relation(fields: [datasetItemId], references: [id], onDelete: Cascade)
traceId String @map("trace_id")
observationId String? @map("observation_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
@@index([datasetRunId], type: Hash)
@@index([datasetItemId], type: Hash)
@@ -691,6 +669,7 @@ model EvalTemplate {
version Int
prompt String
model String
provider String
modelParams Json @map("model_params")
vars String[] @default([])
outputSchema Json @map("output_schema")
+2
View File
@@ -184,6 +184,7 @@ async function main() {
secretKey: encrypt(OPENAI_API_KEY),
displaySecretKey: getDisplaySecretKey(OPENAI_API_KEY),
provider: "openai",
adapter: "openai",
},
});
} else {
@@ -209,6 +210,7 @@ async function main() {
"Please evaluate the toxicity of the following text {{input}} {{output}}",
model: "gpt-3.5-turbo",
vars: ["input", "output"],
provider: "openai",
outputSchema: {
score: "provide a score between 0 and 1",
reasoning: "one sentence reasoning for the score",
+7
View File
@@ -0,0 +1,7 @@
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = '_prisma_migrations') THEN
DELETE FROM _prisma_migrations
WHERE migration_name IN ('20240606090858_pricings_add_latest_gemini_models', '20240530212419_model_price_anthropic_via_google_vertex', '20240604133340_backfill_manual_scores');
END IF;
END $$;
+10 -33
View File
@@ -1,5 +1,5 @@
import z from "zod";
import { ModelProvider, UIModelParams } from "../..";
import { LLMAdapter } from "../..";
export const langfuseObjects = [
"trace",
@@ -77,38 +77,15 @@ export const availableEvalVariables = [
},
];
export const evalLLMModels: UIModelParams[] = [
{
provider: { value: ModelProvider.OpenAI, enabled: true },
model: { value: "gpt-3.5-turbo", enabled: true },
temperature: { value: 1, enabled: true },
maxTemperature: { value: 2, enabled: true },
max_tokens: { value: 256, enabled: true },
top_p: { value: 1, enabled: true },
},
{
provider: { value: ModelProvider.OpenAI, enabled: true },
model: { value: "gpt-4-turbo-preview", enabled: true },
temperature: { value: 1, enabled: true },
maxTemperature: { value: 2, enabled: true },
max_tokens: { value: 256, enabled: true },
top_p: { value: 1, enabled: true },
},
{
provider: { value: ModelProvider.OpenAI, enabled: true },
model: { value: "gpt-4o", enabled: true },
temperature: { value: 1, enabled: true },
maxTemperature: { value: 2, enabled: true },
max_tokens: { value: 256, enabled: true },
top_p: { value: 1, enabled: true },
},
] as const;
export const EvalModelNames = z.enum([
"gpt-3.5-turbo",
"gpt-4-turbo-preview",
"gpt-4o",
]);
export const evalModelsByAdapter: Record<LLMAdapter, string[]> = {
[LLMAdapter.OpenAI]: [
"gpt-4o",
"gpt-4-turbo-preview",
"gpt-3.5-turbo",
] as const,
[LLMAdapter.Anthropic]: [] as const,
[LLMAdapter.Azure]: [] as const,
};
export const OutputSchema = z.object({
reasoning: z.string(),
@@ -1,9 +1,9 @@
import lodash from "lodash";
import { z } from "zod";
import { ModelUsageUnit } from "@langfuse/shared";
import { NonEmptyString, jsonSchema } from "@/src/utils/zod";
import { ObservationLevel } from "@langfuse/shared";
import { NonEmptyString, jsonSchema } from "../../utils/zod";
import { ModelUsageUnit } from "../../constants";
import { ObservationLevel } from "@prisma/client";
export const Usage = z.object({
input: z.number().int().nullish(),
@@ -109,7 +109,7 @@ export const CreateGenerationBody = CreateSpanBody.extend({
z.string(),
z
.union([z.string(), z.number(), z.boolean(), z.array(z.string())])
.nullish(),
.nullish()
)
.nullish(),
usage: usage,
@@ -131,7 +131,7 @@ export const UpdateGenerationBody = UpdateSpanBody.extend({
z.string(),
z
.union([z.string(), z.number(), z.boolean(), z.array(z.string())])
.nullish(),
.nullish()
)
.nullish(),
usage: usage,
@@ -195,7 +195,7 @@ export const LegacyGenerationsCreateSchema = z.object({
modelParameters: z
.record(
z.string(),
z.union([z.string(), z.number(), z.boolean()]).nullish(),
z.union([z.string(), z.number(), z.boolean()]).nullish()
)
.nullish(),
prompt: jsonSchema.nullish(),
@@ -219,7 +219,7 @@ export const LegacyGenerationPatchSchema = z.object({
modelParameters: z
.record(
z.string(),
z.union([z.string(), z.number(), z.boolean()]).nullish(),
z.union([z.string(), z.number(), z.boolean()]).nullish()
)
.nullish(),
prompt: jsonSchema.nullish(),
@@ -243,7 +243,7 @@ export const LegacyObservationBody = z.object({
modelParameters: z
.record(
z.string(),
z.union([z.string(), z.number(), z.boolean()]).nullish(),
z.union([z.string(), z.number(), z.boolean()]).nullish()
)
.nullish(),
input: jsonSchema.nullish(),
+3
View File
@@ -9,6 +9,9 @@ export * from "./filterToPrisma";
export * from "./tracesTable";
export * from "./server/auth";
export * from "./observationsTable";
export * from "./features/ingestion/types";
export * from "./utils/zod";
export * from "./utils/json";
export { env } from "./env";
// llm api
+31
View File
@@ -1,6 +1,7 @@
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()}`;
@@ -48,3 +49,33 @@ export function createShaHash(privateKey: string, salt: string): string {
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" } },
checks: ["pkce", "state"],
idToken: true,
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
image: null,
};
},
options,
};
}
@@ -17,7 +17,7 @@ import {
ChatMessageRole,
LLMFunctionCall,
ModelParams,
ModelProvider,
LLMAdapter,
} from "./types";
import zodToJsonSchema from "zod-to-json-schema";
import { JsonOutputFunctionsParser } from "langchain/output_parsers";
@@ -27,7 +27,9 @@ type LLMCompletionParams = {
modelParams: ModelParams;
functionCall?: LLMFunctionCall;
callbacks?: BaseCallbackHandler[];
baseURL?: string;
apiKey?: string;
maxRetries?: number;
};
type FetchLLMCompletionParams = LLMCompletionParams & {
@@ -57,7 +59,16 @@ export async function fetchLLMCompletion(
params: FetchLLMCompletionParams
): Promise<string | IterableReadableStream<Uint8Array> | unknown> {
// the apiKey must never be printed to the console
const { messages, modelParams, streaming, callbacks, apiKey } = params;
const {
messages,
modelParams,
streaming,
callbacks,
apiKey,
baseURL,
maxRetries,
} = params;
const finalMessages = messages.map((message) => {
if (message.role === ChatMessageRole.User)
return new HumanMessage(message.content);
@@ -68,16 +79,18 @@ export async function fetchLLMCompletion(
});
let chatModel: ChatOpenAI | ChatAnthropic;
if (modelParams.provider === ModelProvider.Anthropic) {
if (modelParams.adapter === LLMAdapter.Anthropic) {
chatModel = new ChatAnthropic({
anthropicApiKey: apiKey,
anthropicApiUrl: baseURL,
modelName: modelParams.model,
temperature: modelParams.temperature,
maxTokens: modelParams.max_tokens,
topP: modelParams.top_p,
callbacks,
clientOptions: { maxRetries },
});
} else if (modelParams.provider === ModelProvider.OpenAI) {
} else if (modelParams.adapter === LLMAdapter.OpenAI) {
chatModel = new ChatOpenAI({
openAIApiKey: apiKey,
modelName: modelParams.model,
@@ -85,15 +98,29 @@ export async function fetchLLMCompletion(
maxTokens: modelParams.max_tokens,
topP: modelParams.top_p,
callbacks,
maxRetries,
configuration: {
baseURL,
},
});
} else if (modelParams.adapter === LLMAdapter.Azure) {
chatModel = new ChatOpenAI({
azureOpenAIApiKey: apiKey,
azureOpenAIBasePath: baseURL,
azureOpenAIApiDeploymentName: modelParams.model,
azureOpenAIApiVersion: "2024-02-01",
temperature: modelParams.temperature,
maxTokens: modelParams.max_tokens,
topP: modelParams.top_p,
callbacks,
maxRetries,
});
} else {
// eslint-disable-next-line no-unused-vars
const _exhaustiveCheck: never = modelParams;
const _exhaustiveCheck: never = modelParams.adapter;
throw new Error("This model provider is not supported.");
}
console.log("Making LLM call with params: ", modelParams);
if (params.functionCall) {
const functionCallingModel = chatModel.bind({
functions: [
+36 -18
View File
@@ -1,3 +1,4 @@
import { LlmApiKeys } from "@prisma/client";
import z from "zod";
export type PromptVariable = { name: string; value: string; isUsed: boolean };
@@ -9,9 +10,10 @@ export type ChatMessage = {
export type ChatMessageWithId = ChatMessage & { id: string };
export enum ModelProvider {
export enum LLMAdapter {
Anthropic = "anthropic",
OpenAI = "openai",
Azure = "azure",
}
export enum ChatMessageRole {
@@ -19,32 +21,30 @@ export enum ChatMessageRole {
User = "user",
Assistant = "assistant",
}
export type ModelParams = AnthropicModelParams | OpenAIModelParams;
export type ModelParams = {
provider: string;
adapter: LLMAdapter;
model: string;
} & ModelConfig;
type RecordWithEnabledFlag<T> = {
[K in keyof T]: { value: T[K]; enabled: boolean };
};
export type UIModelParams = RecordWithEnabledFlag<
Required<AnthropicModelParams | OpenAIModelParams> & {
Required<ModelParams> & {
maxTemperature: number;
}
>;
// Generic config
export type ModelConfig = z.infer<typeof ZodModelConfig>;
export const ZodModelConfig = z.object({
max_tokens: z.coerce.number().optional(),
temperature: z.coerce.number().optional(),
top_p: z.coerce.number().optional(),
});
// OpenAI
export type OpenAIModelParams = {
provider: ModelProvider.OpenAI;
model: OpenAIModel;
} & ModelConfig;
export const openAIModels = [
"gpt-4o",
"gpt-4o-2024-05-13",
@@ -64,12 +64,6 @@ export const openAIModels = [
export type OpenAIModel = (typeof openAIModels)[number];
// Anthropic
export type AnthropicModelParams = {
provider: ModelProvider.Anthropic;
model: AnthropicModel;
} & ModelConfig;
export const anthropicModels = [
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
@@ -81,8 +75,9 @@ export const anthropicModels = [
export type AnthropicModel = (typeof anthropicModels)[number];
export const supportedModels = {
[ModelProvider.Anthropic]: anthropicModels,
[ModelProvider.OpenAI]: openAIModels,
[LLMAdapter.Anthropic]: anthropicModels,
[LLMAdapter.OpenAI]: openAIModels,
[LLMAdapter.Azure]: [],
} as const;
export type LLMFunctionCall = {
@@ -90,3 +85,26 @@ export type LLMFunctionCall = {
description: string;
parameters: z.ZodTypeAny; // this has to be a json schema for OpenAI
};
export const LLMApiKeySchema = z
.object({
id: z.string(),
projectId: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
adapter: z.nativeEnum(LLMAdapter),
provider: z.string(),
displaySecretKey: z.string(),
secretKey: z.string(),
baseURL: z.string().nullable(),
customModels: z.array(z.string()),
withDefaultModels: z.boolean(),
})
// strict mode to prevent extra keys. Thorws error otherwise
// https://github.com/colinhacks/zod?tab=readme-ov-file#strict
.strict();
export type LLMApiKey =
z.infer<typeof LLMApiKeySchema> extends LlmApiKeys
? z.infer<typeof LLMApiKeySchema>
: never;
@@ -1,6 +1,6 @@
import { type jsonSchema } from "@/src/utils/zod";
import { type z } from "zod";
import lodash from "lodash";
import { jsonSchema } from "./zod";
export const parseJson = (input: string) => {
try {
@@ -37,7 +37,7 @@ export function deepParseJson(json: unknown): unknown {
// Ensure we only iterate over the object's own properties
if (Object.prototype.hasOwnProperty.call(json, key)) {
(json as Record<string, unknown>)[key] = deepParseJson(
(json as Record<string, unknown>)[key],
(json as Record<string, unknown>)[key]
);
}
}
@@ -50,7 +50,7 @@ export function deepParseJson(json: unknown): unknown {
export const mergeJson = (
json1?: z.infer<typeof jsonSchema>,
json2?: z.infer<typeof jsonSchema>,
json2?: z.infer<typeof jsonSchema>
) => {
if (json1 === undefined) {
return json2;
@@ -26,7 +26,7 @@ const jsonSchemaNullable: z.ZodType<JsonNested> = z.lazy(() =>
nestedLiteralSchema,
z.array(jsonSchemaNullable),
z.record(jsonSchemaNullable),
]),
])
);
// Root schema that does not allow nulls at the root level
@@ -35,17 +35,17 @@ export const jsonSchema: z.ZodType<Json> = z.lazy(() =>
rootLiteralSchema,
z.array(jsonSchemaNullable),
z.record(jsonSchemaNullable),
]),
])
);
export const paginationZod = {
page: z.preprocess(
(x) => (x === "" ? undefined : x),
z.coerce.number().default(1),
z.coerce.number().default(1)
),
limit: z.preprocess(
(x) => (x === "" ? undefined : x),
z.coerce.number().lte(100).default(50),
z.coerce.number().lte(100).default(50)
),
};
+2179 -978
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -9,7 +9,9 @@
"start": {
"dependsOn": ["^start"]
},
"db:migrate": {},
"db:migrate": {
"cache": false
},
"db:deploy": {},
"db:reset": {},
"db:push": {},
+3 -2
View File
@@ -1,8 +1,8 @@
FROM node:20-alpine AS alpine
FROM node:20-alpine3.20 AS alpine
# It's important to update the index before installing packages to ensure you're getting the latest versions.
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk update && apk upgrade --no-cache libcrypto3 libssl3 libc6-compat
RUN apk update && apk upgrade --no-cache libcrypto3 libssl3 libc6-compat busybox ssl_client
FROM alpine AS base
RUN npm install turbo@^1.13.3 --global
@@ -78,6 +78,7 @@ COPY --from=builder --chown=nextjs:nodejs /app/web/public ./web/public
COPY --from=builder --chown=nextjs:nodejs /app/packages/shared/prisma ./packages/shared/prisma
COPY --chown=nextjs:nodejs ./web/entrypoint.sh ./web/entrypoint.sh
COPY --chown=nextjs:nodejs ./packages/shared/scripts/cleanup.sql ./packages/shared/scripts/cleanup.sql
RUN chmod +x ./web/entrypoint.sh
USER nextjs
+3
View File
@@ -1,5 +1,6 @@
#!/bin/sh
# Run cleanup script before running migrations
# Check if DATABASE_URL is not set
if [ -z "$DATABASE_URL" ]; then
# Check if all required variables are provided
@@ -18,6 +19,8 @@ if [ -z "$DIRECT_URL" ]; then
export DIRECT_URL=$DATABASE_URL
fi
prisma db execute --url "$DIRECT_URL" --file "./packages/shared/scripts/cleanup.sql"
# Apply migrations
prisma migrate deploy --schema=./packages/shared/prisma/schema.prisma
status=$?
+15 -15
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "2.48.0",
"version": "2.54.0",
"private": true,
"license": "MIT",
"engines": {
@@ -27,7 +27,7 @@
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lint": "^6.8.0",
"@headlessui/react": "1.7.18",
"@headlessui/tailwindcss": "0.2.0",
"@headlessui/tailwindcss": "0.2.1",
"@heroicons/react": "^2.1.3",
"@hookform/resolvers": "^3.3.4",
"@langchain/anthropic": "^0.1.21",
@@ -36,6 +36,7 @@
"@langfuse/ee": "workspace:*",
"@langfuse/shared": "workspace:*",
"@marsidev/react-turnstile": "^0.5.4",
"@mui/x-tree-view": "^7.6.2",
"@next-auth/prisma-adapter": "^1.0.7",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
@@ -58,27 +59,26 @@
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-toggle-group": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@react-email/components": "^0.0.18",
"@react-email/render": "^0.0.14",
"@react-email/components": "^0.0.19",
"@react-email/render": "^0.0.15",
"@remixicon/react": "^4.2.0",
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@sentry/nextjs": "^7.113.0",
"@sentry/node": "^7.113.0",
"@sentry/profiling-node": "^7.113.0",
"@sentry/types": "^7.113.0",
"@sentry/nextjs": "^8.8.0",
"@sentry/node": "^8.8.0",
"@sentry/profiling-node": "^8.8.0",
"@sentry/types": "^8.8.0",
"@t3-oss/env-nextjs": "^0.10.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/react-query": "^4.36.1",
"@tanstack/react-table": "^8.11.8",
"@tanstack/react-virtual": "^3.5.0",
"@tremor/react": "3.16.2",
"@trpc/client": "^10.45.0",
"@trpc/next": "^10.45.0",
"@trpc/react-query": "^10.45.0",
"@trpc/server": "^10.45.0",
"@uiw/codemirror-theme-github": "^4.21.25",
"@uiw/codemirror-theme-tokyo-night": "^4.22.1",
"@uiw/codemirror-theme-tokyo-night": "^4.22.2",
"@uiw/react-codemirror": "^4.21.25",
"ai": "^3.0.23",
"bcryptjs": "^2.4.3",
@@ -89,7 +89,7 @@
"cors": "^2.8.5",
"date-fns": "^3.3.1",
"decimal.js": "^10.4.3",
"dompurify": "^3.1.4",
"dompurify": "^3.1.5",
"exponential-backoff": "^3.1.1",
"js-tiktoken": "^1.0.12",
"kysely": "^0.27.3",
@@ -125,21 +125,21 @@
"@mermaid-js/mermaid-cli": "^10.7.0",
"@playwright/test": "^1.43.1",
"@tailwindcss/forms": "^0.5.7",
"@testing-library/jest-dom": "^6.4.5",
"@testing-library/jest-dom": "^6.4.6",
"@testing-library/react": "^15.0.7",
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/dompurify": "^3.0.5",
"@types/eslint": "^8.56.7",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.4",
"@types/lodash": "^4.17.5",
"@types/node": "20.10.5",
"@types/nodemailer": "^6.4.15",
"@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^7.7.0",
"@typescript-eslint/parser": "^7.12.0",
"autoprefixer": "^10.4.19",
"dotenv-cli": "^7.4.2",
"eslint": "^8.56.0",
@@ -150,7 +150,7 @@
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.14",
"tailwindcss": "^3.4.3",
"tailwindcss": "^3.4.4",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.7.2",
+45 -19
View File
@@ -397,7 +397,10 @@ paths:
description: ''
/api/public/ingestion:
post:
description: Batched ingestion for Langfuse Tracing
description: >-
Batched ingestion for Langfuse Tracing. If you want to use tracing via
the API, such as to build your own Langfuse client implementation, this
is the only API route you need to implement.
operationId: ingestion_batch
tags:
- Ingestion
@@ -446,6 +449,14 @@ paths:
type: array
items:
$ref: '#/components/schemas/IngestionEvent'
description: >-
Batch of tracing events to be ingested. Discriminated by
attribute `type`.
metadata:
nullable: true
description: >-
Optional. Metadata field used by the Langfuse SDKs for
debugging.
required:
- batch
/api/public/metrics/daily:
@@ -997,9 +1008,10 @@ paths:
- name: source
in: query
description: Retrieve only scores from a specific source.
required: true
required: false
schema:
$ref: '#/components/schemas/ScoreSource'
nullable: true
- name: operator
in: query
description: Retrieve only scores with <operator> value.
@@ -1015,6 +1027,13 @@ paths:
type: number
format: double
nullable: true
- name: scoreIds
in: query
description: Comma-separated list of score IDs to limit the results to.
required: false
schema:
type: string
nullable: true
responses:
'200':
description: ''
@@ -1964,8 +1983,19 @@ components:
type:
type: string
enum:
- event-create
- $ref: '#/components/schemas/CreateEventEvent'
- span-create
- $ref: '#/components/schemas/CreateSpanEvent'
required:
- type
- type: object
allOf:
- type: object
properties:
type:
type: string
enum:
- span-update
- $ref: '#/components/schemas/UpdateSpanEvent'
required:
- type
- type: object
@@ -1997,19 +2027,8 @@ components:
type:
type: string
enum:
- span-create
- $ref: '#/components/schemas/CreateSpanEvent'
required:
- type
- type: object
allOf:
- type: object
properties:
type:
type: string
enum:
- span-update
- $ref: '#/components/schemas/UpdateSpanEvent'
- event-create
- $ref: '#/components/schemas/CreateEventEvent'
required:
- type
- type: object
@@ -2332,13 +2351,20 @@ components:
properties:
id:
type: string
description: UUID v4 that identifies the event
timestamp:
type: string
metadata: {}
description: >-
Datetime (ISO 8601) of event creation in client. Should be as close
to actual event creation in client as possible, this timestamp will
be used for ordering of events in future release. Resolution:
milliseconds (required), microseconds (optimal).
metadata:
nullable: true
description: Optional. Metadata field used by the Langfuse SDKs for debugging.
required:
- id
- timestamp
- metadata
TraceEvent:
title: TraceEvent
type: object
+8 -3
View File
@@ -337,7 +337,7 @@
"_type": "endpoint",
"name": "Batch",
"request": {
"description": "Batched ingestion for Langfuse Tracing",
"description": "Batched ingestion for Langfuse Tracing. If you want to use tracing via the API, such as to build your own Langfuse client implementation, this is the only API route you need to implement.",
"url": {
"raw": "{{baseUrl}}/api/public/ingestion",
"host": [
@@ -356,7 +356,7 @@
"auth": null,
"body": {
"mode": "raw",
"raw": "{\n \"batch\": [\n {\n \"type\": \"trace-create\",\n \"body\": {\n \"id\": \"example\",\n \"timestamp\": \"1994-11-05T13:15:30Z\",\n \"name\": \"example\",\n \"userId\": \"example\",\n \"input\": \"UNKNOWN\",\n \"output\": \"UNKNOWN\",\n \"sessionId\": \"example\",\n \"release\": \"example\",\n \"version\": \"example\",\n \"metadata\": \"UNKNOWN\",\n \"tags\": [\n \"example\"\n ],\n \"public\": true\n },\n \"id\": \"example\",\n \"timestamp\": \"example\",\n \"metadata\": \"UNKNOWN\"\n }\n ]\n}",
"raw": "{\n \"batch\": [\n {\n \"type\": \"trace-create\",\n \"body\": {\n \"id\": \"example\",\n \"timestamp\": \"1994-11-05T13:15:30Z\",\n \"name\": \"example\",\n \"userId\": \"example\",\n \"input\": \"UNKNOWN\",\n \"output\": \"UNKNOWN\",\n \"sessionId\": \"example\",\n \"release\": \"example\",\n \"version\": \"example\",\n \"metadata\": \"UNKNOWN\",\n \"tags\": [\n \"example\"\n ],\n \"public\": true\n },\n \"id\": \"example\",\n \"timestamp\": \"example\",\n \"metadata\": \"UNKNOWN\"\n }\n ],\n \"metadata\": \"UNKNOWN\"\n}",
"options": {
"raw": {
"language": "json"
@@ -755,7 +755,7 @@
"request": {
"description": "Get a list of scores",
"url": {
"raw": "{{baseUrl}}/api/public/scores?page=&limit=&userId=&name=&fromTimestamp=&source=&operator=&value=",
"raw": "{{baseUrl}}/api/public/scores?page=&limit=&userId=&name=&fromTimestamp=&source=&operator=&value=&scoreIds=",
"host": [
"{{baseUrl}}"
],
@@ -804,6 +804,11 @@
"key": "value",
"value": "",
"description": "Retrieve only scores with <operator> value."
},
{
"key": "scoreIds",
"value": "",
"description": "Comma-separated list of score IDs to limit the results to."
}
],
"variable": []
+1 -1
View File
@@ -4,7 +4,7 @@ if (process.env.NEXT_PUBLIC_SENTRY_DSN)
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Replay may only be enabled for the client-side
integrations: [new Sentry.Replay()],
integrations: [Sentry.replayIntegration()],
// Set tracesSampleRate to 1.0 to capture 100%
// of transactions for performance monitoring.
+7 -12
View File
@@ -1,8 +1,6 @@
import { prisma } from "@langfuse/shared/src/db";
import * as Sentry from "@sentry/nextjs";
import { ProfilingIntegration } from "@sentry/profiling-node";
import type { SamplingContext, TransactionEvent } from "@sentry/types";
import { jsonSchema } from "@/src/utils/zod";
import { jsonSchema } from "@langfuse/shared";
import { nodeProfilingIntegration } from "@sentry/profiling-node";
if (process.env.NEXT_PUBLIC_SENTRY_DSN)
Sentry.init({
@@ -12,7 +10,7 @@ if (process.env.NEXT_PUBLIC_SENTRY_DSN)
// Set tracesSampleRate to 1.0 to capture 100%
// of transactions for performance monitoring.
// We recommend adjusting this value in production
tracesSampler: (samplingContext: SamplingContext) => {
tracesSampler: (samplingContext) => {
if (
samplingContext.request &&
samplingContext.request.url &&
@@ -23,9 +21,7 @@ if (process.env.NEXT_PUBLIC_SENTRY_DSN)
if (
samplingContext.request &&
samplingContext.request.url &&
samplingContext.request.url.includes("api/auth") &&
samplingContext.transactionContext.status !== "ok" &&
samplingContext.transactionContext.status !== "unauthenticated"
samplingContext.request.url.includes("api/auth")
) {
return 0.1;
}
@@ -35,14 +31,13 @@ if (process.env.NEXT_PUBLIC_SENTRY_DSN)
profilesSampleRate: 0.1,
integrations: [
// Add profiling integration to list of integrations
new ProfilingIntegration(),
new Sentry.Integrations.Prisma({ client: prisma }),
Sentry.metrics.metricsAggregatorIntegration(),
Sentry.prismaIntegration(),
nodeProfilingIntegration(),
],
// filter out passwords from the signup request body
// transaction events are sentry transactions which include logs and spans.
beforeSendTransaction(transaction: TransactionEvent) {
beforeSendTransaction(transaction) {
if (
transaction.request &&
typeof transaction.request.data === "string" &&
+38 -9
View File
@@ -3,7 +3,7 @@
import type { Session } from "next-auth";
import { pruneDatabase } from "@/src/__tests__/test-utils";
import { ModelProvider } from "@langfuse/shared";
import { LLMAdapter } from "@langfuse/shared";
import { prisma } from "@langfuse/shared/src/db";
import { appRouter } from "@/src/server/api/root";
import { createInnerTRPCContext } from "@/src/server/api/trpc";
@@ -38,11 +38,20 @@ describe("llmApiKey.all RPC", () => {
it("should create an llm api key", async () => {
const secret = "test-secret";
const provider = "openai";
const adapter = LLMAdapter.OpenAI;
const customModels = ["fancy-gpt-3.5-turbo"];
const baseURL = "https://custom.openai.com/v1";
const withDefaultModels = false;
await caller.llmApiKey.create({
projectId,
secretKey: secret,
provider: ModelProvider.OpenAI,
provider,
adapter,
baseURL,
customModels,
withDefaultModels,
});
const llmApiKeys = await prisma.llmApiKeys.findMany({
@@ -55,18 +64,31 @@ describe("llmApiKey.all RPC", () => {
expect(llmApiKeys[0].projectId).toBe(projectId);
expect(llmApiKeys[0].secretKey).not.toBeNull();
expect(llmApiKeys[0].secretKey).not.toEqual(secret);
expect(llmApiKeys[0].provider).toBe(ModelProvider.OpenAI);
expect(llmApiKeys[0].provider).toBe(provider);
expect(llmApiKeys[0].adapter).toBe(adapter);
expect(llmApiKeys[0].baseURL).toBe(baseURL);
expect(llmApiKeys[0].customModels).toEqual(customModels);
expect(llmApiKeys[0].withDefaultModels).toBe(withDefaultModels);
// this has to be 3 dots and the last 4 characters of the secret
expect(llmApiKeys[0].displaySecretKey).toMatch(/^...[a-zA-Z0-9]{4}$/);
});
it("should create and get an llm api key", async () => {
const secret = "test-secret";
const provider = "openai";
const adapter = LLMAdapter.OpenAI;
const customModels = ["fancy-gpt-3.5-turbo"];
const baseURL = "https://custom.openai.com/v1";
const withDefaultModels = false;
await caller.llmApiKey.create({
projectId,
secretKey: secret,
provider: ModelProvider.OpenAI,
provider,
adapter,
baseURL,
customModels,
withDefaultModels,
});
const dbLlmApiKeys = await prisma.llmApiKeys.findMany({
@@ -77,16 +99,23 @@ describe("llmApiKey.all RPC", () => {
expect(dbLlmApiKeys.length).toBe(1);
const llmApiKeys = await caller.llmApiKey.all({
const { data: llmApiKeys } = await caller.llmApiKey.all({
projectId,
});
expect(llmApiKeys.data.length).toBe(1);
expect(llmApiKeys.data[0].provider).toBe(ModelProvider.OpenAI);
expect(llmApiKeys.length).toBe(1);
expect(llmApiKeys[0].projectId).toBe(projectId);
expect(llmApiKeys[0].secretKey).not.toBeNull();
expect(llmApiKeys[0].secretKey).not.toEqual(secret);
expect(llmApiKeys[0].provider).toBe(provider);
expect(llmApiKeys[0].adapter).toBe(adapter);
expect(llmApiKeys[0].baseURL).toBe(baseURL);
expect(llmApiKeys[0].customModels).toEqual(customModels);
expect(llmApiKeys[0].withDefaultModels).toBe(withDefaultModels);
// this has to be 3 dots and the last 4 characters of the secret
expect(llmApiKeys.data[0].displaySecretKey).toMatch(/^...[a-zA-Z0-9]{4}$/);
expect(llmApiKeys[0].displaySecretKey).toMatch(/^...[a-zA-Z0-9]{4}$/);
// response must not contain the secret key itself
expect(llmApiKeys.data[0]).not.toHaveProperty("secretKey");
expect(llmApiKeys[0]).not.toHaveProperty("secretKey");
});
});
+32
View File
@@ -591,5 +591,37 @@ describe("/api/public/scores API Endpoint", () => {
message: "Invalid request data",
});
});
it("should filter scores by score IDs", async () => {
const getScore = await makeAPICall<{
data: [
{
id: string;
name: string;
value: number;
},
];
meta: object;
}>("GET", `/api/public/scores?scoreIds=${scoreId_1},${scoreId_2}`);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 2,
totalPages: 1,
});
expect(getScore.body.data).toMatchObject([
{
id: scoreId_2,
name: scoreName,
value: 50.5,
},
{
id: scoreId_1,
name: scoreName,
value: 10.5,
},
]);
});
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { paginationZod } from "@/src/utils/zod";
import { paginationZod } from "@langfuse/shared";
import { ZodError } from "zod";
// Create test cases
+1 -1
View File
@@ -1,6 +1,6 @@
import chatCompletionHandler from "@/src/ee/features/playground/server/chatCompletionHandler";
export const dynamic = "force-dynamic";
export const maxDuration = 60;
export const maxDuration = 120;
export const POST = chatCompletionHandler;
+119 -13
View File
@@ -1,8 +1,124 @@
import { AlertTriangle, Check } from "lucide-react";
import {
AlertTriangle,
BadgeCheck,
Check,
Github,
HardDriveDownload,
Map,
Newspaper,
} from "lucide-react";
import { VERSION } from "@/src/constants";
import { env } from "@/src/env.mjs";
import { cn } from "@/src/utils/tailwind";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from "@/src/components/ui/dropdown-menu";
import { ArrowUp } from "lucide-react";
import { api } from "@/src/utils/api";
import { Button } from "@/src/components/ui/button";
import Link from "next/link";
import { useIsEeEnabled } from "@/src/ee/utils/useIsEeEnabled";
const VersionLabel = ({ className }: { className?: string }) => {
const checkUpdate = api.public.checkUpdate.useQuery(undefined, {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
enabled: !env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION, // do not check for updates on Langfuse Cloud
onError: (error) => console.error("checkUpdate error", error), // do not render default error message
});
const isEeVersion =
useIsEeEnabled() && !Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
const hasUpdate =
!env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION &&
checkUpdate.data &&
checkUpdate.data.updateType;
const color =
checkUpdate.data?.updateType === "major"
? "text-dark-red"
: checkUpdate.data?.updateType === "minor"
? "text-dark-yellow"
: undefined;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="xs" className={className}>
{VERSION}
{hasUpdate && <ArrowUp className={`ml-1 h-3 w-3 ${color}`} />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{hasUpdate ? (
<>
<DropdownMenuLabel>
New {checkUpdate.data?.updateType} version:{" "}
{checkUpdate.data?.latestRelease}
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
) : !env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION ? (
<>
<DropdownMenuLabel>This is the latest release</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
) : null}
{isEeVersion && (
<>
<DropdownMenuLabel className="flex items-center font-normal">
<BadgeCheck size={16} className="mr-2" />
Enterprise Edition
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem asChild>
<Link
href="https://github.com/langfuse/langfuse/releases"
target="_blank"
>
<Github size={16} className="mr-2" />
Releases
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="https://langfuse.com/changelog" target="_blank">
<Newspaper size={16} className="mr-2" />
Changelog
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="https://langfuse.com/roadmap" target="_blank">
<Map size={16} className="mr-2" />
Roadmap
</Link>
</DropdownMenuItem>
{hasUpdate && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href="https://langfuse.com/docs/deployment/self-host#update"
target="_blank"
>
<HardDriveDownload size={16} className="mr-2" />
Update
</Link>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
};
export const LangfuseIcon = ({
size = 32,
@@ -65,22 +181,12 @@ export const LangfuseLogo = ({
<span
className={cn(
"ml-2 font-mono font-semibold",
size === "sm" ? "text-sm" : "text-xl",
size === "sm" ? "text-sm" : "text-lg",
)}
>
Langfuse
</span>
{version && (
<a
href="https://github.com/langfuse/langfuse/releases"
target="_blank"
rel="noopener"
title="View releases on GitHub"
className="ml-2 text-xs text-muted-foreground"
>
{VERSION}
</a>
)}
{version && <VersionLabel className="ml-2" />}
</div>
</div>
);
@@ -3,7 +3,7 @@ import Link from "next/link";
import { Label } from "@/src/components/ui/label";
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
import { api } from "@/src/utils/api";
import { supportedModels, type UIModelParams } from "@langfuse/shared";
import { type UIModelParams } from "@langfuse/shared";
import { ArrowTopRightIcon } from "@radix-ui/react-icons";
export const LLMApiKeyComponent = (p: {
@@ -39,11 +39,7 @@ export const LLMApiKeyComponent = (p: {
);
}
const model = p.modelParams.model.value;
const modelProvider = Object.entries(supportedModels).find((providerData) =>
(providerData[1] as any as string[]).includes(model),
)?.[0];
const modelProvider = p.modelParams.provider.value;
const apiKey = apiKeys.data?.data.find((k) => k.provider === modelProvider);
return (
@@ -51,9 +47,11 @@ export const LLMApiKeyComponent = (p: {
<Label className="text-xs font-semibold">API key</Label>
<div>
{apiKey ? (
<span className="mr-2 rounded-sm bg-input p-1 text-xs">
{apiKey.displaySecretKey}
</span>
<Link href={`/project/${p.projectId}/settings#llm-api-keys`}>
<span className="mr-2 rounded-sm bg-input p-1 text-xs">
{apiKey.displaySecretKey}
</span>
</Link>
) : undefined}
</div>
{/* Custom form message to include a link to the already existing prompt */}
+72 -74
View File
@@ -8,11 +8,12 @@ import {
} from "@/src/components/ui/select";
import { Slider } from "@/src/components/ui/slider";
import { Switch } from "@/src/components/ui/switch";
import { CreateLLMApiKeyDialog } from "@/src/features/public-api/components/CreateLLMApiKeyDialog";
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
import { cn } from "@/src/utils/tailwind";
import {
ModelProvider,
supportedModels,
type LLMAdapter,
type supportedModels,
type UIModelParams,
} from "@langfuse/shared";
@@ -20,7 +21,8 @@ import { LLMApiKeyComponent } from "./LLMApiKeyComponent";
export type ModelParamsContext = {
modelParams: UIModelParams;
availableModels?: UIModelParams[];
availableProviders: string[];
availableModels: string[];
updateModelParamValue: <Key extends keyof UIModelParams>(
key: Key,
value: UIModelParams[Key]["value"],
@@ -31,6 +33,7 @@ export type ModelParamsContext = {
export const ModelParameters: React.FC<ModelParamsContext> = ({
modelParams,
availableProviders,
availableModels,
updateModelParamValue,
setModelParamEnabled,
@@ -43,76 +46,71 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
return (
<div className="flex flex-col space-y-4">
<p className="font-semibold">Model</p>
<div className="space-y-4">
<ModelParamsSelect
title="Provider"
modelParamsKey="provider"
disabled={formDisabled}
value={modelParams.provider.value}
options={
availableModels
? [...new Set(availableModels.map((m) => m.provider.value))]
: Object.values(ModelProvider)
}
updateModelParam={updateModelParamValue}
/>
<ModelParamsSelect
title="Model name"
modelParamsKey="model"
disabled={formDisabled}
value={modelParams.model.value}
options={Object.values(
availableModels
? availableModels
.filter(
(m) => m.provider.value === modelParams.provider.value,
)
.map((m) => m.model.value)
: supportedModels[modelParams.provider.value],
)}
updateModelParam={updateModelParamValue}
/>
<ModelParamsSlider
title="Temperature"
modelParamsKey="temperature"
formDisabled={formDisabled}
enabled={modelParams.temperature.enabled}
setModelParamEnabled={setModelParamEnabled}
value={modelParams.temperature.value}
min={0}
max={modelParams.maxTemperature.value}
step={0.01}
tooltip="The sampling temperature. Higher values will make the output more random, while lower values will make it more focused and deterministic."
updateModelParam={updateModelParamValue}
/>
<ModelParamsSlider
title="Output token limit"
modelParamsKey="max_tokens"
formDisabled={formDisabled}
enabled={modelParams.max_tokens.enabled}
setModelParamEnabled={setModelParamEnabled}
value={modelParams.max_tokens.value}
min={1}
max={4096}
step={1}
tooltip="The maximum number of tokens that can be generated in the chat completion."
updateModelParam={updateModelParamValue}
/>
<ModelParamsSlider
title="Top P"
modelParamsKey="top_p"
formDisabled={formDisabled}
enabled={modelParams.top_p.enabled}
setModelParamEnabled={setModelParamEnabled}
value={modelParams.top_p.value}
min={0}
max={1}
step={0.01}
tooltip="An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We generally recommend altering this or temperature but not both."
updateModelParam={updateModelParamValue}
/>
<LLMApiKeyComponent {...{ projectId, modelParams }} />
</div>
{availableProviders.length === 0 ? (
<>
<p className="text-sm">No LLM API key set in project.</p>
<CreateLLMApiKeyDialog />
</>
) : (
<div className="space-y-4">
<ModelParamsSelect
title="Provider"
modelParamsKey="provider"
disabled={formDisabled}
value={modelParams.provider.value}
options={availableProviders}
updateModelParam={updateModelParamValue}
/>
<ModelParamsSelect
title="Model name"
modelParamsKey="model"
disabled={formDisabled}
value={modelParams.model.value}
options={availableModels}
updateModelParam={updateModelParamValue}
/>
<ModelParamsSlider
title="Temperature"
modelParamsKey="temperature"
formDisabled={formDisabled}
enabled={modelParams.temperature.enabled}
setModelParamEnabled={setModelParamEnabled}
value={modelParams.temperature.value}
min={0}
max={modelParams.maxTemperature.value}
step={0.01}
tooltip="The sampling temperature. Higher values will make the output more random, while lower values will make it more focused and deterministic."
updateModelParam={updateModelParamValue}
/>
<ModelParamsSlider
title="Output token limit"
modelParamsKey="max_tokens"
formDisabled={formDisabled}
enabled={modelParams.max_tokens.enabled}
setModelParamEnabled={setModelParamEnabled}
value={modelParams.max_tokens.value}
min={1}
max={4096}
step={1}
tooltip="The maximum number of tokens that can be generated in the chat completion."
updateModelParam={updateModelParamValue}
/>
<ModelParamsSlider
title="Top P"
modelParamsKey="top_p"
formDisabled={formDisabled}
enabled={modelParams.top_p.enabled}
setModelParamEnabled={setModelParamEnabled}
value={modelParams.top_p.value}
min={0}
max={1}
step={0.01}
tooltip="An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We generally recommend altering this or temperature but not both."
updateModelParam={updateModelParamValue}
/>
<LLMApiKeyComponent {...{ projectId, modelParams }} />
</div>
)}
</div>
);
};
@@ -148,7 +146,7 @@ const ModelParamsSelect = ({
onValueChange={(value) =>
updateModelParam(
modelParamsKey,
value as (typeof supportedModels)[ModelProvider][number],
value as (typeof supportedModels)[LLMAdapter][number],
)
}
value={value}
+1 -1
View File
@@ -67,7 +67,7 @@ const supportChannels = [
title: "Slack Connect",
description: "Get a dedicated support channel for you and your team.",
href: "mailto:help@langfuse.com?subject=Slack%20Connect%20Request&body=I'd%20like%20to%20request%20a%20dedicated%20Slack%20Connect%20channel%20for%20me%20and%20my%20team.%0D%0A%0D%0AUsers%20(emails)%20to%20include%20besides%20mine%3A%0D%0A%0D%0A",
buttonText: "Request Slack Connect (via Email)",
buttonText: "Request via Email",
},
];
+1 -4
View File
@@ -53,16 +53,13 @@ export function DeleteButton({
},
});
if (!hasAccess) {
return null;
}
return (
<Popover key={itemId}>
<PopoverTrigger asChild>
<Button
variant={isTableAction ? "ghost" : "outline"}
size={isTableAction ? "xs" : "icon"}
disabled={!hasAccess}
onClick={() =>
type === "trace"
? capture("trace:delete_form_open", {
+21 -9
View File
@@ -32,6 +32,7 @@ import useLocalStorage from "@/src/components/useLocalStorage";
import { ProjectNavigation } from "@/src/components/projectNavigation";
import DOMPurify from "dompurify";
import { ThemeToggle } from "@/src/features/theming/ThemeToggle";
import { useIsEeEnabled } from "@/src/ee/utils/useIsEeEnabled";
const signOutUser = async () => {
localStorage.clear();
@@ -76,6 +77,7 @@ export default function Layout(props: PropsWithChildren) {
session.data?.environment.enableExperimentalFeatures ?? false;
const projectId = router.query.projectId as string | undefined;
const isEeEnabled = useIsEeEnabled();
const mapNavigation = (route: Route): NavigationItem | null => {
// Project-level routes
@@ -91,11 +93,14 @@ export default function Layout(props: PropsWithChildren) {
)
return null;
// cloud only
// check ee or cloud requirements
if (
route.cloudOnly !== undefined &&
// the feature should be available in local development
route.cloudOnly !== (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined)
route.requires !== undefined &&
!(
(route.requires === "cloud" &&
Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION)) ||
(route.requires === "cloud-or-ee" && isEeEnabled)
)
)
return null;
@@ -147,6 +152,7 @@ export default function Layout(props: PropsWithChildren) {
!publishablePaths.includes(router.pathname) &&
!router.pathname.startsWith("/public/")
) {
console.warn("Layout: User was signed out as db user was not found");
signOutUser();
return <Spinner message="Redirecting" />;
@@ -521,9 +527,9 @@ const MainNavigation: React.FC<{
onNavitemClick?: () => void;
className?: string;
}> = ({ nav, onNavitemClick, className }) => {
const [isOpen, setIsOpen] = useLocalStorage(
"sidebar-tracing-default-open",
false,
const [isOpen, setIsOpen] = useLocalStorage<Record<string, boolean>>(
"sidebar-item-default-open",
{},
);
return (
@@ -572,14 +578,20 @@ const MainNavigation: React.FC<{
<Disclosure
as="div"
defaultOpen={
item.children.some((child) => child.current) || isOpen
item.children.some((child) => child.current) ||
isOpen[item.name]
}
>
{({ open }) => (
<>
<Disclosure.Button
className="group flex w-full items-center gap-x-3 rounded-md p-2 text-left text-sm font-semibold hover:bg-primary-foreground hover:text-primary-accent"
onClick={() => setIsOpen(!isOpen)}
onClick={() =>
setIsOpen((prev) => ({
...prev,
[item.name]: !prev[item.name],
}))
}
>
{item.icon && (
<item.icon
+6 -6
View File
@@ -24,7 +24,7 @@ export type Route = {
children?: Array<Route>; // folder
bottom?: boolean; // bottom of the sidebar, only for first level routes
newTab?: boolean; // open in new tab
cloudOnly?: boolean; // only available in cloud
requires?: "cloud" | "cloud-or-ee"; // feature requires cloud or ee
};
export const ROUTES: Route[] = [
@@ -62,25 +62,25 @@ export const ROUTES: Route[] = [
{
name: "Evaluation",
icon: Lightbulb,
cloudOnly: true,
requires: "cloud",
label: "Beta",
children: [
{
name: "Templates",
pathname: `/project/[projectId]/evals/templates`,
cloudOnly: true,
requires: "cloud",
rbacScope: "evalTemplate:read",
},
{
name: "Configs",
pathname: `/project/[projectId]/evals/configs`,
cloudOnly: true,
requires: "cloud",
rbacScope: "evalJob:read",
},
{
name: "Log",
pathname: `/project/[projectId]/evals/log`,
cloudOnly: true,
requires: "cloud",
rbacScope: "evalJobExecution:read",
},
],
@@ -100,7 +100,7 @@ export const ROUTES: Route[] = [
name: "Playground",
pathname: "/project/[projectId]/playground",
icon: TerminalIcon,
cloudOnly: true,
requires: "cloud-or-ee",
label: "Beta",
},
{
+49 -117
View File
@@ -12,33 +12,19 @@ import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context
import { api } from "@/src/utils/api";
import { usdFormatter } from "@/src/utils/numbers";
import Link from "next/link";
import { useEffect, useRef } from "react";
import { useWindowVirtualizer } from "@tanstack/react-virtual";
import { type RouterOutput } from "@/src/utils/types";
import {
DataTableRowHeightSwitch,
type RowHeight,
useRowHeightLocalStorage,
} from "@/src/components/table/data-table-row-height-switch";
import { ScrollArea } from "@/src/components/ui/scroll-area";
import { useEffect, useState } from "react";
import { AnnotateButton } from "@/src/features/manual-scoring/components/AnnotateButton";
import { Button } from "@/src/components/ui/button";
// do not use the usual table row heights here
const rowHeightMapping: Record<RowHeight, number> = {
s: 200,
m: 350,
l: 700,
};
// some projects have thousands of traces in a sessions, paginate to avoid rendering all at once
const PAGE_SIZE = 50;
export const SessionPage: React.FC<{
sessionId: string;
projectId: string;
}> = ({ sessionId, projectId }) => {
const { setDetailPageList } = useDetailPageLists();
const [rowHeight, setRowHeight] = useRowHeightLocalStorage(
"single-session",
"m",
);
const [visibleTraces, setVisibleTraces] = useState(PAGE_SIZE);
const session = api.sessions.byId.useQuery(
{
sessionId,
@@ -96,11 +82,6 @@ export const SessionPage: React.FC<{
}
listKey="sessions"
/>,
<DataTableRowHeightSwitch
rowHeight={rowHeight}
setRowHeight={setRowHeight}
key="height"
/>,
]}
/>
<div className="flex flex-wrap gap-2">
@@ -121,97 +102,49 @@ export const SessionPage: React.FC<{
</Badge>
)}
</div>
{session.data && (
<TraceCardList
session={session.data}
projectId={projectId}
rowHeight={rowHeightMapping[rowHeight]}
/>
)}
</div>
);
};
const TraceCardList = ({
session,
projectId,
rowHeight,
}: {
session: RouterOutput["sessions"]["byId"];
projectId: string;
rowHeight: number;
}) => {
const listVirtualizationRef = useRef<HTMLDivElement | null>(null);
const virtualizer = useWindowVirtualizer({
count: session.traces.length,
estimateSize: () => rowHeight,
overscan: 5,
scrollMargin: listVirtualizationRef.current?.offsetTop ?? 0,
gap: 10,
});
useEffect(() => {
// re-measure when rowHeight changes to update the virtualizer
virtualizer.measure();
}, [rowHeight, virtualizer]);
return (
<div className="mt-5 border-t pt-5">
<div
ref={listVirtualizationRef}
style={{
height: `${virtualizer.getTotalSize()}px`,
position: "relative",
}}
>
{virtualizer
.getVirtualItems()
.map((virtualItem) => ({
virtualItem,
trace: session.traces[virtualItem.index],
}))
.map(({ virtualItem, trace }) => (
<Card
className="group grid w-full gap-3 overflow-hidden border-border p-2 shadow-none hover:border-ring md:grid-cols-3"
key={virtualItem.key}
data-index={virtualItem.index}
style={{
position: "absolute",
top: 0,
left: 0,
height: `${rowHeight}px`,
transform: `translateY(${virtualItem.start - virtualizer.options.scrollMargin}px)`,
}}
>
<ScrollArea className="col-span-2 pr-3">
<SessionIO traceId={trace.id} />
</ScrollArea>
<ScrollArea>
<div className="-mt-1 overflow-y-auto p-1 opacity-50 transition-opacity group-hover:opacity-100">
<Link
href={`/project/${projectId}/traces/${trace.id}`}
className="text-xs hover:underline"
>
Trace: {trace.name} ({trace.id})&nbsp;
</Link>
<div className="text-xs text-muted-foreground">
{trace.timestamp.toLocaleString()}
</div>
<div className="mb-1 mt-2 text-xs text-muted-foreground">
Scores
</div>
<div className="flex flex-wrap content-start items-start gap-1">
<GroupedScoreBadges scores={trace.scores} />
</div>
<AnnotateButton
projectId={projectId}
traceId={trace.id}
scores={trace.scores}
variant="badge"
/>
</div>
</ScrollArea>
</Card>
))}
<div className="mt-5 flex flex-col gap-2 border-t pt-5">
{session.data?.traces.slice(0, visibleTraces).map((trace) => (
<Card
className="group grid gap-3 border-border p-2 shadow-none hover:border-ring md:grid-cols-3"
key={trace.id}
>
<SessionIO traceId={trace.id} />
<div className="-mt-1 p-1 opacity-50 transition-opacity group-hover:opacity-100">
<Link
href={`/project/${projectId}/traces/${trace.id}`}
className="text-xs hover:underline"
>
Trace: {trace.name} ({trace.id})&nbsp;
</Link>
<div className="text-xs text-muted-foreground">
{trace.timestamp.toLocaleString()}
</div>
<div className="mb-1 mt-2 text-xs text-muted-foreground">
Scores
</div>
<div className="mb-1 flex flex-wrap content-start items-start gap-1">
<GroupedScoreBadges scores={trace.scores} />
</div>
<AnnotateButton
projectId={projectId}
traceId={trace.id}
scores={trace.scores}
variant="badge"
type="session"
source="SessionDetail"
/>
</div>
</Card>
))}
{session.data?.traces && session.data.traces.length > visibleTraces && (
<Button
onClick={() => setVisibleTraces((prev) => prev + PAGE_SIZE)}
variant="ghost"
className="self-center"
>
{`Load ${Math.min(session.data.traces.length - visibleTraces, PAGE_SIZE)} More`}
</Button>
)}
</div>
</div>
);
@@ -230,9 +163,8 @@ const SessionIO = ({ traceId }: { traceId: string }) => {
refetchOnMount: false, // prevents refetching loops
},
);
return (
<div className="flex flex-col gap-2 overflow-x-hidden overflow-y-scroll 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"
+54 -29
View File
@@ -1,6 +1,12 @@
import { DataTable } from "@/src/components/table/data-table";
import { type LangfuseColumnDef } from "@/src/components/table/types";
import { Button } from "@/src/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/src/components/ui/popover";
import { useState } from "react";
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
@@ -10,6 +16,7 @@ import { type Prisma, type Model } from "@langfuse/shared/src/db";
import Decimal from "decimal.js";
import { Trash } from "lucide-react";
import { useQueryParams, withDefault, NumberParam } from "use-query-params";
import { cn } from "@/src/utils/tailwind";
export type ModelTableRow = {
modelId: string;
@@ -222,13 +229,12 @@ export default function ModelTable({ projectId }: { projectId: string }) {
accessorKey: "actions",
header: "Actions",
cell: ({ row }) => {
return row.original.maintainer === "User" ? (
return (
<DeleteModelButton
projectId={projectId}
modelId={row.original.modelId}
isBuiltIn={row.original.maintainer === "Langfuse"}
/>
) : (
<div className="h-6" />
);
},
},
@@ -287,10 +293,13 @@ export default function ModelTable({ projectId }: { projectId: string }) {
const DeleteModelButton = ({
modelId,
projectId,
isBuiltIn,
}: {
modelId: string;
projectId: string;
isBuiltIn?: boolean;
}) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const capture = usePostHogClientCapture();
const mut = api.models.delete.useMutation({
@@ -304,32 +313,48 @@ const DeleteModelButton = ({
scope: "models:CUD",
});
if (!hasAccess) {
return null;
}
return (
<Button
size="xs"
variant="ghost"
onClick={() => {
const confirmDelete = window.confirm(
"Are you sure you want to delete this model?",
);
if (confirmDelete) {
capture("models:delete_button_click");
mut
.mutateAsync({
projectId,
modelId,
})
.catch((error) => {
console.error(error);
});
}
}}
>
<Trash size={14} />
</Button>
<Popover open={isOpen} onOpenChange={() => setIsOpen(!isOpen)}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="xs"
disabled={!hasAccess || isBuiltIn}
title={
isBuiltIn ? "Built-in models cannot be deleted" : "Delete model"
}
className={cn(
isBuiltIn &&
"disabled:pointer-events-auto disabled:cursor-not-allowed",
)}
>
<Trash className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent>
<h2 className="text-md mb-3 font-semibold">Please confirm</h2>
<p className="mb-3 text-sm">
This action permanently deletes this model definition.
</p>
<div className="flex justify-end space-x-4">
<Button
type="button"
variant="destructive"
loading={mut.isLoading}
onClick={() => {
capture("models:delete_button_click");
mut.mutateAsync({
projectId,
modelId,
});
setIsOpen(false);
}}
>
Delete Model
</Button>
</div>
</PopoverContent>
</Popover>
);
};
@@ -0,0 +1,46 @@
export function joinTableCoreAndMetrics<
Core extends { id: string },
Metric extends { id: string },
>(
userCoreData?: Core[],
userMetricsData?: Metric[],
): {
status: "loading" | "error" | "success";
rows: (Core & Partial<Metric>)[] | undefined;
} {
if (!userCoreData) {
return { status: "error", rows: undefined };
}
const userCoreDataProcessed = userCoreData;
if (!userMetricsData) {
// create an object with all the keys of the UserMetrics type with undefined value
return {
status: "success",
rows: userCoreDataProcessed.map((u) => ({
...u,
...({} as Partial<Metric>),
})),
};
}
const metricsById = userMetricsData.reduce<Record<string, Metric>>(
(acc, metric) => {
acc[metric.id] = metric;
return acc;
},
{},
);
const joinedData = userCoreDataProcessed.map((userCore) => {
const metrics = metricsById[userCore.id];
return {
...userCore,
...(metrics ?? ({} as Partial<Metric>)),
};
});
return { status: "success", rows: joinedData };
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
import { z } from "zod";
import { deepParseJson } from "@/src/utils/json";
import { deepParseJson } from "@langfuse/shared";
import { cn } from "@/src/utils/tailwind";
import { useState } from "react";
import { Button } from "@/src/components/ui/button";
@@ -159,6 +159,7 @@ export const ObservationPreview = (props: {
traceId={preloadedObservation.traceId}
observationId={preloadedObservation.id}
scores={props.scores}
type="observation"
/>
{observationWithInputAndOutput.data?.type === "GENERATION" && (
<JumpToPlaygroundButton
@@ -237,7 +238,7 @@ const PromptBadge = (props: { promptId: string; projectId: string }) => {
if (prompt.isLoading || !prompt.data) return null;
return (
<Link
href={`/project/${props.projectId}/prompts/${prompt.data.name}?version=${prompt.data.version}`}
href={`/project/${props.projectId}/prompts/${encodeURIComponent(prompt.data.name)}?version=${prompt.data.version}`}
>
<Badge>
Prompt: {prompt.data.name}
+6 -41
View File
@@ -1,6 +1,6 @@
import { type NestedObservation } from "@/src/utils/types";
import { cn } from "@/src/utils/tailwind";
import { type Trace, type Score, $Enums } from "@langfuse/shared";
import { type Trace, type Score, type $Enums } from "@langfuse/shared";
import { GroupedScoreBadges } from "@/src/components/grouped-score-badge";
import { Fragment } from "react";
import { type ObservationReturnType } from "@/src/server/api/routers/traces";
@@ -10,6 +10,10 @@ import { MinusCircle, MinusIcon, PlusCircleIcon, PlusIcon } from "lucide-react";
import { Toggle } from "@/src/components/ui/toggle";
import { Button } from "@/src/components/ui/button";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import {
nestObservations,
treeItemColors,
} from "@/src/components/trace/lib/helpers";
export const ObservationTree = (props: {
observations: ObservationReturnType[];
@@ -257,53 +261,14 @@ const ObservationTreeNode = (props: {
const ColorCodedObservationType = (props: {
observationType: $Enums.ObservationType;
}) => {
const colors: Record<$Enums.ObservationType, string> = {
[$Enums.ObservationType.SPAN]: "bg-muted-blue",
[$Enums.ObservationType.GENERATION]: "bg-muted-orange",
[$Enums.ObservationType.EVENT]: "bg-muted-green",
};
return (
<span
className={cn(
"self-start rounded-sm p-1 text-xs",
colors[props.observationType],
treeItemColors.get(props.observationType),
)}
>
{props.observationType}
</span>
);
};
export function nestObservations(
list: ObservationReturnType[],
): NestedObservation[] {
if (list.length === 0) return [];
// Step 1: Create a map where the keys are object IDs, and the values are
// the corresponding objects with an added 'children' property.
const map = new Map<string, NestedObservation>();
for (const obj of list) {
map.set(obj.id, { ...obj, children: [] });
}
// Step 2: Create another map for the roots of all trees.
const roots = new Map<string, NestedObservation>();
// Step 3: Populate the 'children' arrays and root map.
for (const obj of map.values()) {
if (obj.parentObservationId) {
const parent = map.get(obj.parentObservationId);
if (parent) {
parent.children.push(obj);
}
} else {
roots.set(obj.id, obj);
}
}
// TODO sum token amounts per level
// Step 4: Return the roots.
return Array.from(roots.values());
}
@@ -0,0 +1,386 @@
import { Card } from "@/src/components/ui/card";
import { type ObservationReturnType } from "@/src/server/api/routers/traces";
import { type Score, type Trace } from "@langfuse/shared";
import React, { useEffect, useRef, useState } from "react";
import { SimpleTreeView } from "@mui/x-tree-view/SimpleTreeView";
import { TreeItem } from "@mui/x-tree-view/TreeItem";
import { MinusIcon, PlusIcon, PanelRightOpen } from "lucide-react";
import { nestObservations } from "@/src/components/trace/lib/helpers";
import { type NestedObservation } from "@/src/utils/types";
import { cn } from "@/src/utils/tailwind";
import { Button } from "@/src/components/ui/button";
import {
type TreeItemType,
treeItemColors,
} from "@/src/components/trace/lib/helpers";
import {
Drawer,
DrawerContent,
DrawerTrigger,
} from "@/src/components/ui/drawer";
import { TracePreview } from "@/src/components/trace/TracePreview";
import { ObservationPreview } from "@/src/components/trace/ObservationPreview";
import useSessionStorage from "@/src/components/useSessionStorage";
// Fixed widths for styling for v1
const SCALE_WIDTH = 800;
const STEP_SIZE = 100;
const CARD_PADDING = 60;
const LABEL_WIDTH = 35;
const MIN_LABEL_WIDTH = 250;
const TREE_INDENTATION = 12; // default in MUI X TreeView
const PREDEFINED_STEP_SIZES = [
0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 2.5, 3, 4, 5, 6, 7, 8, 9, 10,
];
const calculateStepSize = (latency: number, scaleWidth: number) => {
const calculatedStepSize = latency / (scaleWidth / STEP_SIZE);
return (
PREDEFINED_STEP_SIZES.find((step) => step >= calculatedStepSize) ||
PREDEFINED_STEP_SIZES[PREDEFINED_STEP_SIZES.length - 1]
);
};
function TreeItemInner({
latency,
totalScaleSpan,
type,
startOffset = 0,
name,
children,
setBackgroundColor,
level = 0,
cardWidth,
}: {
latency: number;
totalScaleSpan: number;
type: TreeItemType;
startOffset?: number;
name?: string | null;
children?: React.ReactNode;
setBackgroundColor: (color: string) => void;
level?: number;
cardWidth: number;
}) {
const itemWidth = (latency / totalScaleSpan) * SCALE_WIDTH;
const itemOffsetLabelWidth = itemWidth + startOffset + LABEL_WIDTH;
const customLabelWidth = cardWidth - SCALE_WIDTH - CARD_PADDING;
return (
<div className="group my-1 grid w-full min-w-fit grid-cols-[1fr,auto] items-center">
<div
className="flex flex-row items-center gap-2"
style={{
maxWidth: customLabelWidth - level * TREE_INDENTATION,
minWidth: MIN_LABEL_WIDTH - LABEL_WIDTH - level * TREE_INDENTATION,
}}
>
<span
className={cn("rounded-sm p-1 text-xs", treeItemColors.get(type))}
>
{type}
</span>
<span
className="w-fit-content overflow-hidden text-ellipsis whitespace-nowrap break-all text-sm"
title={name ?? undefined}
>
{name}
</span>
<div
className="w-6 flex-1"
onClick={(event) => {
event.stopPropagation();
}}
>
<Drawer
onOpenChange={(open) => setBackgroundColor(open ? "!bg-muted" : "")}
>
<DrawerTrigger asChild>
<Button
className="focus:none active:none hidden justify-start hover:!bg-transparent group-hover:block"
type="button"
size="xs"
variant="ghost"
>
<PanelRightOpen className="h-4 w-4"></PanelRightOpen>
</Button>
</DrawerTrigger>
<DrawerContent className="h-1/2 w-full overflow-hidden md:w-3/5 lg:w-3/5 xl:w-3/5 2xl:w-3/5">
{children}
</DrawerContent>
</Drawer>
</div>
</div>
<div className="flex items-center" style={{ width: `${SCALE_WIDTH}px` }}>
<div className={`relative w-[${SCALE_WIDTH}px]`}>
<div className="ml-4 mr-4 h-full border-r-2"></div>
<div
className={cn(
"flex h-5 items-center justify-end rounded-sm",
itemWidth
? treeItemColors.get(type)
: "border border-dashed bg-muted",
)}
style={{
width: `${itemWidth || 10}px`,
marginLeft: `${startOffset}px`,
}}
>
<span
className={cn(
"hidden justify-end text-xs text-muted-foreground group-hover:block",
itemOffsetLabelWidth > SCALE_WIDTH
? "mr-1"
: !!latency
? "-mr-9"
: "-mr-6",
)}
>
{!!latency ? `${latency.toFixed(2)}s` : "n/a"}
</span>
</div>
</div>
</div>
</div>
);
}
function TraceTreeItem({
observation,
level = 0,
traceStartTime,
totalScaleSpan,
projectId,
scores,
observations,
cardWidth,
}: {
observation: NestedObservation;
level: number;
traceStartTime: Date;
totalScaleSpan: number;
projectId: string;
scores: Score[];
observations: Array<ObservationReturnType>;
cardWidth: number;
}) {
const { startTime, endTime } = observation || {};
const [backgroundColor, setBackgroundColor] = useState("");
const latency = endTime
? (endTime.getTime() - startTime.getTime()) / 1000
: 0;
const startOffset =
((startTime.getTime() - traceStartTime.getTime()) / totalScaleSpan / 1000) *
SCALE_WIDTH;
return (
<TreeItem
classes={{
content: `border-l border-dashed !rounded-none ${backgroundColor} !min-w-fit hover:!bg-muted`,
selected: "!bg-background !important hover:!bg-muted",
label: "!min-w-fit",
}}
key={observation.id}
itemId={observation.id}
label={
<TreeItemInner
latency={latency}
type={observation.type}
name={observation.name}
startOffset={startOffset}
totalScaleSpan={totalScaleSpan}
setBackgroundColor={setBackgroundColor}
level={level}
cardWidth={cardWidth}
>
<>
<h3 className="mb-6 px-8 pt-8 text-2xl font-semibold tracking-tight">
Detail view
</h3>
<div className="overflow-y-auto px-8 pb-8 pt-2">
<ObservationPreview
observations={observations}
scores={scores}
projectId={projectId}
currentObservationId={observation.id}
traceId={observation.traceId}
/>
</div>
</>
</TreeItemInner>
}
>
{Array.isArray(observation.children)
? observation.children.map((child) => (
<TraceTreeItem
key={child.id}
observation={child}
level={level + 1}
traceStartTime={traceStartTime}
totalScaleSpan={totalScaleSpan}
projectId={projectId}
scores={scores}
observations={observations}
cardWidth={cardWidth}
/>
))
: null}
</TreeItem>
);
}
export function TraceTimelineView({
trace,
observations,
projectId,
scores,
}: {
trace: Trace & { latency?: number };
observations: Array<ObservationReturnType>;
projectId: string;
scores: Score[];
}) {
const { latency, name, id } = trace;
const [backgroundColor, setBackgroundColor] = useState("");
const [expandedItems, setExpandedItems] = useSessionStorage<string[]>(
`${trace.id}-expanded`,
[trace.id],
);
const [cardWidth, setCardWidth] = useState(0);
const parentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleResize = () => {
if (parentRef.current) {
const availableWidth = parentRef.current.offsetWidth;
setCardWidth(availableWidth);
}
};
handleResize();
window.addEventListener("resize", handleResize); // Recalculate on window resize
return () => {
window.removeEventListener("resize", handleResize);
};
}, [parentRef]);
if (!latency) return null;
const nestedObservations = nestObservations(observations);
const stepSize = calculateStepSize(latency, SCALE_WIDTH);
const totalScaleSpan = stepSize * (SCALE_WIDTH / STEP_SIZE);
return (
<div ref={parentRef} className="w-full">
<Card
className="flex max-h-[calc(100dvh-24rem)] flex-col overflow-x-auto overflow-y-hidden"
style={{ width: cardWidth }}
>
<div className="grid w-full grid-cols-[1fr,auto] items-center p-2">
<h3
className="p-2 text-2xl font-semibold tracking-tight"
style={{
minWidth: `${MIN_LABEL_WIDTH}px`,
}}
>
Trace Timeline
</h3>
<div
className="relative mr-2 h-4"
style={{ width: `${SCALE_WIDTH}px` }}
>
{Array.from({ length: SCALE_WIDTH / STEP_SIZE + 1 }).map(
(_, index) => {
const step = stepSize * index;
const isLastStep = index === SCALE_WIDTH / STEP_SIZE;
return isLastStep ? (
<span
className="absolute -right-2 text-xs text-muted-foreground"
key={index}
>
{step.toFixed(2)}s
</span>
) : (
<div
key={index}
className="absolute h-full border border-l text-xs"
style={{ left: `${index * STEP_SIZE}px` }}
>
<span className="absolute left-2 text-xs text-muted-foreground">
{step.toFixed(2)}s
</span>
</div>
);
},
)}
</div>
</div>
<div className="min-w-fit overflow-y-auto p-2">
<SimpleTreeView
slots={{
expandIcon: PlusIcon,
collapseIcon: MinusIcon,
}}
expandedItems={expandedItems}
onExpandedItemsChange={(_, itemIds) => setExpandedItems(itemIds)}
itemChildrenIndentation={TREE_INDENTATION}
>
<TreeItem
key={id}
itemId={id}
classes={{
content: `${backgroundColor} !min-w-fit !hover:bg-muted`,
selected: "!bg-background !important hover:!bg-muted",
label: "!min-w-fit",
}}
label={
<TreeItemInner
name={name}
latency={latency}
totalScaleSpan={totalScaleSpan}
setBackgroundColor={setBackgroundColor}
type="TRACE"
cardWidth={cardWidth}
>
<div className="overflow-y-auto p-8">
<h3 className="mb-6 text-2xl font-semibold tracking-tight">
Detail view
</h3>
<TracePreview
trace={trace}
observations={observations}
scores={scores}
/>
</div>
</TreeItemInner>
}
>
{Boolean(nestedObservations.length)
? nestedObservations.map((observation) => (
<TraceTreeItem
key={observation.id}
observation={observation}
level={1}
traceStartTime={nestedObservations[0].startTime}
totalScaleSpan={totalScaleSpan}
projectId={projectId}
scores={scores}
observations={observations}
cardWidth={cardWidth}
/>
))
: null}
</TreeItem>
</SimpleTreeView>
</div>
</Card>
</div>
);
}
+93 -14
View File
@@ -6,7 +6,7 @@ import { TracePreview } from "./TracePreview";
import Header from "@/src/components/layouts/header";
import { Badge } from "@/src/components/ui/badge";
import { TraceAggUsageBadge } from "@/src/components/token-usage-badge";
import { StringParam, useQueryParam } from "use-query-params";
import { StringParam, useQueryParam, withDefault } from "use-query-params";
import { PublishTraceSwitch } from "@/src/components/publish-object-switch";
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
import { useRouter } from "next/router";
@@ -18,12 +18,22 @@ import { ErrorPage } from "@/src/components/error-page";
import { TagTraceDetailsPopover } from "@/src/features/tag/components/TagTraceDetailsPopover";
import useLocalStorage from "@/src/components/useLocalStorage";
import { Toggle } from "@/src/components/ui/toggle";
import { Award, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import {
Award,
ChevronsDownUp,
ChevronsUpDown,
ListTree,
Network,
Terminal,
} from "lucide-react";
import { usdFormatter } from "@/src/utils/numbers";
import Decimal from "decimal.js";
import { useCallback, useState } from "react";
import { DeleteButton } from "@/src/components/deleteButton";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
import { TraceTimelineView } from "@/src/components/trace/TraceTimelineView";
import { Alert, AlertDescription, AlertTitle } from "@/src/components/ui/alert";
export function Trace(props: {
observations: Array<ObservationReturnType>;
@@ -168,6 +178,7 @@ export function Trace(props: {
}
export function TracePage({ traceId }: { traceId: string }) {
const capture = usePostHogClientCapture();
const router = useRouter();
const utils = api.useUtils();
const trace = api.traces.byId.useQuery(
@@ -199,6 +210,11 @@ export function TracePage({ traceId }: { traceId: string }) {
const totalCost = calculateDisplayTotalCost(trace.data?.observations ?? []);
const [selectedTab, setSelectedTab] = useQueryParam(
"display",
withDefault(StringParam, "details"),
);
if (trace.error?.data?.code === "UNAUTHORIZED")
return <ErrorPage message="You do not have access to this trace." />;
if (!trace.data) return <div>loading...</div>;
@@ -227,9 +243,17 @@ export function TracePage({ traceId }: { traceId: string }) {
/>
<DetailPageNav
currentId={traceId}
path={(id) =>
`/project/${router.query.projectId as string}/traces/${id}`
}
path={(id) => {
const { view, display, projectId } = router.query;
const queryParams = new URLSearchParams({
...(typeof view === "string" ? { view } : {}),
...(typeof display === "string" ? { display } : {}),
});
const queryParamString = Boolean(queryParams.size)
? `?${queryParams.toString()}`
: "";
return `/project/${projectId as string}/traces/${id}${queryParamString}`;
}}
listKey="traces"
/>
<DeleteButton
@@ -281,15 +305,70 @@ export function TracePage({ traceId }: { traceId: string }) {
/>
</div>
</div>
<div className="mt-5 flex-1 overflow-hidden border-t pt-5">
<Trace
key={trace.data.id}
trace={trace.data}
scores={trace.data.scores}
projectId={trace.data.projectId}
observations={trace.data.observations}
/>
</div>
<Tabs
value={selectedTab}
onValueChange={(tab) => {
setSelectedTab(tab);
capture("trace_detail:display_mode_switch", { view: tab });
}}
className="flex w-full justify-end border-b bg-background"
>
<TabsList className="bg-background py-0">
<TabsTrigger
value="details"
className="h-full rounded-none border-b-4 border-transparent data-[state=active]:border-primary-accent data-[state=active]:shadow-none"
>
<Network className="mr-1 h-4 w-4"></Network>
Tree
</TabsTrigger>
<TabsTrigger
value="timeline"
className="h-full rounded-none border-b-4 border-transparent data-[state=active]:border-primary-accent data-[state=active]:shadow-none"
>
<ListTree className="mr-1 h-4 w-4"></ListTree>
Timeline
<Badge className="pointer-events-none ml-2 px-1.5">Beta</Badge>
</TabsTrigger>
</TabsList>
</Tabs>
{selectedTab === "details" && (
<div className="mt-5 flex-1 overflow-hidden border-t pt-5">
<Trace
key={trace.data.id}
trace={trace.data}
scores={trace.data.scores}
projectId={trace.data.projectId}
observations={trace.data.observations}
/>
</div>
)}
{selectedTab === "timeline" && (
<div className="mt-5 flex-1 flex-col space-y-5 overflow-hidden">
<Alert>
<Terminal className="h-4 w-4" />
<AlertTitle>New Trace Timeline (beta)</AlertTitle>
<AlertDescription>
We value your feedback! Share your thoughts on{" "}
<a
href="https://github.com/orgs/langfuse/discussions/2195"
target="_blank"
className="underline"
rel="noopener noreferrer"
>
GitHub discussions
</a>
.
</AlertDescription>
</Alert>
<TraceTimelineView
key={trace.data.id}
trace={trace.data}
scores={trace.data.scores}
observations={trace.data.observations}
projectId={trace.data.projectId}
/>
</div>
)}
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { $Enums } from "@langfuse/shared";
import { type NestedObservation } from "@/src/utils/types";
import { type ObservationReturnType } from "@/src/server/api/routers/traces";
export type TreeItemType = $Enums.ObservationType | "TRACE";
export const treeItemColors: Map<TreeItemType, string> = new Map([
[$Enums.ObservationType.SPAN, "bg-muted-blue"],
[$Enums.ObservationType.GENERATION, "bg-muted-orange"],
[$Enums.ObservationType.EVENT, "bg-muted-green"],
["TRACE", "bg-input"],
]);
export function nestObservations(
list: ObservationReturnType[],
): NestedObservation[] {
if (list.length === 0) return [];
// Step 1: Create a map where the keys are object IDs, and the values are
// the corresponding objects with an added 'children' property.
const map = new Map<string, NestedObservation>();
for (const obj of list) {
map.set(obj.id, { ...obj, children: [] });
}
// Step 2: Create another map for the roots of all trees.
const roots = new Map<string, NestedObservation>();
// Step 3: Populate the 'children' arrays and root map.
for (const obj of map.values()) {
if (obj.parentObservationId) {
const parent = map.get(obj.parentObservationId);
if (parent) {
parent.children.push(obj);
}
} else {
roots.set(obj.id, obj);
}
}
// Step 4: Sort children by start time for each parent
for (const obj of map.values()) {
obj.children.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
}
// TODO sum token amounts per level
// Step 5: Return the roots.
return Array.from(roots.values());
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { Check, ChevronsDownUp, ChevronsUpDown, Copy } from "lucide-react";
import { cn } from "@/src/utils/tailwind";
import { default as React18JsonView } from "react18-json-view";
import "react18-json-view/src/dark.css";
import { deepParseJson } from "@/src/utils/json";
import { deepParseJson } from "@langfuse/shared";
import { Skeleton } from "@/src/components/ui/skeleton";
import { useTheme } from "next-themes";
+58
View File
@@ -0,0 +1,58 @@
import { useState, useEffect } from "react";
/**
* useSessionStorage is a hook for managing data with the sessionStorage API.
*
* @param {string} sessionStorageKey - The key under which the value is stored in sessionStorage.
* @param {T} initialValue - The initial value of the data to be stored.
*
* Note: The object T should be able to be stringified, as it will be stored in sessionStorage as a string.
*
* @return An array with three elements:
* value: Current value
* setValue: Function to update the value
* clearValue: Function to remove value from the session storage.
* This function will also reset the value to initial value
*
* @template T - The type of the data to be stored in sessionStorage. It should be a type that can be stringified.
*
* @throws Will throw an error if the stringifying the value or accessing session storage fails.
*/
function useSessionStorage<T>(
sessionStorageKey: string,
initialValue: T,
): [T, React.Dispatch<React.SetStateAction<T>>, () => void] {
const [value, setValue] = useState<T>(() => {
if (typeof window === "undefined") {
return initialValue;
}
try {
const storedValue = sessionStorage.getItem(sessionStorageKey);
return storedValue ? (JSON.parse(storedValue) as T) : initialValue;
} catch (error) {
console.error("Error reading from session storage", error);
return initialValue;
}
});
const clearValue = () => {
try {
sessionStorage.removeItem(sessionStorageKey);
setValue(initialValue);
} catch (error) {
console.error("Error clearing session storage", error);
}
};
useEffect(() => {
try {
sessionStorage.setItem(sessionStorageKey, JSON.stringify(value));
} catch (error) {
console.error("Error writing to session storage", error);
}
}, [sessionStorageKey, value]);
return [value, setValue, clearValue] as const;
}
export default useSessionStorage;
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v2.48.0";
export const VERSION = "v2.54.0";
@@ -11,7 +11,7 @@ import {
PopoverTrigger,
} from "@/src/components/ui/popover";
import { useState } from "react";
import { Trash2 } from "lucide-react";
import { Trash } from "lucide-react";
import {
Tabs,
TabsContent,
@@ -156,7 +156,7 @@ export function DeactivateConfig({
disabled={!hasAccess || config?.status !== "ACTIVE"}
loading={isLoading}
>
<Trash2 className="h-5 w-5" />
<Trash className="h-5 w-5" />
</Button>
</PopoverTrigger>
<PopoverContent>
@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Input } from "@/src/components/ui/input";
@@ -18,19 +18,12 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { extractVariables, getIsCharOrUnderscore } from "@/src/utils/string";
import router from "next/router";
import { type EvalTemplate } from "@prisma/client";
import { ModelParameters } from "@/src/components/ModelParameters";
import {
ModelParameters,
type ModelParamsContext,
} from "@/src/components/ModelParameters";
import {
EvalModelNames,
OutputSchema,
evalLLMModels,
type UIModelParams,
ModelProvider,
type OpenAIModel,
type OpenAIModelParams,
type ModelParams,
ZodModelConfig,
} from "@langfuse/shared";
import { PromptDescription } from "@/src/features/prompts/components/prompt-description";
import {
@@ -43,6 +36,7 @@ import {
import { TEMPLATES } from "@/src/ee/features/evals/components/templates";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { getFinalModelParams } from "@/src/ee/utils/getFinalModelParams";
import { useModelParams } from "@/src/ee/features/playground/page/hooks/useModelParams";
export const EvalTemplateForm = (props: {
projectId: string;
@@ -102,15 +96,6 @@ export const EvalTemplateForm = (props: {
score: currentTemplate?.outputScore?.trim() ?? "",
reasoning: currentTemplate?.outputReasoning?.trim() ?? "",
},
model: "gpt-3.5-turbo",
modelParams: {
model: "gpt-3.5-turbo",
provider: ModelProvider.OpenAI,
temperature: 1,
maxTemperature: 2,
max_tokens: 256,
top_p: 1,
},
}
: props.existingEvalTemplate
? {
@@ -121,10 +106,13 @@ export const EvalTemplateForm = (props: {
score: string;
reasoning: string;
},
model: props.existingEvalTemplate.model as OpenAIModel,
modelParams: props.existingEvalTemplate
.modelParams as OpenAIModelParams & {
maxTemperature: number;
selectedModel: {
provider: props.existingEvalTemplate.provider,
model: props.existingEvalTemplate.model,
modelParams: props.existingEvalTemplate
.modelParams as ModelParams & {
maxTemperature: number;
},
},
}
: undefined
@@ -135,6 +123,12 @@ export const EvalTemplateForm = (props: {
);
};
const selectedModelSchema = z.object({
provider: z.string().min(1, "Select a provider"),
model: z.string().min(1, "Select a model"),
modelParams: ZodModelConfig,
});
const formSchema = z.object({
name: z.string().min(1, "Enter a name"),
prompt: z
@@ -167,9 +161,12 @@ export type EvalTemplateFormPreFill = {
score: string;
reasoning: string;
};
model: OpenAIModel;
modelParams: OpenAIModelParams & {
maxTemperature: number;
selectedModel?: {
provider: string;
model: string;
modelParams: ModelParams & {
maxTemperature: number;
};
};
};
@@ -186,27 +183,41 @@ export const InnerEvalTemplateForm = (props: {
}) => {
const capture = usePostHogClientCapture();
const [formError, setFormError] = useState<string | null>(null);
const preFilledModel = useMemo(
() => getModelParamsWithEnabledFlag(props.preFilledFormValues),
[props.preFilledFormValues],
);
// updates the model params based on the pre-filled data
// either form update or from langfuse-generated template
const [modelParams, setModelParams] = useState<UIModelParams>(preFilledModel);
const updateModelParamValue: ModelParamsContext["updateModelParamValue"] = (
key,
value,
) => {
setModelParams((prev) => ({ ...prev, [key]: { ...prev[key], value } }));
};
const {
modelParams,
setModelParams,
updateModelParamValue,
setModelParamEnabled,
availableModels,
availableProviders,
} = useModelParams({ evalModelsOnly: true });
const setModelParamEnabled: ModelParamsContext["setModelParamEnabled"] = (
key,
enabled,
) => {
setModelParams((prev) => ({ ...prev, [key]: { ...prev[key], enabled } }));
};
useEffect(() => {
if (props.preFilledFormValues?.selectedModel) {
const { provider, model, modelParams } =
props.preFilledFormValues.selectedModel;
const modelConfig = Object.entries(modelParams).reduce(
(acc, [key, value]) => {
return {
...acc,
[key]: { value, enabled: true },
};
},
{} as UIModelParams,
);
setModelParams((prev) => ({
...prev,
...modelConfig,
provider: { value: provider, enabled: true },
model: { value: model, enabled: true },
}));
}
}, [props.preFilledFormValues?.selectedModel, setModelParams]);
// updates the form based on the pre-filled data
// either form update or from langfuse-generated template
@@ -243,33 +254,8 @@ export const InnerEvalTemplateForm = (props: {
outputScore: OutputSchema.parse(props.preFilledFormValues.outputSchema)
.score,
});
// state for the model params is outside of the form, hence needs to be handled individually
// also set the context for the playground
const model = EvalModelNames.parse(preFilledModel.model.value);
updateModelParamValue("model", model);
setModelParams((prev) => ({
...prev,
...preFilledModel,
}));
const modelProvider = evalLLMModels.find((m) => m.model.value === model)
?.provider.value;
if (modelProvider) {
updateModelParamValue("provider", modelProvider); // updating the provider based on the model
updateModelParamValue(
"maxTemperature",
modelProvider === ModelProvider.OpenAI ? 2 : 1,
); // setting the max value of the slider based on the provider
}
}
}, [
props.preFilledFormValues,
preFilledModel,
form,
props.existingEvalTemplateName,
]);
}, [props.preFilledFormValues, form, props.existingEvalTemplateName]);
const extractedVariables = form.watch("prompt")
? extractVariables(form.watch("prompt")).filter(getIsCharOrUnderscore)
@@ -288,26 +274,31 @@ export const InnerEvalTemplateForm = (props: {
: "eval_templates:new_form_submit",
);
const model = EvalModelNames.safeParse(modelParams.model.value);
const evalTemplate = {
name: values.name,
projectId: props.projectId,
prompt: values.prompt,
provider: modelParams.provider.value,
model: modelParams.model.value,
modelParams: getFinalModelParams(modelParams),
vars: extractedVariables ?? [],
outputSchema: {
score: values.outputScore,
reasoning: values.outputReasoning,
},
};
if (!model.success) {
setFormError("Please select a model.");
const parsedModel = selectedModelSchema.safeParse(evalTemplate);
if (!parsedModel.success) {
setFormError(
`${parsedModel.error.errors[0].path}: ${parsedModel.error.errors[0].message}`,
);
return;
}
createEvalTemplateMutation
.mutateAsync({
name: values.name,
projectId: props.projectId,
prompt: values.prompt,
model: model.data,
modelParams: getFinalModelParams(modelParams),
vars: extractedVariables ?? [],
outputSchema: {
score: values.outputScore,
reasoning: values.outputReasoning,
},
})
.mutateAsync(evalTemplate)
.then((res) => {
props.onFormSuccess?.();
form.reset();
@@ -429,10 +420,11 @@ export const InnerEvalTemplateForm = (props: {
<ModelParameters
{...{
modelParams,
availableModels,
availableProviders,
updateModelParamValue: updateModelParamValue,
setModelParamEnabled,
}}
availableModels={[...evalLLMModels]}
formDisabled={!props.isEditing}
/>
</div>
@@ -456,33 +448,3 @@ export const InnerEvalTemplateForm = (props: {
</Form>
);
};
function getModelParamsWithEnabledFlag(
evalPreFill?: EvalTemplateFormPreFill,
): UIModelParams {
const defaultModelParams: ModelParams & { maxTemperature: number } = {
model: evalPreFill?.model ?? "gpt-3.5-turbo",
provider: ModelProvider.OpenAI,
max_tokens: 100,
maxTemperature:
evalPreFill?.modelParams?.provider === ModelProvider.OpenAI ? 2 : 1,
top_p: 1,
temperature: 1,
};
return Object.entries({
...defaultModelParams,
...evalPreFill?.modelParams,
}).reduce(
(params, [key, value]) => ({
...params,
[key]: {
enabled: Boolean(
!evalPreFill || evalPreFill.modelParams[key as keyof UIModelParams],
),
value,
},
}),
{} as UIModelParams,
);
}
+3 -2
View File
@@ -8,7 +8,6 @@ import { throwIfNoAccess } from "@/src/features/rbac/utils/checkAccess";
import { auditLog } from "@/src/features/audit-logs/auditLog";
import { DEFAULT_TRACE_JOB_DELAY, EvalTargetObject } from "@langfuse/shared";
import {
EvalModelNames,
ZodModelConfig,
singleFilter,
variableMapping,
@@ -19,7 +18,8 @@ export const CreateEvalTemplate = z.object({
name: z.string().min(1),
projectId: z.string(),
prompt: z.string(),
model: EvalModelNames,
provider: z.string(),
model: z.string(),
modelParams: ZodModelConfig,
vars: z.array(z.string()),
outputSchema: z.object({
@@ -332,6 +332,7 @@ export const evalRouter = createTRPCRouter({
modelParams: input.modelParams,
vars: input.vars,
outputSchema: input.outputSchema,
provider: input.provider,
},
});
@@ -1,24 +1,24 @@
import { Terminal } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { z } from "zod";
import { createEmptyMessage } from "@/src/components/ChatMessages/utils/createEmptyMessage";
import { Button } from "@/src/components/ui/button";
import usePlaygroundCache from "@/src/ee/features/playground/page/hooks/usePlaygroundCache";
import { type PlaygroundCache } from "@/src/ee/features/playground/page/types";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { PromptType } from "@/src/features/prompts/server/utils/validation";
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
import {
ChatMessageRole,
supportedModels as playgroundSupportedModels,
type Observation,
type Prompt,
supportedModels as playgroundSupportedModels,
type UIModelParams,
ZodModelConfig,
} from "@langfuse/shared";
import { useEffect, useState } from "react";
import { PromptType } from "@/src/features/prompts/server/utils/validation";
import { ChatMessageListSchema } from "@/src/features/prompts/components/NewPromptForm/validation";
import { createEmptyMessage } from "@/src/components/ChatMessages/utils/createEmptyMessage";
import { getIsCloudEnvironment } from "@/src/ee/utils/getIsCloudEnvironment";
import { useIsEeEnabled } from "@/src/ee/utils/useIsEeEnabled";
type JumpToPlaygroundButtonProps = (
| {
@@ -42,6 +42,7 @@ export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
const projectId = useProjectIdFromURL();
const { setPlaygroundCache } = usePlaygroundCache();
const [capturedState, setCapturedState] = useState<PlaygroundCache>(null);
const isEeEnabled = useIsEeEnabled();
useEffect(() => {
if (props.source === "prompt") {
@@ -56,7 +57,7 @@ export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
setPlaygroundCache(capturedState);
};
if (!getIsCloudEnvironment()) return null;
if (!isEeEnabled) return null;
return (
<Button
@@ -76,9 +77,19 @@ export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
);
};
const ParsedChatMessageListSchema = z.array(
z.object({
role: z.nativeEnum(ChatMessageRole),
content: z.union([
z.string(),
z.any().transform((v) => JSON.stringify(v, null, 2)),
]),
}),
);
const parsePrompt = (prompt: Prompt): PlaygroundCache => {
if (prompt.type === PromptType.Chat) {
const parsedMessages = ChatMessageListSchema.safeParse(prompt.prompt);
const parsedMessages = ParsedChatMessageListSchema.safeParse(prompt.prompt);
return parsedMessages.success ? { messages: parsedMessages.data } : null;
} else {
@@ -109,14 +120,16 @@ const parseGeneration = (generation: Observation): PlaygroundCache => {
}
if (typeof input === "object") {
const parsedMessages = ChatMessageListSchema.safeParse(input);
const parsedMessages = ParsedChatMessageListSchema.safeParse(input);
if (parsedMessages.success)
return { messages: parsedMessages.data, modelParams };
}
if (typeof input === "object" && "messages" in input) {
const parsedMessages = ChatMessageListSchema.safeParse(input["messages"]);
const parsedMessages = ParsedChatMessageListSchema.safeParse(
input["messages"],
);
if (parsedMessages.success)
return { messages: parsedMessages.data, modelParams };
@@ -1,5 +1,5 @@
import { type ChangeEvent } from "react";
import { CheckCircle2, Circle, Trash2Icon } from "lucide-react";
import { CheckCircle2, Circle, TrashIcon } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import { Textarea } from "@/src/components/ui/textarea";
import { type PromptVariable } from "@langfuse/shared";
@@ -46,7 +46,7 @@ export const PromptVariableComponent: React.FC<{
onClick={handleDeleteVariable}
className="p-0"
>
{!isUsed && <Trash2Icon size={16} />}
{!isUsed && <TrashIcon size={16} />}
</Button>
</div>
<Textarea
@@ -3,9 +3,10 @@ import { useRouter } from "next/router";
import { Button } from "@/src/components/ui/button";
import usePlaygroundCache from "@/src/ee/features/playground/page/hooks/usePlaygroundCache";
import { getIsCloudEnvironment } from "@/src/ee/utils/getIsCloudEnvironment";
import { useIsEeEnabled } from "@/src/ee/utils/useIsEeEnabled";
export const ResetPlaygroundButton: React.FC = () => {
const isEeEnabled = useIsEeEnabled();
const router = useRouter();
const { setPlaygroundCache } = usePlaygroundCache();
@@ -15,7 +16,9 @@ export const ResetPlaygroundButton: React.FC = () => {
router.reload();
};
return getIsCloudEnvironment() ? (
if (!isEeEnabled) return null;
return (
<Button
variant={"outline"}
title="Reset playground state"
@@ -24,5 +27,5 @@ export const ResetPlaygroundButton: React.FC = () => {
<ListRestartIcon className="mr-1 h-5 w-5" />
<span>Reset playground</span>
</Button>
) : null;
);
};
@@ -19,14 +19,15 @@ import {
} from "@/src/components/ui/popover";
import { usePlaygroundContext } from "@/src/ee/features/playground/page/context";
import usePlaygroundCache from "@/src/ee/features/playground/page/hooks/usePlaygroundCache";
import { getIsCloudEnvironment } from "@/src/ee/utils/getIsCloudEnvironment";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { PromptType } from "@/src/features/prompts/server/utils/validation";
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
import { api } from "@/src/utils/api";
import { cn } from "@/src/utils/tailwind";
import { useIsEeEnabled } from "@/src/ee/utils/useIsEeEnabled";
export const SaveToPromptButton: React.FC = () => {
const isEeEnabled = useIsEeEnabled();
const [open, setOpen] = useState(false);
const [selectedPromptId, setSelectedPromptId] = useState("");
const { modelParams, messages, output, promptVariables } =
@@ -83,7 +84,7 @@ export const SaveToPromptButton: React.FC = () => {
);
};
if (!getIsCloudEnvironment()) return null;
if (!isEeEnabled) return null;
return (
<Popover>
@@ -11,6 +11,7 @@ import { v4 as uuidv4 } from "uuid";
import { createEmptyMessage } from "@/src/components/ChatMessages/utils/createEmptyMessage";
import useCommandEnter from "@/src/ee/features/playground/page/hooks/useCommandEnter";
import { useModelParams } from "@/src/ee/features/playground/page/hooks/useModelParams";
import usePlaygroundCache from "@/src/ee/features/playground/page/hooks/usePlaygroundCache";
import { getFinalModelParams } from "@/src/ee/utils/getFinalModelParams";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
@@ -19,7 +20,6 @@ import { extractVariables } from "@/src/utils/string";
import {
ChatMessageRole,
type ChatMessageWithId,
ModelProvider,
type PromptVariable,
type UIModelParams,
} from "@langfuse/shared";
@@ -68,9 +68,14 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
createEmptyMessage(ChatMessageRole.System),
createEmptyMessage(ChatMessageRole.User),
]);
const [modelParams, setModelParams] = useState<UIModelParams>(
getDefaultModelParams(ModelProvider.OpenAI),
);
const {
modelParams,
setModelParams,
availableProviders,
availableModels,
updateModelParamValue,
setModelParamEnabled,
} = useModelParams();
// Load state from cache
useEffect(() => {
@@ -97,11 +102,7 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
if (cachedPromptVariables) {
setPromptVariables(cachedPromptVariables);
}
}, [playgroundCache]);
useEffect(() => {
setModelParams(getDefaultModelParams(modelParams.provider.value));
}, [modelParams.provider.value]);
}, [playgroundCache, setModelParams]);
const updatePromptVariables = useCallback(() => {
const messageContents = messages.map((m) => m.content).join("\n");
@@ -169,6 +170,10 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
finalMessages.map((m) => m.content).join("\n"),
);
if (!modelParams.provider.value || !modelParams.model.value) {
throw new Error("Please select a model");
}
if (leftOverVariables.length > 0) {
throw Error("Error replacing variables. Please check your inputs.");
}
@@ -210,24 +215,6 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
useCommandEnter(!isStreaming, handleSubmit);
const updateModelParamValue: PlaygroundContextType["updateModelParamValue"] =
(key, value) => {
setModelParams((prev) => ({
...prev,
[key]: { ...prev[key], value },
}));
};
const setModelParamEnabled: PlaygroundContextType["setModelParamEnabled"] = (
key,
enabled,
) => {
setModelParams((prev) => ({
...prev,
[key]: { ...prev[key], enabled },
}));
};
const updatePromptVariableValue = (variable: string, value: string) => {
setPromptVariables((prev) =>
prev.map((v) => (v.name === variable ? { ...v, value } : v)),
@@ -258,6 +245,9 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
outputJson,
handleSubmit,
isStreaming,
availableProviders,
availableModels,
}}
>
{children}
@@ -342,38 +332,6 @@ function getFinalMessages(
return finalMessages;
}
function getDefaultModelParams(provider: ModelProvider): UIModelParams {
switch (provider) {
// Docs: https://platform.openai.com/docs/api-reference/chat/create
case ModelProvider.OpenAI:
return {
provider: {
value: provider,
enabled: true,
},
model: { value: "gpt-3.5-turbo", enabled: true },
temperature: { value: 1, enabled: true },
maxTemperature: { value: 2, enabled: true },
max_tokens: { value: 256, enabled: true },
top_p: { value: 1, enabled: true },
};
// Docs: https://docs.anthropic.com/claude/reference/messages_post
case ModelProvider.Anthropic:
return {
provider: {
value: provider,
enabled: true,
},
model: { value: "claude-3-opus-20240229", enabled: true },
temperature: { value: 0, enabled: true },
maxTemperature: { value: 1, enabled: true },
max_tokens: { value: 256, enabled: true },
top_p: { value: 1, enabled: true },
};
}
}
function getOutputJson(
output: string,
messages: ChatMessageWithId[],
@@ -0,0 +1,170 @@
import { useEffect, useMemo, useState } from "react";
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
import { api } from "@/src/utils/api";
import {
LLMAdapter,
evalModelsByAdapter,
supportedModels,
type UIModelParams,
} from "@langfuse/shared";
import { type ModelParamsContext } from "@/src/components/ModelParameters";
export const useModelParams = ({
evalModelsOnly,
}: {
evalModelsOnly?: boolean;
} = {}) => {
const [modelParams, setModelParams] = useState<UIModelParams>({
...getDefaultAdapterParams(LLMAdapter.OpenAI),
provider: { value: "", enabled: true },
model: { value: "", enabled: true },
});
// Set initial model params
const projectId = useProjectIdFromURL();
const availableLLMApiKeys = api.llmApiKey.all.useQuery(
{
projectId: projectId as string,
},
{ enabled: Boolean(projectId) },
);
const availableProviders = useMemo(() => {
return availableLLMApiKeys.data?.data.map((key) => key.provider) ?? [];
}, [availableLLMApiKeys]);
const selectedProviderApiKey = availableLLMApiKeys.data?.data.find(
(key) => key.provider === modelParams.provider.value,
);
const availableModels = useMemo(
() =>
!selectedProviderApiKey
? []
: evalModelsOnly
? [
...evalModelsByAdapter[selectedProviderApiKey.adapter],
...selectedProviderApiKey.customModels,
]
: selectedProviderApiKey.withDefaultModels
? [
...selectedProviderApiKey.customModels,
...supportedModels[selectedProviderApiKey.adapter],
]
: selectedProviderApiKey.customModels,
[selectedProviderApiKey, evalModelsOnly],
);
const updateModelParamValue: ModelParamsContext["updateModelParamValue"] = (
key,
value,
) => {
setModelParams((prev) => ({
...prev,
[key]: { ...prev[key], value },
}));
};
const setModelParamEnabled: ModelParamsContext["setModelParamEnabled"] = (
key,
enabled,
) => {
setModelParams((prev) => ({
...prev,
[key]: { ...prev[key], enabled },
}));
};
// Set default provider and model
useEffect(() => {
if (availableProviders.length > 0 && !modelParams.provider.value) {
updateModelParamValue("provider", availableProviders[0]);
}
}, [availableProviders, modelParams.provider.value]);
useEffect(() => {
if (
(availableModels.length > 0 && !modelParams.model.value) ||
!availableModels.includes(modelParams.model.value)
) {
updateModelParamValue("model", availableModels[0]);
}
}, [availableModels, modelParams.model.value]);
// Update adapter and max temperature when provider changes
useEffect(() => {
if (selectedProviderApiKey?.adapter) {
setModelParams((prev) => ({
...prev,
adapter: {
value: selectedProviderApiKey.adapter,
enabled: true,
},
maxTemperature: getDefaultAdapterParams(selectedProviderApiKey.adapter)
.maxTemperature,
temperature: {
value: Math.min(
prev.temperature.value,
getDefaultAdapterParams(selectedProviderApiKey.adapter)
.maxTemperature.value,
),
enabled: true,
},
}));
}
}, [selectedProviderApiKey?.adapter]);
return {
modelParams,
setModelParams,
availableProviders,
availableModels,
updateModelParamValue,
setModelParamEnabled,
};
};
function getDefaultAdapterParams(
adapter: LLMAdapter,
): Omit<UIModelParams, "provider" | "model"> {
switch (adapter) {
// Docs: https://platform.openai.com/docs/api-reference/chat/create
case LLMAdapter.OpenAI:
return {
adapter: {
value: adapter,
enabled: true,
},
temperature: { value: 0, enabled: true },
maxTemperature: { value: 2, enabled: true },
max_tokens: { value: 256, enabled: true },
top_p: { value: 1, enabled: true },
};
case LLMAdapter.Azure:
return {
adapter: {
value: adapter,
enabled: true,
},
temperature: { value: 0, enabled: true },
maxTemperature: { value: 2, enabled: true },
max_tokens: { value: 256, enabled: true },
top_p: { value: 1, enabled: true },
};
// Docs: https://docs.anthropic.com/claude/reference/messages_post
case LLMAdapter.Anthropic:
return {
adapter: {
value: adapter,
enabled: true,
},
temperature: { value: 0, enabled: true },
maxTemperature: { value: 1, enabled: true },
max_tokens: { value: 256, enabled: true },
top_p: { value: 1, enabled: true },
};
}
}
@@ -1,13 +1,13 @@
import { useEffect, useState } from "react";
import { getIsCloudEnvironment } from "@/src/ee/utils/getIsCloudEnvironment";
import { type PlaygroundCache } from "../types";
import { useIsEeEnabled } from "@/src/ee/utils/useIsEeEnabled";
const playgroundCacheKey = "playgroundCache";
export default function usePlaygroundCache() {
const [cache, setCache] = useState<PlaygroundCache>(null);
const isEeEnabled = useIsEeEnabled();
const setPlaygroundCache = (cache: PlaygroundCache) => {
sessionStorage.setItem(playgroundCacheKey, JSON.stringify(cache));
};
@@ -24,7 +24,7 @@ export default function usePlaygroundCache() {
}, []);
return {
playgroundCache: getIsCloudEnvironment() ? cache : null,
setPlaygroundCache: getIsCloudEnvironment() ? setPlaygroundCache : () => {},
playgroundCache: isEeEnabled ? cache : null,
setPlaygroundCache: isEeEnabled ? setPlaygroundCache : () => {},
};
}
@@ -3,10 +3,12 @@ import { ResetPlaygroundButton } from "@/src/ee/features/playground/page/compone
import { SaveToPromptButton } from "@/src/ee/features/playground/page/components/SaveToPromptButton";
import { PlaygroundProvider } from "@/src/ee/features/playground/page/context";
import Playground from "@/src/ee/features/playground/page/playground";
import { getIsCloudEnvironment } from "@/src/ee/utils/getIsCloudEnvironment";
import { useIsEeEnabled } from "@/src/ee/utils/useIsEeEnabled";
export default function PlaygroundPage() {
return getIsCloudEnvironment() ? (
const isEeAvailable = useIsEeEnabled();
if (!isEeAvailable) return null;
return (
<PlaygroundProvider>
<div className="flex h-[95vh] flex-col">
<Header
@@ -28,5 +30,5 @@ export default function PlaygroundPage() {
</div>
</div>
</PlaygroundProvider>
) : null;
);
}
@@ -1,9 +1,9 @@
import { getServerSession } from "next-auth";
import { getIsCloudEnvironment } from "@/src/ee/utils/getIsCloudEnvironment";
import { getAuthOptions } from "@/src/server/auth";
import { isProjectMemberOrAdmin } from "@/src/server/utils/checkProjectMembershipOrAdmin";
import { ApiError, ForbiddenError, UnauthorizedError } from "@langfuse/shared";
import { isEeEnabled } from "@/src/ee/utils/isEeEnabled";
export type AuthorizeRequestResult = {
userId: string;
@@ -12,8 +12,10 @@ export type AuthorizeRequestResult = {
export const authorizeRequestOrThrow = async (
projectId: string,
): Promise<AuthorizeRequestResult> => {
if (!getIsCloudEnvironment())
throw new ApiError("This endpoint is available in Langfuse cloud only.");
if (!isEeEnabled)
throw new ApiError(
"LLM Playground is not yet available in the v2 open-source version.",
);
const authOptions = await getAuthOptions();
const session = await getServerSession(authOptions);
@@ -39,6 +39,7 @@ export default async function chatCompletionHandler(req: NextRequest) {
streaming: true,
callbacks: [new PosthogCallbackHandler("playground", body, userId)],
apiKey: decrypt(LLMApiKey.secretKey),
baseURL: LLMApiKey.baseURL || undefined,
});
return new StreamingTextResponse(stream);
@@ -1,31 +1,14 @@
import { z } from "zod";
import {
ChatMessageRole,
ModelProvider,
anthropicModels,
openAIModels,
} from "@langfuse/shared";
import { ChatMessageRole, LLMAdapter } from "@langfuse/shared";
const OpenAIModelSchema = z.enum([...openAIModels]);
const AnthropicModelSchema = z.enum([...anthropicModels]);
const OpenAIModelParamsSchema = z.object({
provider: z.literal(ModelProvider.OpenAI),
model: OpenAIModelSchema,
max_tokens: z.number().optional(),
temperature: z.number().optional(),
top_p: z.number().optional(),
});
const AnthropicModelParamsSchema = z.object({
provider: z.literal(ModelProvider.Anthropic),
model: AnthropicModelSchema,
const ModelParamsSchema = z.object({
provider: z.string(),
adapter: z.nativeEnum(LLMAdapter),
model: z.string(),
temperature: z.number().optional(),
max_tokens: z.number().optional(),
top_p: z.number().optional(),
});
const ModelParamsSchema = z.union([
OpenAIModelParamsSchema,
AnthropicModelParamsSchema,
]);
const MessageSchema = z.object({
role: z.nativeEnum(ChatMessageRole),
content: z.string(),
@@ -1,4 +0,0 @@
import { env } from "@/src/env.mjs";
export const getIsCloudEnvironment = () =>
Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
+15
View File
@@ -0,0 +1,15 @@
import { env } from "@/src/env.mjs";
/**
* Function to determine if the enterprise edition (EE) features are enabled. Do not use in frontend code as env variables are not available in the browser.
*
* This hook checks two conditions:
* 1. If Langfuse is running on Langfuse Cloud
* 2. If EE license is provided based on the user's session
*
* @returns {boolean}
*/
export const isEeEnabled =
Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) ||
Boolean(env.LANGFUSE_EE_LICENSE_KEY);
+20
View File
@@ -0,0 +1,20 @@
import { env } from "@/src/env.mjs";
import { useSession } from "next-auth/react";
/**
* Custom React frontend hook to determine if the enterprise edition (EE) features are enabled.
*
* This hook checks two conditions:
* 1. If Langfuse is running on Langfuse Cloud
* 2. If EE license is provided based on the user's session
*
* @returns {boolean}
*/
export const useIsEeEnabled: () => boolean = () => {
const session = useSession();
return (
Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) ||
Boolean(session.data?.environment.eeEnabled)
);
};
+32 -14
View File
@@ -30,14 +30,20 @@ export const env = createEnv({
LANGFUSE_NEW_USER_SIGNUP_WEBHOOK: z.string().url().optional(),
// Add `.min(1) on ID and SECRET if you want to make sure they're not empty
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES: z.enum(["true", "false"]).optional(),
LANGFUSE_DEFAULT_TABLE_DATETIME_OFFSET: z.string().optional().refine((v) =>
v === undefined || !isNaN(Number.parseInt(v))
, {
message: "LANGFUSE_DEFAULT_TABLE_DATETIME_OFFSET must be a number when set"
}).transform((v) =>
v === undefined ? undefined : -Number.parseInt(v) // negative offset
LANGFUSE_DEFAULT_TABLE_DATETIME_OFFSET: z
.string()
.optional()
.refine((v) => v === undefined || !isNaN(Number.parseInt(v)), {
message:
"LANGFUSE_DEFAULT_TABLE_DATETIME_OFFSET must be a number when set",
})
.transform(
(v) => (v === undefined ? undefined : -Number.parseInt(v)), // negative offset
),
LANGFUSE_DISABLE_EXPENSIVE_POSTGRES_QUERIES: z.enum(["true", "false"]).optional().default("false"),
LANGFUSE_DISABLE_EXPENSIVE_POSTGRES_QUERIES: z
.enum(["true", "false"])
.optional()
.default("false"),
SALT: z.string({
required_error:
"A strong Salt is required to encrypt API keys securely. See: https://langfuse.com/docs/deployment/self-host#deploy-the-container",
@@ -51,6 +57,7 @@ export const env = createEnv({
// AUTH
AUTH_GOOGLE_CLIENT_ID: z.string().optional(),
AUTH_GOOGLE_CLIENT_SECRET: z.string().optional(),
AUTH_GOOGLE_ALLOWED_DOMAINS: z.string().optional(),
AUTH_GOOGLE_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
AUTH_GITHUB_CLIENT_ID: z.string().optional(),
AUTH_GITHUB_CLIENT_SECRET: z.string().optional(),
@@ -71,6 +78,11 @@ export const env = createEnv({
AUTH_COGNITO_CLIENT_SECRET: z.string().optional(),
AUTH_COGNITO_ISSUER: z.string().url().optional(),
AUTH_COGNITO_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
AUTH_CUSTOM_CLIENT_ID: z.string().optional(),
AUTH_CUSTOM_CLIENT_SECRET: z.string().optional(),
AUTH_CUSTOM_ISSUER: z.string().url().optional(),
AUTH_CUSTOM_NAME: z.string().optional(),
AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
AUTH_DOMAINS_WITH_SSO_ENFORCEMENT: z.string().optional(),
AUTH_DISABLE_USERNAME_PASSWORD: z.enum(["true", "false"]).optional(),
AUTH_DISABLE_SIGNUP: z.enum(["true", "false"]).optional(),
@@ -88,12 +100,11 @@ export const env = createEnv({
// Worker
LANGFUSE_WORKER_HOST: z.string().optional(),
LANGFUSE_WORKER_PASSWORD: z.string().optional(),
// Prompt playground
OPENAI_API_KEY: z.string().optional(),
ANTHROPIC_API_KEY: z.string().optional(),
TURNSTILE_SECRET_KEY: z.string().optional(),
// DB event log
ENABLE_EVENT_LOG: z.enum(["true", "false"]).optional().default("true"),
// EE License
LANGFUSE_EE_LICENSE_KEY: z.string().optional(),
},
/**
@@ -150,6 +161,7 @@ export const env = createEnv({
// AUTH
AUTH_GOOGLE_CLIENT_ID: process.env.AUTH_GOOGLE_CLIENT_ID,
AUTH_GOOGLE_CLIENT_SECRET: process.env.AUTH_GOOGLE_CLIENT_SECRET,
AUTH_GOOGLE_ALLOWED_DOMAINS: process.env.AUTH_GOOGLE_ALLOWED_DOMAINS,
AUTH_GOOGLE_ALLOW_ACCOUNT_LINKING:
process.env.AUTH_GOOGLE_ALLOW_ACCOUNT_LINKING,
AUTH_GITHUB_CLIENT_ID: process.env.AUTH_GITHUB_CLIENT_ID,
@@ -174,7 +186,14 @@ export const env = createEnv({
AUTH_COGNITO_CLIENT_ID: process.env.AUTH_COGNITO_CLIENT_ID,
AUTH_COGNITO_CLIENT_SECRET: process.env.AUTH_COGNITO_CLIENT_SECRET,
AUTH_COGNITO_ISSUER: process.env.AUTH_COGNITO_ISSUER,
AUTH_COGNITO_ALLOW_ACCOUNT_LINKING: process.env.AUTH_COGNITO_ALLOW_ACCOUNT_LINKING,
AUTH_COGNITO_ALLOW_ACCOUNT_LINKING:
process.env.AUTH_COGNITO_ALLOW_ACCOUNT_LINKING,
AUTH_CUSTOM_CLIENT_ID: process.env.AUTH_CUSTOM_CLIENT_ID,
AUTH_CUSTOM_CLIENT_SECRET: process.env.AUTH_CUSTOM_CLIENT_SECRET,
AUTH_CUSTOM_ISSUER: process.env.AUTH_CUSTOM_ISSUER,
AUTH_CUSTOM_NAME: process.env.AUTH_CUSTOM_NAME,
AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING:
process.env.AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING,
AUTH_DOMAINS_WITH_SSO_ENFORCEMENT:
process.env.AUTH_DOMAINS_WITH_SSO_ENFORCEMENT,
AUTH_DISABLE_USERNAME_PASSWORD: process.env.AUTH_DISABLE_USERNAME_PASSWORD,
@@ -193,9 +212,6 @@ export const env = createEnv({
// Worker
LANGFUSE_WORKER_HOST: process.env.LANGFUSE_WORKER_HOST,
LANGFUSE_WORKER_PASSWORD: process.env.LANGFUSE_WORKER_PASSWORD,
// Prompt playground
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
@@ -204,6 +220,8 @@ export const env = createEnv({
NEXT_PUBLIC_CRISP_WEBSITE_ID: process.env.NEXT_PUBLIC_CRISP_WEBSITE_ID,
// db event log
ENABLE_EVENT_LOG: process.env.ENABLE_EVENT_LOG,
// EE License
LANGFUSE_EE_LICENSE_KEY: process.env.LANGFUSE_EE_LICENSE_KEY,
},
// Skip validation in Docker builds
// DOCKER_BUILD is set in Dockerfile
@@ -1,4 +1,4 @@
import { noHtmlCheck } from "@/src/utils/zod";
import { noHtmlCheck } from "@langfuse/shared";
import * as z from "zod";
export const projectNameSchema = z.object({
+1 -1
View File
@@ -1,4 +1,4 @@
import { noHtmlCheck } from "@/src/utils/zod";
import { noHtmlCheck } from "@langfuse/shared";
import * as z from "zod";
export const signupSchema = z.object({
@@ -3,6 +3,10 @@ import { getColorsForCategories } from "@/src/features/dashboard/utils/getColors
import { compactNumberFormatter } from "@/src/utils/numbers";
import { cn } from "@/src/utils/tailwind";
import { AreaChart, LineChart } from "@tremor/react";
import {
CurrencyTooltip,
NumberTooltip,
} from "@/src/features/dashboard/components/Tooltip";
export type TimeSeriesChartDataPoint = {
ts: number;
@@ -58,6 +62,9 @@ export function BaseTimeSeriesChart(props: {
};
const ChartComponent = props.chartType === "area" ? AreaChart : LineChart;
const TooltipComponent = props.valueFormatter
? CurrencyTooltip
: NumberTooltip;
const colors = getColorsForCategories(Array.from(labels));
return (
@@ -74,6 +81,7 @@ export function BaseTimeSeriesChart(props: {
showAnimation={true}
onValueChange={() => {}}
enableLegendSlider={true}
customTooltip={TooltipComponent}
/>
);
}
@@ -21,7 +21,7 @@ export const TabComponent = ({ tabs }: TabComponentProps) => {
<select
id="tabs"
name="tabs"
className="focus:border-primary-accent focus:ring-primary-accent block w-full rounded-md border-border py-2 pl-3 pr-10 text-base focus:outline-none sm:text-sm"
className="block w-full rounded-md border-border bg-background py-2 pl-3 pr-10 text-base focus:border-primary-accent focus:outline-none focus:ring-primary-accent sm:text-sm"
defaultValue={0}
onChange={(e) => setSelectedIndex(Number(e.target.selectedIndex))}
>
@@ -0,0 +1,71 @@
import {
ChartTooltipFrame,
ChartTooltipRow,
} from "@tremor/react/dist/components/chart-elements/common/ChartTooltip";
import { compactNumberFormatter, usdFormatter } from "@/src/utils/numbers";
import { cn } from "@/src/utils/tailwind";
import { type CustomTooltipProps } from "@tremor/react";
import { getRandomColor } from "@/src/features/dashboard/utils/getColorsForCategories";
export const Tooltip = ({
payload,
active,
label,
formatter,
}: CustomTooltipProps & { formatter: (value: number) => string }) => {
if (!active || !payload) return null;
// Filter out duplicates and sort by value in descending order
const uniquePayload = Array.from(
new Map(payload.map((category) => [category.name, category])).values(),
);
const sortedPayload = uniquePayload.sort(
(a, b) => (Number(b.value) ?? 0) - (Number(a.value) ?? 0),
);
return (
<ChartTooltipFrame>
<div
className={cn(
// light
"border-b border-tremor-border px-4 py-2",
// dark
"dark:border-dark-tremor-border",
)}
>
<p
className={cn(
// common
"font-medium",
// light
"text-tremor-content-emphasis",
// dark
"dark:text-dark-tremor-content-emphasis",
)}
>
{label}
</p>
</div>
<div className={cn("space-y-1 px-4 py-2")}>
{sortedPayload.map(({ name, value, color }, index) => (
<ChartTooltipRow
key={`${index}`}
value={formatter(Number(value))}
name={name?.toString() ?? ""}
color={color ?? getRandomColor()}
/>
))}
</div>
</ChartTooltipFrame>
);
};
export const NumberTooltip = (props: CustomTooltipProps) => (
<Tooltip {...props} formatter={compactNumberFormatter} />
);
export const CurrencyTooltip = (props: CustomTooltipProps) => (
<Tooltip {...props} formatter={usdFormatter} />
);
@@ -91,7 +91,7 @@ export const TracesBarListChart = ({
<BarList
data={adjustedData}
valueFormatter={(number: number) =>
Intl.NumberFormat("us").format(number).toString()
Intl.NumberFormat("en-US").format(number).toString()
}
className="mt-6"
showAnimation={true}
@@ -47,7 +47,7 @@ const predefinedColors: Color[] = [
"stone",
];
function getRandomColor() {
export function getRandomColor() {
return predefinedColors[Math.floor(Math.random() * predefinedColors.length)];
}
@@ -12,7 +12,7 @@ import {
import { throwIfNoAccess } from "@/src/features/rbac/utils/checkAccess";
import { auditLog } from "@/src/features/audit-logs/auditLog";
import { DB } from "@/src/server/db";
import { paginationZod } from "@/src/utils/zod";
import { paginationZod } from "@langfuse/shared";
export const datasetRouter = createTRPCRouter({
allDatasetMeta: protectedProjectProcedure
@@ -530,18 +530,6 @@ export const datasetRouter = createTRPCRouter({
},
},
},
include: {
observation: {
select: {
id: true,
},
},
trace: {
select: {
id: true,
},
},
},
orderBy: {
createdAt: "desc",
},
@@ -549,6 +537,8 @@ export const datasetRouter = createTRPCRouter({
skip: input.page * input.limit,
});
if (runItems.length === 0) return { totalRunItems: 0, runItems: [] };
const traceScores = await ctx.prisma.score.findMany({
where: {
projectId: ctx.session.projectId,
@@ -598,18 +588,37 @@ export const datasetRouter = createTRPCRouter({
},
});
const traces = await ctx.prisma.traceView.findMany({
where: {
id: {
in: runItems.map((ri) => ri.traceId).filter(Boolean) as string[],
},
projectId: ctx.session.projectId,
},
select: {
id: true,
duration: true,
},
});
// Directly access 'traces' table and calculate duration via lateral join
// Previously used 'traces_view' was not performant enough
const traceIdsSQL = Prisma.sql`ARRAY[${Prisma.join(runItems.map((ri) => ri.traceId))}]`;
const traces = await ctx.prisma.$queryRaw<
{
id: string;
duration: number;
}[]
>(
Prisma.sql`
SELECT
t.id,
o.duration
FROM
traces t
LEFT JOIN LATERAL (
SELECT
EXTRACT(epoch FROM COALESCE(max(o1.end_time), max(o1.start_time)))::double precision - EXTRACT(epoch FROM min(o1.start_time))::double precision AS duration
FROM
observations o1
WHERE
o1.project_id = t.project_id
AND o1.trace_id = t.id
GROUP BY
o1.project_id,
o1.trace_id) o ON TRUE
WHERE
t.project_id = ${input.projectId}
AND t.id = ANY(${traceIdsSQL})
`,
);
const items = runItems.map((ri) => {
return {
@@ -629,6 +638,7 @@ export const datasetRouter = createTRPCRouter({
};
});
// Note: We early return in case of no run items, when adding parameters here, make sure to update the early return above
return {
totalRunItems,
runItems: items,
@@ -25,7 +25,7 @@ import {
filterOperators,
singleFilter,
} from "@langfuse/shared";
import { NonEmptyString } from "@/src/utils/zod";
import { NonEmptyString } from "@langfuse/shared";
import { cn } from "@/src/utils/tailwind";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";

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