Compare commits

...
55 Commits
Author SHA1 Message Date
steffen911 fae508d820 chore: release v3.45.2 2025-03-26 09:59:47 +01:00
Steffen SchmitzandGitHub a6e581d349 fix: use correct column name for filtering traces bar chart (#6215) 2025-03-26 08:56:09 +00:00
Max DeichmannandGitHub 9e77738a2f fix: fix by model charts (#6208)
* fix

* push

* push

* push
2025-03-25 20:56:00 +00:00
Marc Klingen d15c0a27f5 chore: release v3.45.1 2025-03-25 19:49:44 +01:00
Marc KlingenandGitHub a8626841b5 chore(cloud): update blobstorage integration entitlement (#6209) 2025-03-25 18:40:57 +00:00
marliessophieandGitHub 5945a93b7d fix(evaluator-config): preview execution of dataset run items (#6206)
* chore(evaluator-config): preview execution of dataset run items

* push
2025-03-25 17:44:11 +00:00
Steffen SchmitzandGitHub 9c447de584 chore: auto-close stale issues (#6207) 2025-03-25 17:21:44 +00:00
Max DeichmannGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
48df0e04de fix: fix model charts (#6202)
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* Update packages/shared/src/server/repositories/dashboards.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Update web/src/__tests__/async/repositories/dashboard-repository.servertest.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* fix

* fix

* fix

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-03-25 17:12:50 +00:00
Steffen SchmitzandGitHub 0897ac753b feat: add custom-query backend (#6006)
* feat: add self-serve dashboard backend poc

* chore: data model thoughts

* chore: create initial query builder example and test case

* chore: handle empty dimensions and metrics

* chore: lint

* chore: extend filter conditions to account for timestamps

* chore: refactor query builder into sub-functions

* chore: use template queries for user-supplied values

* chore: add query builder tests

* chore: handle time dimension

* chore: add trpc endpoint to execute custom clickhouse query

* chore: add a query playground component to test a couple of queries

* chore: handle multiple joins

* chore: add observations table to data model

* chore: fix bug in data model

* chore: add test case to compare with old dashboard results

* chore: add scaffold for additional views

* expand the users view and add segments to filter scores subviews

* chore: add test cases for score views

* chore: drop users and sessions for now

* chore: add scores aggregate and observations cost tests

* chore: add sql injection tests

* chore: lint

* chore: fix dashboard test cases

* chore: drop users query test

* chore: add order by logic

* chore: fill timeseries values

* chore: use new query function on dashboard

* chore: convert TracesBarListChart.tsx to new query endpoint

* chore: make chart data compile

* chore: update tests

* chore: add tags to custom queries

* chore: typing

* chore: suffix join condition with sql

* chore: add typing in queryBuidler

* chore: limit playground to cloud admin users

* chore: add util to map legacy dashboard columns to new model

* chore: separate time filter state for TracesBarListChart.tsx

* chore: pass timestamps directly into chart components

* chore: add userId and sessionId on observations

* chore: pick auto time granularity based on hours
2025-03-25 14:54:37 +00:00
Marc KlingenandGitHub d00c63100e chore(cloud): remove rate limits from projects api as it is used by auth_check in sdks (#6193)
* chore(cloud): remove rate limits from projects api as it is used by auth_check in sdks

* address feedback

* improve comment
2025-03-25 14:52:23 +00:00
steffen911 e8970bc11c chore: release v3.45.0 2025-03-25 15:52:33 +01:00
Steffen SchmitzGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
eecb253093 feat: add blob storage integration (#6143)
* feat: add page to configure blob storage integration

* chore: remove blob storage logo and include three config options

* chore: add blob storage integration table

* chore: update router for blobstorage

* chore: placeholder for password and type update

* chore: create scaffolding for blobstorage queue processors

* chore: add scheduling logic for blob storage integration

* chore: prepare setup in processing job

* chore: implement extraction fucntion

* chore: add test case and fix generator setup

* chore: lint

* chore: overwrite default region to empty

* chore: remove details from auditlog and add beta

* Update web/src/pages/project/[projectId]/settings/integrations/blobstorage.tsx

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Update worker/src/__tests__/blobStorageIntegrationProcessing.test.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* chore: correct env name in tests

* Update worker/src/ee/integrations/blobstorage/handleBlobStorageIntegrationProjectJob.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* chore: update doc urls

* chore: open docs in new tab

* chore: ensure consistent block id length azure

* chore: revert test change

* chore: remove dummy error log

* chore: type annotation

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-03-25 13:39:33 +00:00
marliessophieGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
4e4fbb5101 fix(prompt-experiments): show form error if name is duplicated (#6189)
* fix(prompt-experiments): show form error if name is duplicated

* chore: fix typo

* Update web/src/ee/features/experiments/hooks/useExperimentNameValidation.tsx

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* eslint

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-03-25 09:07:16 +00:00
marliessophieandGitHub bb4823aa27 fix(evaluator-templates): control editing state (#6187) 2025-03-24 13:29:19 +00:00
marliessophieandGitHub 174d96c100 feat(evals): preview number of historic items to be evaluated (#6116)
* feat(evals): preview number of historic items to be evaluated

* refactor: split up large evaluator form file

* chore: drop optional pagination

* chore: fix eslint

* chore: rename test
2025-03-24 13:05:50 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8e59b544c9 chore(deps): bump next from 14.2.21 to 14.2.25 (#6175)
Bumps [next](https://github.com/vercel/next.js) from 14.2.21 to 14.2.25.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v14.2.21...v14.2.25)

---
updated-dependencies:
- dependency-name: next
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-24 12:57:26 +00:00
Hassieb PakzadandGitHub cffd84f0e7 chore(azure-llm-connection): update api-version to support reasoning models (#6186) 2025-03-24 14:06:55 +01:00
Hassieb PakzadandGitHub 845faee385 fix(cost-tracking): null values in OpenAI usage schema (#6184) 2025-03-24 13:42:20 +01:00
Hassieb PakzadGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
e45c4af30f chore(prompts): add error logs for resolvePromptGraph (#6181)
* chore(prompts): add error logs for resolvePromptGraph

* Update packages/shared/src/server/services/PromptService/index.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* fix

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-03-24 11:31:29 +00:00
Marc KlingenandGitHub 616544a48c chore: apply prettier (#6176) 2025-03-24 09:46:13 +00:00
steffen911 75d64b38b3 chore: release v3.44.0 2025-03-24 09:35:15 +01:00
Steffen SchmitzandGitHub 65cdd75282 chore: drop queue backup option in bullmq (#6141)
* chore: drop queue backup option in bullmq

* chore: lint
2025-03-24 08:26:32 +00:00
Steffen SchmitzandGitHub 2744028f4c feat: extract top-level metadata from langfuse.metadata (#6173)
* feat: extract top-level metadata from langfuse.metadata

* chore: extract metadata based on prefix

* chore: add support for resourceAttributes
2025-03-24 08:09:04 +00:00
Max DeichmannandGitHub 3c02c54de2 fix: fix model paring from redis (#6157)
* push

* fix

* push

* fix

* fix
2025-03-21 18:42:06 +00:00
marliessophieandGitHub 974d2c1007 fix(ui): reference errors in prompt detail screen (#6126)
* chore(ui): convert `action-button` to use forwardRef for compatibility w/ buttons

* fix(ui): ensure prompt item ref points to `command-item`
2025-03-21 17:07:21 +00:00
Max DeichmannandGitHub 008045c771 perf: cache model not found (#6148)
* push

* push

* fix
2025-03-21 14:57:17 +00:00
Marc KlingenandGitHub c6790c6c0e chore(ui): do not auto-generate new api keys in setup-tracing step (#6149)
* chore(ui): do not auto-generate new api keys in setup-tracing step

* fix
2025-03-21 14:17:01 +00:00
Marc KlingenandGitHub 909445ce5b fix(prompts): trim prompt names (#6145)
* fix(prompts): trim prompt names

* push
2025-03-21 13:38:04 +00:00
Max DeichmannandGitHub f4ed4a2635 chore: improve model match observability (#6147)
push
2025-03-21 13:18:39 +00:00
Max DeichmannandGitHub d6206b5269 chore: increase model match observability (#6144) 2025-03-21 13:45:36 +01:00
Max DeichmannandGitHub 62fa43aa78 feat: cache LLM models in Redis (#6129)
* push

* fix

* fix

* fix

* push

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* push

* push

* push

* fix

* fix

* fix

* fix

* push
2025-03-21 11:26:11 +00:00
Steffen SchmitzandGitHub 3c9239a4ab chore: update .env.prod.example (#6140) 2025-03-21 10:24:48 +00:00
marliessophieandGitHub 476cddb4e1 chore(ui): clamp page-header title to one line (#6139) 2025-03-21 10:01:45 +00:00
Steffen SchmitzandGitHub a6c1678da8 feat: add additional input/output parsing for pydantic via OTel (#6138) 2025-03-21 10:00:33 +00:00
Steffen SchmitzandGitHub 129805ebde fix: safely parse big numbers in api responses (#6136)
* fix: safely parse big numbers in api responses

* feat: add additional input/output parsing for pydantic via OTel

* Revert "feat: add additional input/output parsing for pydantic via OTel"

This reverts commit 71d0f3abd951702393aac7ab79a875b888f089cf.
2025-03-21 09:30:24 +00:00
marliessophieandGitHub 59eafb42c3 fix(ui): require annotation queue selection for bulk action (#6127) 2025-03-20 21:08:57 +00:00
marliessophieandGitHub e4695ac717 fix(ui): dom nesting error data-table-row-height-switch (#6125) 2025-03-20 20:12:52 +00:00
marliessophieandGitHub eebd6ea0e9 fix(ui): dom nesting error page-header (#6124) 2025-03-20 20:06:05 +00:00
Marc KlingenandGitHub b4a8b08d3d chore(cloud): add posthog for projects and orgs (#6117)
* chore(cloud): add posthog for projects and orgs

* push
2025-03-20 18:07:17 +00:00
Max DeichmannandGitHub ed44b663ff chore: add dd env variables to github build command (#6119) 2025-03-20 17:36:48 +01:00
Marc KlingenandGitHub 720ebe861e chore(cloud): cache response of cloudStatus api for 5 minutes (#6115)
chore(cloud): keep response of cloudStatus api cached across views for 5 minutes
2025-03-20 14:25:54 +00:00
Max DeichmannandGitHub 9a6f8fd75d refactor: refactor naming of database batch exports (#6111)
* refactor: refactor naming of database batch exports

* refactor: refactor naming of database batch exports
2025-03-20 12:56:38 +00:00
Steffen SchmitzandGitHub f0da1cfa30 chore: reduce cloudwatch flush frequency and batch metrics (#6108)
* chore: reduce cloudwatch flush frequency and batch metrics

* chore: simplify metrics reset

* chore: keep 30s flush

* chore: correct CW metrics
2025-03-20 12:05:07 +00:00
Marc KlingenandGitHub 0c9ebb3da7 feat(cloud): show status page menu item during incidents (#6100)
* feat(cloud): show status page menu item during incidents

* add to support menu
2025-03-20 09:59:49 +00:00
Hassieb PakzadandGitHub 3d16d1ac57 fix(model-params-ui): duplicate model names leading to rendering issues (#6107) 2025-03-20 10:40:59 +01:00
Marc KlingenandGitHub fa462a8bc4 chore(ui): improve padding in peek view header (#6099) 2025-03-19 18:59:29 +00:00
David Alexander PfeifferandGitHub 695b65fe0d chore(tests): fully unpin node version from temporary fix #6063 (#6096) 2025-03-19 15:38:36 +00:00
Hassieb PakzadGitHubjake goldenellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
955388e846 feat(llm-connections): add atla adapter (#6088)
* feat: enable Atla integration (#6022)

* push

* push

* Update packages/shared/src/server/llm/fetchLLMCompletion.ts

* Update web/src/features/public-api/components/CreateLLMApiKeyForm.tsx

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Revert "Update web/src/features/public-api/components/CreateLLMApiKeyForm.tsx"

This reverts commit efb2d3b359a85680224876481421d31bfbd44fd3.

---------

Co-authored-by: jake golden <jackson.golden@gmail.com>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-03-19 12:36:12 +00:00
David Alexander PfeifferandGitHub c6864ed8d9 fix(tests): disable --experimental-require-module (default since Node 20.19.0) since it breaks jest-based testing (#6075)
* pin node to problematic version for working on a fix

* added cross-env and disabled the feature flag causing the testing issue - long term fix should probably be switching to vitest

* unpin node version since problematic feature is now disabled fo affected tests
2025-03-19 11:04:59 +00:00
marliessophieandGitHub 917516fa91 chore(ui): control for more specific onClick actions in table with peek view (#6086) 2025-03-19 10:25:03 +00:00
David Alexander PfeifferandGitHub 19017a6709 fix(ui): OpenAiMessageView now renders messages where content is an empty string instead of ignoring them (#6068)
* fix(ui): IOPreview now renders messages where content is an empty string instead of ignoring them

* removed debug logs

* OpenAiMessageView first filters valid messages to render and then executes the "show x more ..." logic, this way we ensure that there can never be a mismatch between messages items and the amount of fields we support displaying

* Render empty string quotation marks if content is empty but not null or undefined

* Change length check to falsy check for conditional display of empty quotes if content is empty

* cleaner falsy check for rendering empty string quotation marks
2025-03-19 09:41:32 +00:00
marliessophieandGitHub ca5e6569f9 fix(peek-view): ensure onClickRow action smoothly keeps peek view open; onClick of select cells independent of peek view state (#6079)
* fix(ui): stop propagation on traces table checkbox

* fix(peek-ui): handle onRowClick action correctly for peek view

* feat(peek): do not close upon any screen button or checkbox clicks

* push
2025-03-19 00:41:36 +00:00
marliessophieandGitHub 101942b98b fix(ui): padding on single prompt page (#6070) 2025-03-18 18:35:32 +00:00
marliessophieandGitHub 1188a21b02 feat(traces): add peek view on traces table (#6048)
* feat: adjust table to new style

* style: filter and column options

* fixup: add peek view

* feat: add detail navigation

* chore: remove duplicated code

* chore: style settings tables

* chore: pin first column on traces table

* style: adjust animation speed of peek view

* fix: padding on score configs table

* style: onClick cursor in table

* fix: eslint

* style

* chore: fix toggle for timeline

* push

* style: add padding
2025-03-18 17:25:52 +00:00
Marc KlingenandGitHub ec72dd840d chore(ui): render min-level hint in a single line (#6051) 2025-03-18 14:55:54 +00:00
181 changed files with 12208 additions and 3007 deletions
+31 -31
View File
@@ -172,7 +172,32 @@ OTEL_SERVICE_NAME="langfuse"
# LANGFUSE_INIT_USER_NAME=User Name
# LANGFUSE_INIT_USER_PASSWORD=password
# Redis configuration
# REDIS_HOST=
# REDIS_PORT=
# REDIS_AUTH=
# REDIS_CONNECTION_STRING=
# REDIS_ENABLE_AUTO_PIPELINING=
# Cache configuration
# LANGFUSE_CACHE_API_KEY_ENABLED=
# LANGFUSE_CACHE_API_KEY_TTL_SECONDS=
# LANGFUSE_CACHE_PROMPT_ENABLED=
# LANGFUSE_CACHE_PROMPT_TTL_SECONDS=
# Clickhouse configuration
# CLICKHOUSE_URL=
# CLICKHOUSE_CLUSTER_NAME=default
# CLICKHOUSE_DB=default
# CLICKHOUSE_USER=
# CLICKHOUSE_PASSWORD=
# CLICKHOUSE_CLUSTER_ENABLED=true
# Ingestion configuration
# LANGFUSE_INGESTION_QUEUE_DELAY_MS=
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_BATCH_SIZE=
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=
# LANGFUSE_INGESTION_CLICKHOUSE_MAX_ATTEMPTS=
### START Enterprise Edition Configuration
@@ -241,19 +266,8 @@ OTEL_SERVICE_NAME="langfuse"
# Admin API
# ADMIN_API_KEY=
# Redis
# REDIS_HOST=
# REDIS_PORT=
# REDIS_AUTH=
# REDIS_CONNECTION_STRING=
# REDIS_ENABLE_AUTO_PIPELINING=
# Cache configuration
# LANGFUSE_CACHE_API_KEY_ENABLED=
# LANGFUSE_CACHE_API_KEY_TTL_SECONDS=
# LANGFUSE_CACHE_PROMPT_ENABLED=
# LANGFUSE_CACHE_PROMPT_TTL_SECONDS=
# LANGFUSE_CACHE_MODEL_MATCH_ENABLED=
# LANGFUSE_CACHE_MODEL_MATCH_TTL_SECONDS=
# Rate limiting
# LANGFUSE_RATE_LIMITS_ENABLED=
@@ -262,27 +276,13 @@ OTEL_SERVICE_NAME="langfuse"
# STRIPE_SECRET_KEY=
# STRIPE_WEBHOOK_SIGNING_SECRET=
# Betterstack Status Page
# BETTERSTACK_UPTIME_API_KEY=
# BETTERSTACK_UPTIME_STATUS_PAGE_ID=
### END Langfuse Cloud Config
### START Langfuse V3 Ingestion
# Clickhouse
# CLICKHOUSE_MIGRATION_URL=
# CLICKHOUSE_URL=
# CLICKHOUSE_USER=
# CLICKHOUSE_PASSWORD=
# CLICKHOUSE_DB=
# Ingestion
# LANGFUSE_INGESTION_QUEUE_DELAY_MS=
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_BATCH_SIZE=
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=
# LANGFUSE_INGESTION_CLICKHOUSE_MAX_ATTEMPTS=
### END Langfuse V3 Ingestion
### START Langfuse CI Config
# LANGFUSE_INIT_ORG_CLOUD_PLAN=
@@ -56,6 +56,12 @@ jobs:
--build-arg SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} \
--build-arg SENTRY_ORG=${{ vars.SENTRY_ORG }} \
--build-arg SENTRY_PROJECT=${{ vars.SENTRY_PROJECT }} \
--build-arg NEXT_PUBLIC_DATADOG_APPLICATION_ID=${{ vars.NEXT_PUBLIC_DATADOG_APPLICATION_ID }} \
--build-arg NEXT_PUBLIC_DATADOG_CLIENT_TOKEN=${{ vars.NEXT_PUBLIC_DATADOG_CLIENT_TOKEN }} \
--build-arg NEXT_PUBLIC_DATADOG_SITE=${{ vars.NEXT_PUBLIC_DATADOG_SITE }} \
--build-arg NEXT_PUBLIC_DATADOG_ENVIRONMENT=${{ vars.NEXT_PUBLIC_DATADOG_ENVIRONMENT }} \
--build-arg NEXT_PUBLIC_DATADOG_SESSION_SAMPLE_RATE=${{ vars.NEXT_PUBLIC_DATADOG_SESSION_SAMPLE_RATE }} \
--build-arg NEXT_PUBLIC_DATADOG_SESSION_REPLAY_SAMPLE_RATE=${{ vars.NEXT_PUBLIC_DATADOG_SESSION_REPLAY_SAMPLE_RATE }} \
.
docker push $REGISTRY/$REPOSITORY:$IMAGE_TAG
- name: Render AWS ECS Task Definition
+7 -7
View File
@@ -40,7 +40,7 @@ jobs:
version: 9.5.0
- uses: actions/setup-node@v4
with:
node-version: "20.18.3"
node-version: 20
cache: "pnpm"
cache-dependency-path: "pnpm-lock.yaml"
- name: install dependencies
@@ -97,7 +97,7 @@ jobs:
name: tests-web-sync (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }})
strategy:
matrix:
node-version: ["20.18.3"]
node-version: [20]
postgres-version: [12, 15]
steps:
- name: Set Swap Space
@@ -174,7 +174,7 @@ jobs:
name: tests-web-async (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }}, mode${{ matrix.blob-provider }})
strategy:
matrix:
node-version: ["20.18.3"]
node-version: [20]
postgres-version: [12, 15]
blob-provider: ["", "-azure"]
steps:
@@ -250,7 +250,7 @@ jobs:
name: tests-worker (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }}, mode${{ matrix.blob-provider }})
strategy:
matrix:
node-version: ["20.18.3"]
node-version: [20]
postgres-version: [12, 15]
blob-provider: ["", "-azure"]
steps:
@@ -322,7 +322,7 @@ jobs:
version: 9.5.0
- uses: actions/setup-node@v4
with:
node-version: "20.18.3"
node-version: 20
cache: "pnpm"
cache-dependency-path: "pnpm-lock.yaml"
- name: Login to Docker Hub
@@ -372,7 +372,7 @@ jobs:
version: 9.5.0
- uses: actions/setup-node@v4
with:
node-version: "20.18.3"
node-version: 20
cache: "pnpm"
cache-dependency-path: "pnpm-lock.yaml"
- name: install dependencies
@@ -459,7 +459,7 @@ jobs:
- name: Setup node
uses: actions/setup-node@v4
with:
node-version: "20.18.3"
node-version: 20
cache-dependency-path: "pnpm-lock.yaml"
- name: Checkout
uses: actions/checkout@v4
+22
View File
@@ -0,0 +1,22 @@
name: Close inactive issues
on:
schedule:
- cron: "30 1 * * *"
jobs:
close-issues:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9
with:
days-before-issue-stale: 30
days-before-issue-close: 14
stale-issue-label: "stale"
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale. Please reopen if the issue persists."
days-before-pr-stale: -1
days-before-pr-close: -1
repo-token: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -1 +1 @@
v20.18.3
v20
+1 -1
View File
@@ -28,7 +28,7 @@
"@opentelemetry/api": ">=1.0.0 <1.10.0",
"axios": "^1.8.2",
"https-proxy-agent": "^7.0.6",
"next": "^14.2.21",
"next": "^14.2.25",
"next-auth": "^4.24.11",
"zod": "^3.23.8"
},
+4 -4
View File
@@ -400,8 +400,8 @@ types:
prompt_tokens: integer
completion_tokens: integer
total_tokens: integer
prompt_tokens_details: optional<map<string, integer>>
completion_tokens_details: optional<map<string, integer>>
prompt_tokens_details: optional<map<string, optional<integer>>>
completion_tokens_details: optional<map<string, optional<integer>>>
OpenAIResponseUsageSchema:
docs: OpenAI Usage schema from Response API
@@ -409,8 +409,8 @@ types:
input_tokens: integer
output_tokens: integer
total_tokens: integer
input_tokens_details: optional<map<string, integer>>
output_tokens_details: optional<map<string, integer>>
input_tokens_details: optional<map<string, optional<integer>>>
output_tokens_details: optional<map<string, optional<integer>>>
UsageDetails:
discriminated: false
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.43.0",
"version": "3.45.2",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
+1
View File
@@ -84,6 +84,7 @@
"langchain": "^0.3.15",
"langfuse-langchain": "3.30.3",
"lodash": "^4.17.21",
"lossless-json": "^4.0.2",
"next-auth": "^4.24.11",
"nodemailer": "^6.9.15",
"prisma-extension-kysely": "^2.1.0",
+24 -8
View File
@@ -80,6 +80,12 @@ export const JobExecutionStatus = {
CANCELLED: "CANCELLED"
} as const;
export type JobExecutionStatus = (typeof JobExecutionStatus)[keyof typeof JobExecutionStatus];
export const BlobStorageIntegrationType = {
S3: "S3",
S3_COMPATIBLE: "S3_COMPATIBLE",
AZURE_BLOB_STORAGE: "AZURE_BLOB_STORAGE"
} as const;
export type BlobStorageIntegrationType = (typeof BlobStorageIntegrationType)[keyof typeof BlobStorageIntegrationType];
export type Account = {
id: string;
user_id: string;
@@ -188,6 +194,23 @@ export type BillingMeterBackup = {
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
};
export type BlobStorageIntegration = {
project_id: string;
type: BlobStorageIntegrationType;
bucket_name: string;
prefix: string;
access_key_id: string;
secret_access_key: string;
region: string;
endpoint: string | null;
force_path_style: boolean;
next_sync_at: Timestamp | null;
last_sync_at: Timestamp | null;
enabled: boolean;
export_frequency: string;
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
};
export type Comment = {
id: string;
project_id: string;
@@ -502,13 +525,6 @@ export type PromptDependency = {
child_label: string | null;
child_version: number | null;
};
export type QueueBackUp = {
id: string;
project_id: string | null;
queue_name: string;
content: unknown;
created_at: Generated<Timestamp>;
};
export type ScoreConfig = {
id: string;
created_at: Generated<Timestamp>;
@@ -579,6 +595,7 @@ export type DB = {
background_migrations: BackgroundMigration;
batch_exports: BatchExport;
billing_meter_backups: BillingMeterBackup;
blob_storage_integrations: BlobStorageIntegration;
comments: Comment;
cron_jobs: CronJobs;
dataset_items: DatasetItem;
@@ -602,7 +619,6 @@ export type DB = {
projects: Project;
prompt_dependencies: PromptDependency;
prompts: Prompt;
queue_backups: QueueBackUp;
score_configs: ScoreConfig;
scores: LegacyPrismaScore;
Session: Session;
@@ -0,0 +1,2 @@
-- DropTable
DROP TABLE "queue_backups";
@@ -0,0 +1,26 @@
-- CreateEnum
CREATE TYPE "BlobStorageIntegrationType" AS ENUM ('S3', 'S3_COMPATIBLE', 'AZURE_BLOB_STORAGE');
-- CreateTable
CREATE TABLE "blob_storage_integrations" (
"project_id" TEXT NOT NULL,
"type" "BlobStorageIntegrationType" NOT NULL,
"bucket_name" TEXT NOT NULL,
"prefix" TEXT NOT NULL,
"access_key_id" TEXT NOT NULL,
"secret_access_key" TEXT NOT NULL,
"region" TEXT NOT NULL,
"endpoint" TEXT,
"force_path_style" BOOLEAN NOT NULL,
"next_sync_at" TIMESTAMP(3),
"last_sync_at" TIMESTAMP(3),
"enabled" BOOLEAN NOT NULL,
"export_frequency" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "blob_storage_integrations_pkey" PRIMARY KEY ("project_id")
);
-- AddForeignKey
ALTER TABLE "blob_storage_integrations" ADD CONSTRAINT "blob_storage_integrations_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+69 -46
View File
@@ -109,38 +109,39 @@ model Organization {
}
model Project {
id String @id @default(cuid())
orgId String @map("org_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
name String
retentionDays Int? @map("retention_days")
projectMembers ProjectMembership[]
organization Organization @relation(fields: [orgId], references: [id], onUpdate: Cascade, onDelete: Cascade)
apiKeys ApiKey[]
dataset Dataset[]
invitations MembershipInvitation[]
sessions TraceSession[]
Prompt Prompt[]
Model Model[]
EvalTemplate EvalTemplate[]
JobConfiguration JobConfiguration[]
JobExecution JobExecution[]
LlmApiKeys LlmApiKeys[]
PosthogIntegration PosthogIntegration[]
scoreConfig ScoreConfig[]
BatchExport BatchExport[]
comment Comment[]
annotationQueue AnnotationQueue[]
annotationQueueItem AnnotationQueueItem[]
TraceMedia TraceMedia[]
Media Media[]
ObservationMedia ObservationMedia[]
LegacyTrace LegacyPrismaTrace[]
LegacyObservation LegacyPrismaObservation[]
LegacyScore LegacyPrismaScore[]
PromptDependency PromptDependency[]
id String @id @default(cuid())
orgId String @map("org_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
name String
retentionDays Int? @map("retention_days")
projectMembers ProjectMembership[]
organization Organization @relation(fields: [orgId], references: [id], onUpdate: Cascade, onDelete: Cascade)
apiKeys ApiKey[]
dataset Dataset[]
invitations MembershipInvitation[]
sessions TraceSession[]
Prompt Prompt[]
Model Model[]
EvalTemplate EvalTemplate[]
JobConfiguration JobConfiguration[]
JobExecution JobExecution[]
LlmApiKeys LlmApiKeys[]
PosthogIntegration PosthogIntegration[]
BlobStorageIntegration BlobStorageIntegration[]
scoreConfig ScoreConfig[]
BatchExport BatchExport[]
comment Comment[]
annotationQueue AnnotationQueue[]
annotationQueueItem AnnotationQueueItem[]
TraceMedia TraceMedia[]
Media Media[]
ObservationMedia ObservationMedia[]
LegacyTrace LegacyPrismaTrace[]
LegacyObservation LegacyPrismaObservation[]
LegacyScore LegacyPrismaScore[]
PromptDependency PromptDependency[]
@@index([orgId])
@@map("projects")
@@ -675,8 +676,8 @@ model PromptDependency {
childLabel String? @map("child_label")
childVersion Int? @map("child_version")
@@index([projectId, parentId])
@@index([projectId, childName])
@@index([projectId, parentId], map: "prompt_dependencies_project_id_parent_id")
@@index([projectId, childName], map: "prompt_dependencies_project_id_child_name")
@@map("prompt_dependencies")
}
@@ -860,7 +861,6 @@ model SsoConfig {
authConfig Json? @map("auth_config")
// e.g. { "clientId": "1234", "clientSecret": "5678" }, null if credentials from env should be used
// secrets like clientSecret are encrypted on the application level
@@map("sso_configs")
}
@@ -876,6 +876,40 @@ model PosthogIntegration {
@@map("posthog_integrations")
}
model BlobStorageIntegration {
projectId String @id @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
// Blob Storage Config
type BlobStorageIntegrationType @map("type")
bucketName String @map("bucket_name")
prefix String @map("prefix")
accessKeyId String @map("access_key_id")
secretAccessKey String @map("secret_access_key")
region String @map("region")
endpoint String? @map("endpoint")
forcePathStyle Boolean @map("force_path_style")
// Integration Config
nextSyncAt DateTime? @map("next_sync_at")
lastSyncAt DateTime? @map("last_sync_at")
enabled Boolean
exportFrequency String @map("export_frequency")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
@@map("blob_storage_integrations")
}
enum BlobStorageIntegrationType {
S3
S3_COMPATIBLE
AZURE_BLOB_STORAGE
@@map("BlobStorageIntegrationType")
}
model BatchExport {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map("created_at")
@@ -953,17 +987,6 @@ model ObservationMedia {
@@map("observation_media")
}
model QueueBackUp {
id String @id @default(cuid())
projectId String? @map("project_id")
queueName String @map("queue_name")
content Json
createdAt DateTime @default(now()) @map("created_at")
@@map("queue_backups")
}
model BillingMeterBackup {
// unique
stripeCustomerId String @map("stripe_customer_id")
+5 -8
View File
@@ -10,7 +10,7 @@ const EnvSchema = z.object({
REDIS_PORT: z.coerce
.number({
description:
".env files convert numbers to strings, therefoore we have to enforce them to be numbers",
".env files convert numbers to strings, therefore we have to enforce them to be numbers",
})
.positive()
.max(65536, `options.port should be >= 0 and < 65536`)
@@ -50,10 +50,7 @@ const EnvSchema = z.object({
ENABLE_AWS_CLOUDWATCH_METRIC_PUBLISHING: z
.enum(["true", "false"])
.default("false"),
LANGFUSE_S3_CONCURRENT_WRITES: z.coerce
.number()
.positive()
.default(50),
LANGFUSE_S3_CONCURRENT_WRITES: z.coerce.number().positive().default(50),
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: z.string({
required_error: "Langfuse requires a bucket name for S3 Event Uploads.",
}),
@@ -75,9 +72,9 @@ const EnvSchema = z.object({
.enum(["true", "false"])
.default("false"),
LANGFUSE_CUSTOM_SSO_EMAIL_CLAIM: z.string().default('email'),
LANGFUSE_CUSTOM_SSO_NAME_CLAIM: z.string().default('name'),
LANGFUSE_CUSTOM_SSO_SUB_CLAIM: z.string().default('sub'),
LANGFUSE_CUSTOM_SSO_EMAIL_CLAIM: z.string().default("email"),
LANGFUSE_CUSTOM_SSO_NAME_CLAIM: z.string().default("name"),
LANGFUSE_CUSTOM_SSO_SUB_CLAIM: z.string().default("sub"),
});
export const env: z.infer<typeof EnvSchema> =
@@ -2,6 +2,11 @@ export const ClickhouseTableNames = {
traces: "traces",
observations: "observations",
scores: "scores",
// Virtual tables for dashboards
// TODO: Check if we can do this more elegantly
scores_numeric: "scores_numeric",
scores_categorical: "scores_categorical",
} as const;
export type ClickhouseTableName = keyof typeof ClickhouseTableNames;
+3 -1
View File
@@ -17,7 +17,6 @@ export * from "./clickhouse/schemaUtils";
export * from "./clickhouse/schema";
export * from "./repositories/definitions";
export * from "../server/ingestion/types";
export * from "./ingestion/modelMatch";
export * from "./ingestion/processEventBatch";
export * from "../server/ingestion/types";
export * from "../server/ingestion/validateAndInflateScore";
@@ -35,6 +34,8 @@ export * from "./redis/batchActionQueue";
export * from "./redis/ingestionQueue";
export * from "./redis/postHogIntegrationQueue";
export * from "./redis/postHogIntegrationProcessingQueue";
export * from "./redis/blobStorageIntegrationQueue";
export * from "./redis/blobStorageIntegrationProcessingQueue";
export * from "./redis/dataRetentionQueue";
export * from "./redis/dataRetentionProcessingQueue";
export * from "./redis/coreDataS3ExportQueue";
@@ -50,6 +51,7 @@ export * from "./queries";
export * from "./repositories";
export * from "./redis/evalExecutionQueue";
export * from "./services/sessions-ui-table-service";
export * from "./services/datasets-ui-table-service";
// test utils
export * from "./test-utils";
@@ -1,64 +0,0 @@
import { Model, Prisma } from "@prisma/client";
import { prisma } from "../../db";
import { Observation } from "../repositories";
export async function findModel(p: {
event: {
projectId: string;
model?: string;
unit?: string;
startTime?: Date;
};
existingDbObservation?: Omit<Observation, "input" | "output">;
}): Promise<Model | null> {
const { event, existingDbObservation } = p;
// either get the model from the existing observation
// or match pattern on the user provided model name
const modelCondition = event.model
? Prisma.sql`AND ${event.model} ~ match_pattern`
: existingDbObservation?.internalModel
? Prisma.sql`AND model_name = ${existingDbObservation.internalModel}`
: undefined;
if (!modelCondition) return null;
// unit based on the current event or the existing observation, both can be undefined
const mergedUnit = event.unit ?? existingDbObservation?.unit;
const unitCondition = mergedUnit
? Prisma.sql`AND unit = ${mergedUnit}`
: Prisma.empty;
const sql = Prisma.sql`
SELECT
id,
created_at AS "createdAt",
updated_at AS "updatedAt",
project_id AS "projectId",
model_name AS "modelName",
match_pattern AS "matchPattern",
start_date AS "startDate",
input_price AS "inputPrice",
output_price AS "outputPrice",
total_price AS "totalPrice",
unit,
tokenizer_id AS "tokenizerId",
tokenizer_config AS "tokenizerConfig"
FROM
models
WHERE (project_id = ${event.projectId}
OR project_id IS NULL)
${modelCondition}
${unitCondition}
AND (start_date IS NULL OR start_date <= ${
event.startTime ? new Date(event.startTime) : new Date()
}::timestamp with time zone at time zone 'UTC')
ORDER BY
project_id ASC,
start_date DESC NULLS LAST
LIMIT 1
`;
const foundModels = await prisma.$queryRaw<Array<Model>>(sql);
return foundModels[0] ?? null;
}
+20 -12
View File
@@ -81,10 +81,10 @@ const OpenAICompletionUsageSchema = z
completion_tokens: z.number().int().nonnegative(),
total_tokens: z.number().int().nonnegative(),
prompt_tokens_details: z
.record(z.string(), z.number().int().nonnegative())
.record(z.string(), z.number().int().nonnegative().nullish())
.nullish(),
completion_tokens_details: z
.record(z.string(), z.number().int().nonnegative())
.record(z.string(), z.number().int().nonnegative().nullish())
.nullish(),
})
.strict()
@@ -110,15 +110,19 @@ const OpenAICompletionUsageSchema = z
if (prompt_tokens_details) {
for (const [key, value] of Object.entries(prompt_tokens_details)) {
result[`input_${key}`] = value;
result.input = Math.max(result.input - (value ?? 0), 0);
if (value !== null && value !== undefined) {
result[`input_${key}`] = value;
result.input = Math.max(result.input - (value ?? 0), 0);
}
}
}
if (completion_tokens_details) {
for (const [key, value] of Object.entries(completion_tokens_details)) {
result[`output_${key}`] = value;
result.output = Math.max(result.output - (value ?? 0), 0);
if (value !== null && value !== undefined) {
result[`output_${key}`] = value;
result.output = Math.max(result.output - (value ?? 0), 0);
}
}
}
@@ -133,10 +137,10 @@ const OpenAIResponseUsageSchema = z
output_tokens: z.number().int().nonnegative(),
total_tokens: z.number().int().nonnegative(),
input_tokens_details: z
.record(z.string(), z.number().int().nonnegative())
.record(z.string(), z.number().int().nonnegative().nullish())
.nullish(),
output_tokens_details: z
.record(z.string(), z.number().int().nonnegative())
.record(z.string(), z.number().int().nonnegative().nullish())
.nullish(),
})
.strict()
@@ -162,15 +166,19 @@ const OpenAIResponseUsageSchema = z
if (input_tokens_details) {
for (const [key, value] of Object.entries(input_tokens_details)) {
result[`input_${key}`] = value;
result.input = Math.max(result.input - (value ?? 0), 0);
if (value !== null && value !== undefined) {
result[`input_${key}`] = value;
result.input = Math.max(result.input - (value ?? 0), 0);
}
}
}
if (output_tokens_details) {
for (const [key, value] of Object.entries(output_tokens_details)) {
result[`output_${key}`] = value;
result.output = Math.max(result.output - (value ?? 0), 0);
if (value !== null && value !== undefined) {
result[`output_${key}`] = value;
result.output = Math.max(result.output - (value ?? 0), 0);
}
}
}
@@ -160,35 +160,49 @@ export const addUserToSpan = (
export const getTracer = (name: string) => opentelemetry.trace.getTracer(name);
const cloudWatchClient = new CloudWatchClient();
const cloudWatchLastSubmitted: Record<string, number> = {};
const sendCloudWatchMetric = (key: string, value: number | undefined) => {
const currentTime = Date.now();
const interval = 30 * 1000;
let lastFlushTime = 0;
let metricCache: Record<string, number> = {};
// Check if the function has been executed in the last 30s for this key
if (
!cloudWatchLastSubmitted[key] ||
currentTime - cloudWatchLastSubmitted[key] >= interval
) {
cloudWatchLastSubmitted[key] = currentTime;
cloudWatchClient
.send(
new PutMetricDataCommand({
Namespace: "Langfuse",
MetricData: [
{
MetricName: key,
Value: value ?? 0,
},
],
}),
)
.catch((error) => {
logger.warn("Failed to send metric to CloudWatch", error);
});
// Caches metrics and flushes them on schedule
const sendCloudWatchMetric = (key: string, value: number, replace: boolean) => {
// Store the latest value for each metric key. If replace is false (e.g. for increments) we add the value to the existing value.
metricCache[key] = replace ? value : (metricCache[key] || 0) + value;
const currentTime = Date.now();
const flushInterval = 30 * 1000; // 30 seconds
// Check if it's time to flush the metrics
if (currentTime - lastFlushTime >= flushInterval) {
flushMetricsToCloudWatch();
}
};
// Flush all cached metrics in a single API call
const flushMetricsToCloudWatch = () => {
if (Object.keys(metricCache).length === 0) return;
lastFlushTime = Date.now();
const metricData = Object.entries(metricCache).map(([key, value]) => ({
MetricName: key,
Value: value,
}));
// Clear the cache after preparing the metrics
metricCache = {};
cloudWatchClient
.send(
new PutMetricDataCommand({
Namespace: "Langfuse",
MetricData: metricData,
}),
)
.catch((error) => {
logger.warn("Failed to send metrics to CloudWatch", error);
});
};
export const recordGauge = (
stat: string,
value?: number | undefined,
@@ -199,7 +213,7 @@ export const recordGauge = (
| undefined,
) => {
if (env.ENABLE_AWS_CLOUDWATCH_METRIC_PUBLISHING === "true") {
sendCloudWatchMetric(stat, value);
sendCloudWatchMetric(stat, value ?? 0, true);
}
dd.dogstatsd.gauge(stat, value, tags);
};
@@ -210,7 +224,7 @@ export const recordIncrement = (
tags?: { [tag: string]: string | number } | undefined,
) => {
if (env.ENABLE_AWS_CLOUDWATCH_METRIC_PUBLISHING === "true") {
sendCloudWatchMetric(stat, value);
sendCloudWatchMetric(stat, value ?? 1, false);
}
dd.dogstatsd.increment(stat, value, tags);
};
@@ -220,9 +234,6 @@ export const recordHistogram = (
value?: number | undefined,
tags?: { [tag: string]: string | number } | undefined,
) => {
if (env.ENABLE_AWS_CLOUDWATCH_METRIC_PUBLISHING === "true") {
sendCloudWatchMetric(stat, value);
}
dd.dogstatsd.histogram(stat, value, tags);
};
@@ -184,7 +184,7 @@ export async function fetchLLMCompletion(
azureOpenAIApiKey: apiKey,
azureOpenAIBasePath: baseURL,
azureOpenAIApiDeploymentName: modelParams.model,
azureOpenAIApiVersion: "2024-02-01",
azureOpenAIApiVersion: "2025-02-01-preview",
temperature: modelParams.temperature,
maxTokens: modelParams.max_tokens,
topP: modelParams.top_p,
@@ -237,6 +237,22 @@ export async function fetchLLMCompletion(
maxRetries,
apiKey,
});
} else if (modelParams.adapter === LLMAdapter.Atla) {
// Atla models do not support:
// - temperature
// - max_tokens
// - top_p
chatModel = new ChatOpenAI({
openAIApiKey: apiKey,
modelName: modelParams.model,
callbacks: finalCallbacks,
maxRetries,
configuration: {
baseURL: baseURL,
defaultHeaders: extraHeaders,
},
timeout: 1000 * 60, // 1 minute timeout
});
} else {
// eslint-disable-next-line no-unused-vars
const _exhaustiveCheck: never = modelParams.adapter;
+4
View File
@@ -19,6 +19,7 @@ export type ChatMessageWithId = ChatMessage & { id: string };
export enum LLMAdapter {
Anthropic = "anthropic",
OpenAI = "openai",
Atla = "atla",
Azure = "azure",
Bedrock = "bedrock",
VertexAI = "google-vertex-ai",
@@ -152,6 +153,8 @@ export const googleAIStudioModels = [
"gemini-1.5-flash-8b",
] as const;
export const atlaModels = ["atla-selene", "atla-selene-20250214"] as const;
export type AnthropicModel = (typeof anthropicModels)[number];
export type VertexAIModel = (typeof vertexAIModels)[number];
export const supportedModels = {
@@ -161,6 +164,7 @@ export const supportedModels = {
[LLMAdapter.GoogleAIStudio]: googleAIStudioModels,
[LLMAdapter.Azure]: [],
[LLMAdapter.Bedrock]: [],
[LLMAdapter.Atla]: atlaModels,
} as const;
export type LLMFunctionCall = {
@@ -18,3 +18,4 @@ export {
type ClickhouseOperator,
} from "./clickhouse-sql/clickhouse-filter";
export { orderByToClickhouseSql } from "./clickhouse-sql/orderby-factory";
export { createFilterFromFilterState } from "./clickhouse-sql/factory";
+16
View File
@@ -56,6 +56,9 @@ export const EvalExecutionEvent = z.object({
export const PostHogIntegrationProcessingEventSchema = z.object({
projectId: z.string(),
});
export const BlobStorageIntegrationProcessingEventSchema = z.object({
projectId: z.string(),
});
export const ExperimentCreateEventSchema = z.object({
projectId: z.string(),
datasetId: z.string(),
@@ -146,6 +149,9 @@ export type DataRetentionProcessingEventType = z.infer<
export type BatchActionProcessingEventType = z.infer<
typeof BatchActionProcessingEventSchema
>;
export type BlobStorageIntegrationProcessingEventType = z.infer<
typeof BlobStorageIntegrationProcessingEventSchema
>;
export enum QueueName {
TraceUpsert = "trace-upsert", // Ingestion pipeline adds events on each Trace upsert
@@ -160,6 +166,8 @@ export enum QueueName {
ExperimentCreate = "experiment-create-queue",
PostHogIntegrationQueue = "posthog-integration-queue",
PostHogIntegrationProcessingQueue = "posthog-integration-processing-queue",
BlobStorageIntegrationQueue = "blobstorage-integration-queue",
BlobStorageIntegrationProcessingQueue = "blobstorage-integration-processing-queue",
CoreDataS3ExportQueue = "core-data-s3-export-queue",
MeteringDataPostgresExportQueue = "metering-data-postgres-export-queue",
DataRetentionQueue = "data-retention-queue",
@@ -182,6 +190,8 @@ export enum QueueJobs {
ExperimentCreateJob = "experiment-create-job",
PostHogIntegrationJob = "posthog-integration-job",
PostHogIntegrationProcessingJob = "posthog-integration-processing-job",
BlobStorageIntegrationJob = "blobstorage-integration-job",
BlobStorageIntegrationProcessingJob = "blobstorage-integration-processing-job",
CoreDataS3ExportJob = "core-data-s3-export-job",
MeteringDataPostgresExportJob = "metering-data-postgres-export-job",
DataRetentionJob = "data-retention-job",
@@ -276,4 +286,10 @@ export type TQueueJobTypes = {
payload: CreateEvalQueueEventType;
name: QueueJobs.CreateEvalJob;
};
[QueueName.BlobStorageIntegrationProcessingQueue]: {
timestamp: Date;
id: string;
payload: BlobStorageIntegrationProcessingEventType;
name: QueueJobs.BlobStorageIntegrationProcessingJob;
};
};
@@ -0,0 +1,40 @@
import { Queue } from "bullmq";
import { QueueName } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { logger } from "../logger";
export class BlobStorageIntegrationProcessingQueue {
private static instance: Queue | null = null;
public static getInstance(): Queue | null {
if (BlobStorageIntegrationProcessingQueue.instance) {
return BlobStorageIntegrationProcessingQueue.instance;
}
const newRedis = createNewRedisInstance({
enableOfflineQueue: false,
...redisQueueRetryOptions,
});
BlobStorageIntegrationProcessingQueue.instance = newRedis
? new Queue(QueueName.BlobStorageIntegrationProcessingQueue, {
connection: newRedis,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100_000,
attempts: 5,
backoff: {
type: "exponential",
delay: 5000,
},
},
})
: null;
BlobStorageIntegrationProcessingQueue.instance?.on("error", (err) => {
logger.error("BlobStorageIntegrationProcessingQueue error", err);
});
return BlobStorageIntegrationProcessingQueue.instance;
}
}
@@ -0,0 +1,55 @@
import { Queue } from "bullmq";
import { QueueName, QueueJobs } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { logger } from "../logger";
export class BlobStorageIntegrationQueue {
private static instance: Queue | null = null;
public static getInstance(): Queue | null {
if (BlobStorageIntegrationQueue.instance) {
return BlobStorageIntegrationQueue.instance;
}
const newRedis = createNewRedisInstance({
enableOfflineQueue: false,
...redisQueueRetryOptions,
});
BlobStorageIntegrationQueue.instance = newRedis
? new Queue(QueueName.BlobStorageIntegrationQueue, {
connection: newRedis,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
attempts: 5,
backoff: {
type: "exponential",
delay: 5000,
},
},
})
: null;
BlobStorageIntegrationQueue.instance?.on("error", (err) => {
logger.error("BlobStorageIntegrationQueue error", err);
});
if (BlobStorageIntegrationQueue.instance) {
logger.debug("Scheduling jobs for BlobStorageIntegrationQueue");
BlobStorageIntegrationQueue.instance
.add(
QueueJobs.BlobStorageIntegrationJob,
{},
{
repeat: { pattern: "20 * * * *" }, // every hour at 20 minutes past
},
)
.catch((err) => {
logger.error("Error adding BlobStorageIntegrationJob schedule", err);
});
}
return BlobStorageIntegrationQueue.instance;
}
}
@@ -11,6 +11,8 @@ import { TraceDeleteQueue } from "./traceDelete";
import { ProjectDeleteQueue } from "./projectDelete";
import { PostHogIntegrationQueue } from "./postHogIntegrationQueue";
import { PostHogIntegrationProcessingQueue } from "./postHogIntegrationProcessingQueue";
import { BlobStorageIntegrationQueue } from "./blobStorageIntegrationQueue";
import { BlobStorageIntegrationProcessingQueue } from "./blobStorageIntegrationProcessingQueue";
import { CoreDataS3ExportQueue } from "./coreDataS3ExportQueue";
import { MeteringDataPostgresExportQueue } from "./meteringDataPostgresExportQueue";
import { DataRetentionQueue } from "./dataRetentionQueue";
@@ -43,6 +45,10 @@ export function getQueue(queueName: QueueName): Queue | null {
return PostHogIntegrationQueue.getInstance();
case QueueName.PostHogIntegrationProcessingQueue:
return PostHogIntegrationProcessingQueue.getInstance();
case QueueName.BlobStorageIntegrationQueue:
return BlobStorageIntegrationQueue.getInstance();
case QueueName.BlobStorageIntegrationProcessingQueue:
return BlobStorageIntegrationProcessingQueue.getInstance();
case QueueName.IngestionSecondaryQueue:
return SecondaryIngestionQueue.getInstance();
case QueueName.CoreDataS3ExportQueue:
@@ -253,7 +253,7 @@ export const groupTracesByTime = async (
}));
};
export const getObservationUsageByTime = async (
export const getTotalObservationUsageByTimeByModel = async (
projectId: string,
filter: FilterState,
) => {
@@ -286,8 +286,8 @@ export const getObservationUsageByTime = async (
const query = `
SELECT
${selectTimeseriesColumn(bucketSizeInSeconds, "start_time", "start_time")},
sumMap(usage_details) as units,
sumMap(cost_details) as cost,
sumMap(usage_details)['total'] as units,
sumMap(cost_details)['total'] as cost,
provided_model_name
FROM observations o FINAL
${tracesFilter ? "LEFT JOIN traces t ON o.trace_id = t.id AND o.project_id = t.project_id" : ""}
@@ -301,8 +301,8 @@ export const getObservationUsageByTime = async (
const result = await queryClickhouse<{
start_time: string;
units: Record<string, number>;
cost: Record<string, number>;
units: string;
cost: string;
provided_model_name: string;
}>({
query,
@@ -324,23 +324,209 @@ export const getObservationUsageByTime = async (
});
return result.map((row) => ({
start_time: parseClickhouseUTCDateTimeFormat(row.start_time),
units: Object.fromEntries(
Object.entries(row.units ?? {}).map(([key, value]) => [
key,
Number(value),
]),
),
cost: Object.fromEntries(
Object.entries(row.cost ?? {}).map(([key, value]) => [
key,
Number(value),
]),
),
provided_model_name: row.provided_model_name,
startTime: parseClickhouseUTCDateTimeFormat(row.start_time),
units: Number(row.units),
cost: Number(row.cost),
model: row.provided_model_name,
}));
};
export const getObservationCostByTypeByTime = async (
projectId: string,
filter: FilterState,
) => {
const { envFilter, remainingFilters } =
extractEnvironmentFilterFromFilters(filter);
const environmentFilter = new FilterList(
convertEnvFilterToClickhouseFilter(envFilter),
).apply();
const chFilter = new FilterList(
createFilterFromFilterState(remainingFilters, dashboardColumnDefinitions),
);
const appliedFilter = chFilter.apply();
const tracesFilter = chFilter.find((f) => f.clickhouseTable === "traces");
const timeFilter = tracesFilter
? (chFilter.find(
(f) =>
f.clickhouseTable === "observations" &&
f.field.includes("start_time") &&
(f.operator === ">=" || f.operator === ">"),
) as DateTimeFilter | undefined)
: undefined;
const [orderByQuery, orderByParams, bucketSizeInSeconds] = orderByTimeSeries(
filter,
"start_time",
);
const query = `
SELECT
start_time,
groupArray((cost_key, cost_sum)) AS costs
FROM (
SELECT
${selectTimeseriesColumn(bucketSizeInSeconds, "start_time", "start_time")},
cost_key,
SUM(cost) AS cost_sum
FROM
observations o FINAL
${tracesFilter ? "LEFT JOIN traces t ON o.trace_id = t.id AND o.project_id = t.project_id" : ""}
ARRAY JOIN
mapKeys(cost_details) AS cost_key,
mapValues(cost_details) AS cost
WHERE project_id = {projectId: String}
AND ${appliedFilter.query}
${environmentFilter.query ? `AND ${environmentFilter.query}` : ""}
${timeFilter ? `AND t.timestamp >= {traceTimestamp: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}` : ""}
GROUP BY
start_time,
cost_key
)
GROUP BY
start_time
${orderByQuery}
`;
const result = await queryClickhouse<{
start_time: string;
costs: Array<[string, number | null]>;
}>({
query,
params: {
projectId,
...appliedFilter.params,
...environmentFilter.params,
...orderByParams,
...(timeFilter
? { traceTimestamp: convertDateToClickhouseDateTime(timeFilter.value) }
: {}),
},
tags: {
feature: "dashboard",
type: "observationCostByTypeByTime",
kind: "analytic",
projectId,
},
});
const types = result.flatMap((row) => {
return row.costs.map((cost) => cost[0]);
});
const uniqueTypes = [...new Set(types)];
return result.flatMap((row) => {
const intervalStart = parseClickhouseUTCDateTimeFormat(row.start_time);
return uniqueTypes.map((type) => ({
intervalStart: intervalStart,
key: type,
sum: row.costs.find((cost) => cost[0] === type)?.[1]
? Number(row.costs.find((cost) => cost[0] === type)?.[1])
: 0,
}));
});
};
export const getObservationUsageByTypeByTime = async (
projectId: string,
filter: FilterState,
) => {
const { envFilter, remainingFilters } =
extractEnvironmentFilterFromFilters(filter);
const environmentFilter = new FilterList(
convertEnvFilterToClickhouseFilter(envFilter),
).apply();
const chFilter = new FilterList(
createFilterFromFilterState(remainingFilters, dashboardColumnDefinitions),
);
const appliedFilter = chFilter.apply();
const tracesFilter = chFilter.find((f) => f.clickhouseTable === "traces");
const timeFilter = tracesFilter
? (chFilter.find(
(f) =>
f.clickhouseTable === "observations" &&
f.field.includes("start_time") &&
(f.operator === ">=" || f.operator === ">"),
) as DateTimeFilter | undefined)
: undefined;
const [orderByQuery, orderByParams, bucketSizeInSeconds] = orderByTimeSeries(
filter,
"start_time",
);
const query = `
SELECT
start_time,
groupArray((usage_key, usage_sum)) AS usages
FROM (
SELECT
${selectTimeseriesColumn(bucketSizeInSeconds, "start_time", "start_time")} ,
usage_key,
SUM(usage) AS usage_sum
FROM
observations o FINAL
${tracesFilter ? "LEFT JOIN traces t ON o.trace_id = t.id AND o.project_id = t.project_id" : ""}
ARRAY JOIN
mapKeys(usage_details) AS usage_key,
mapValues(usage_details) AS usage
WHERE project_id = {projectId: String}
AND ${appliedFilter.query}
${environmentFilter.query ? `AND ${environmentFilter.query}` : ""}
${timeFilter ? `AND t.timestamp >= {traceTimestamp: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}` : ""}
GROUP BY
start_time,
usage_key
)
GROUP BY
start_time
${orderByQuery}
`;
const result = await queryClickhouse<{
start_time: string;
usages: Array<[string, number | null]>;
}>({
query,
params: {
projectId,
...appliedFilter.params,
...environmentFilter.params,
...orderByParams,
...(timeFilter
? { traceTimestamp: convertDateToClickhouseDateTime(timeFilter.value) }
: {}),
},
tags: {
feature: "dashboard",
type: "observationUsageByTime",
kind: "analytic",
projectId,
},
});
const types = result.flatMap((row) => {
return row.usages.map((usage) => usage[0]);
});
const uniqueTypes = [...new Set(types)];
return result.flatMap((row) => {
const intervalStart = parseClickhouseUTCDateTimeFormat(row.start_time);
return uniqueTypes.map((type) => ({
intervalStart: intervalStart,
key: type,
sum: row.usages.find((usage) => usage[0] === type)?.[1]
? Number(row.usages.find((usage) => usage[0] === type)?.[1])
: 0,
}));
});
};
export const getDistinctModels = async (
projectId: string,
filter: FilterState,
@@ -1324,6 +1324,59 @@ export const getTraceIdsForObservations = async (
}));
};
export const getObservationsForBlobStorageExport = function (
projectId: string,
minTimestamp: Date,
maxTimestamp: Date,
) {
const query = `
SELECT
id,
trace_id,
project_id,
environment,
type,
parent_observation_id,
start_time,
end_time,
name,
metadata,
level,
status_message,
version,
input,
output,
provided_model_name,
model_parameters,
usage_details,
cost_details,
completion_start_time,
prompt_name,
prompt_version
FROM observations FINAL
WHERE project_id = {projectId: String}
AND start_time >= {minTimestamp: DateTime64(3)}
AND start_time <= {maxTimestamp: DateTime64(3)}
`;
const records = queryClickhouseStream<Record<string, unknown>>({
query,
params: {
projectId,
minTimestamp: convertDateToClickhouseDateTime(minTimestamp),
maxTimestamp: convertDateToClickhouseDateTime(maxTimestamp),
},
tags: {
feature: "blobstorage",
type: "observation",
kind: "analytic",
projectId,
},
});
return records;
};
export const getGenerationsForPostHog = async function* (
projectId: string,
minTimestamp: Date,
@@ -846,6 +846,49 @@ export const getDistinctScoreNames = async (
return rows.map((row) => row.name);
};
export const getScoresForBlobStorageExport = function (
projectId: string,
minTimestamp: Date,
maxTimestamp: Date,
) {
const query = `
SELECT
id,
timestamp,
project_id,
environment,
trace_id,
observation_id,
name,
value,
source,
comment,
data_type,
string_value
FROM scores FINAL
WHERE project_id = {projectId: String}
AND timestamp >= {minTimestamp: DateTime64(3)}
AND timestamp <= {maxTimestamp: DateTime64(3)}
`;
const records = queryClickhouseStream<Record<string, unknown>>({
query,
params: {
projectId,
minTimestamp: convertDateToClickhouseDateTime(minTimestamp),
maxTimestamp: convertDateToClickhouseDateTime(maxTimestamp),
},
tags: {
feature: "blobstorage",
type: "score",
kind: "analytic",
projectId,
},
});
return records;
};
export const getScoresForPostHog = async function* (
projectId: string,
minTimestamp: Date,
@@ -810,6 +810,52 @@ export const getUserMetrics = async (
}));
};
export const getTracesForBlobStorageExport = function (
projectId: string,
minTimestamp: Date,
maxTimestamp: Date,
) {
const query = `
SELECT
id,
timestamp,
name,
environment,
project_id,
metadata,
user_id,
session_id,
release,
version,
public,
bookmarked,
tags,
input,
output
FROM traces FINAL
WHERE project_id = {projectId: String}
AND timestamp >= {minTimestamp: DateTime64(3)}
AND timestamp <= {maxTimestamp: DateTime64(3)}
`;
const records = queryClickhouseStream<Record<string, unknown>>({
query,
params: {
projectId,
minTimestamp: convertDateToClickhouseDateTime(minTimestamp),
maxTimestamp: convertDateToClickhouseDateTime(maxTimestamp),
},
tags: {
feature: "blobstorage",
type: "trace",
kind: "analytic",
projectId,
},
});
return records;
};
export const getTracesForPostHog = async function* (
projectId: string,
minTimestamp: Date,
@@ -260,136 +260,144 @@ export class PromptService {
parentPrompt: PartialPrompt;
dependencies?: ParsedPromptDependencyTag[];
}): Promise<ResolvedPromptGraph> {
const { projectId, parentPrompt, dependencies } = params;
try {
const { projectId, parentPrompt, dependencies } = params;
const graph: PromptGraph = {
root: {
name: parentPrompt.name,
version: parentPrompt.version,
id: parentPrompt.id,
},
dependencies: {},
};
const seen = new Set<string>();
const graph: PromptGraph = {
root: {
name: parentPrompt.name,
version: parentPrompt.version,
id: parentPrompt.id,
},
dependencies: {},
};
const seen = new Set<string>();
const resolve = async (
currentPrompt: PartialPrompt,
deps: ParsedPromptDependencyTag[] | undefined,
level: number,
) => {
// Nesting depth check
if (level >= MAX_PROMPT_NESTING_DEPTH) {
throw Error(
`Maximum nesting depth exceeded (${MAX_PROMPT_NESTING_DEPTH})`,
);
}
// Circular dependency check
if (
seen.has(currentPrompt.id) ||
(currentPrompt.name === parentPrompt.name &&
currentPrompt.id !== parentPrompt.id) // ensure that the parent prompt cannot reference a prompt of the same name but different version
) {
throw Error(
`Circular dependency detected involving prompt '${currentPrompt.name}' version ${currentPrompt.version}`,
);
}
seen.add(currentPrompt.id);
// deps can be either passed (if a prompt is created and content was scanned) or retrieved from db
let promptDependencies = deps;
if (!deps) {
promptDependencies = (
await this.prisma.promptDependency.findMany({
where: {
projectId,
parentId: currentPrompt.id,
},
select: {
childName: true,
childLabel: true,
childVersion: true,
},
})
).map(
(dep) =>
({
name: dep.childName,
...(dep.childVersion
? { type: "version", version: dep.childVersion }
: { type: "label", label: dep.childLabel }),
}) as ParsedPromptDependencyTag,
);
}
if (promptDependencies && promptDependencies.length) {
// Instantiate resolved prompt, use stringfied version for regex operations
// Do this inside if clause to skip stringify/parse overhead for prompts without dependencies
let resolvedPrompt = JSON.stringify(currentPrompt.prompt);
for (const dep of promptDependencies) {
const depPrompt = await this.prisma.prompt.findFirst({
where: {
projectId,
name: dep.name,
...(dep.type === "version"
? { version: dep.version }
: { labels: { has: dep.label } }),
},
});
const logName = `${dep.name} - ${dep.type} ${dep.type === "version" ? dep.version : dep.label}`;
if (!depPrompt)
throw Error(`Prompt dependency not found: ${logName}`);
if (depPrompt.type !== "text")
throw Error(`Prompt dependency is not a text prompt: ${logName}`);
// side-effect: populate adjacency list to return later as well
graph.dependencies[currentPrompt.id] ??= []; // initializes an empty list if it does not exist yet
graph.dependencies[currentPrompt.id].push({
id: depPrompt.id,
name: depPrompt.name,
version: depPrompt.version,
});
// resolve the prompt content recursively
const resolvedDepPrompt = await resolve(
depPrompt,
undefined,
level + 1,
const resolve = async (
currentPrompt: PartialPrompt,
deps: ParsedPromptDependencyTag[] | undefined,
level: number,
) => {
// Nesting depth check
if (level >= MAX_PROMPT_NESTING_DEPTH) {
throw Error(
`Maximum nesting depth exceeded (${MAX_PROMPT_NESTING_DEPTH})`,
);
const versionPattern = `@@@langfusePrompt:name=${escapeRegex(depPrompt.name)}\\|version=${escapeRegex(depPrompt.version)}@@@`;
const labelPatterns = depPrompt.labels.map(
(label) =>
`@@@langfusePrompt:name=${escapeRegex(depPrompt.name)}\\|label=${escapeRegex(label)}@@@`,
);
const combinedPattern = [versionPattern, ...labelPatterns].join("|");
const regex = new RegExp(combinedPattern, "g");
const replaceValue = JSON.stringify(resolvedDepPrompt).slice(1, -1); // this is necessary to avoid parsing errors as resolved value is unstringified
resolvedPrompt = resolvedPrompt.replace(regex, replaceValue);
}
seen.delete(currentPrompt.id);
// Circular dependency check
if (
seen.has(currentPrompt.id) ||
(currentPrompt.name === parentPrompt.name &&
currentPrompt.id !== parentPrompt.id) // ensure that the parent prompt cannot reference a prompt of the same name but different version
) {
throw Error(
`Circular dependency detected involving prompt '${currentPrompt.name}' version ${currentPrompt.version}`,
);
}
return JSON.parse(resolvedPrompt);
} else {
seen.delete(currentPrompt.id);
seen.add(currentPrompt.id);
return currentPrompt.prompt;
}
};
// deps can be either passed (if a prompt is created and content was scanned) or retrieved from db
let promptDependencies = deps;
if (!deps) {
promptDependencies = (
await this.prisma.promptDependency.findMany({
where: {
projectId,
parentId: currentPrompt.id,
},
select: {
childName: true,
childLabel: true,
childVersion: true,
},
})
).map(
(dep) =>
({
name: dep.childName,
...(dep.childVersion
? { type: "version", version: dep.childVersion }
: { type: "label", label: dep.childLabel }),
}) as ParsedPromptDependencyTag,
);
}
const resolvedPrompt = await resolve(parentPrompt, dependencies, 0);
if (promptDependencies && promptDependencies.length) {
// Instantiate resolved prompt, use stringfied version for regex operations
// Do this inside if clause to skip stringify/parse overhead for prompts without dependencies
let resolvedPrompt = JSON.stringify(currentPrompt.prompt);
return {
graph: Object.keys(graph.dependencies).length > 0 ? graph : null,
resolvedPrompt,
};
for (const dep of promptDependencies) {
const depPrompt = await this.prisma.prompt.findFirst({
where: {
projectId,
name: dep.name,
...(dep.type === "version"
? { version: dep.version }
: { labels: { has: dep.label } }),
},
});
const logName = `${dep.name} - ${dep.type} ${dep.type === "version" ? dep.version : dep.label}`;
if (!depPrompt)
throw Error(`Prompt dependency not found: ${logName}`);
if (depPrompt.type !== "text")
throw Error(`Prompt dependency is not a text prompt: ${logName}`);
// side-effect: populate adjacency list to return later as well
graph.dependencies[currentPrompt.id] ??= []; // initializes an empty list if it does not exist yet
graph.dependencies[currentPrompt.id].push({
id: depPrompt.id,
name: depPrompt.name,
version: depPrompt.version,
});
// resolve the prompt content recursively
const resolvedDepPrompt = await resolve(
depPrompt,
undefined,
level + 1,
);
const versionPattern = `@@@langfusePrompt:name=${escapeRegex(depPrompt.name)}\\|version=${escapeRegex(depPrompt.version)}@@@`;
const labelPatterns = depPrompt.labels.map(
(label) =>
`@@@langfusePrompt:name=${escapeRegex(depPrompt.name)}\\|label=${escapeRegex(label)}@@@`,
);
const combinedPattern = [versionPattern, ...labelPatterns].join(
"|",
);
const regex = new RegExp(combinedPattern, "g");
const replaceValue = JSON.stringify(resolvedDepPrompt).slice(1, -1); // this is necessary to avoid parsing errors as resolved value is unstringified
resolvedPrompt = resolvedPrompt.replace(regex, replaceValue);
}
seen.delete(currentPrompt.id);
return JSON.parse(resolvedPrompt);
} else {
seen.delete(currentPrompt.id);
return currentPrompt.prompt;
}
};
const resolvedPrompt = await resolve(parentPrompt, dependencies, 0);
return {
graph: Object.keys(graph.dependencies).length > 0 ? graph : null,
resolvedPrompt,
};
} catch (err) {
console.error(err);
throw err;
}
}
private logError(message: string, ...args: any[]) {
@@ -69,8 +69,9 @@ export class StorageServiceFactory {
externalEndpoint?: string | undefined;
region: string | undefined;
forcePathStyle: boolean;
useAzureBlob?: boolean;
}): StorageService {
if (env.LANGFUSE_USE_AZURE_BLOB === "true") {
if (params.useAzureBlob || env.LANGFUSE_USE_AZURE_BLOB === "true") {
return new AzureBlobStorageService(params);
}
return new S3StorageService(params);
@@ -133,10 +134,13 @@ class AzureBlobStorageService implements StorageService {
if (typeof data === "string") {
await blockBlobClient.upload(data, data.length);
} else if (data instanceof Readable) {
let offset = 0;
const blockIds = [];
for await (const chunk of data) {
const blockId = Buffer.from(`block-${offset}`).toString("base64");
// Azure requires block IDs to be base64 strings of the same length
// Use a fixed format with padded index to ensure consistent length
const blockIdStr: string = `block-${blockIds.length.toString().padStart(10, "0")}`;
const blockId = Buffer.from(blockIdStr).toString("base64");
const bufferChunk = Buffer.isBuffer(chunk)
? chunk
: Buffer.from(chunk);
@@ -147,11 +151,10 @@ class AzureBlobStorageService implements StorageService {
bufferChunk.length,
);
blockIds.push(blockId);
offset += bufferChunk.length;
}
await blockBlobClient.commitBlockList(blockIds);
if (blockIds.length > 0) {
await blockBlobClient.commitBlockList(blockIds);
}
} else {
throw new Error("Unsupported data type. Must be Readable or string.");
}
@@ -0,0 +1,61 @@
import { evalDatasetFormFilterCols } from "../../tableDefinitions/tracesTable";
import { FilterState } from "../../types";
import { tableColumnsToSqlFilterAndPrefix } from "../filterToPrisma";
import { Prisma, prisma } from "../../db";
type FetchDatasetItemsTableProps = {
select: "count";
projectId: string;
filter: FilterState;
};
const getDatasetRunItemsTableGeneric = async <T>(
props: FetchDatasetItemsTableProps,
) => {
const { select, projectId, filter } = props;
let sqlSelect: Prisma.Sql;
switch (select) {
case "count":
sqlSelect = Prisma.sql`count(*) as count`;
break;
default:
// eslint-disable-next-line no-case-declarations, no-unused-vars
const exhaustiveCheckDefault: never = select;
throw new Error(`Unknown select type: ${select}`);
}
const datasetItemsFilter = tableColumnsToSqlFilterAndPrefix(
filter,
evalDatasetFormFilterCols,
"dataset_items",
);
const query = Prisma.sql`
SELECT
${sqlSelect}
FROM dataset_run_items as dri
JOIN dataset_items as di ON di.id = dri.dataset_item_id AND di.project_id = ${projectId}
WHERE dri.project_id = ${projectId}
${datasetItemsFilter}
`;
const res = await prisma.$queryRaw<T>(query);
return res;
};
export const getDatasetRunItemsTableCount = async (props: {
projectId: string;
filter: FilterState;
}) => {
const res = await getDatasetRunItemsTableGeneric<Array<{ count: bigint }>>({
select: "count",
projectId: props.projectId,
filter: props.filter,
});
const totalCount = res.length > 0 ? Number(res[0].count) : 0;
return { totalCount };
};
@@ -1,5 +1,7 @@
import { UiColumnMappings } from "./types";
// Make sure to update web/src/features/query/dashboardUiTableToViewMapping.ts if you make changes
export const dashboardColumnDefinitions: UiColumnMappings = [
{
uiTableName: "Trace Name",
+13 -1
View File
@@ -1,4 +1,5 @@
import { JsonNested } from "./zod";
import { parse, isSafeNumber, isNumber } from "lossless-json";
/**
* Deeply parses a JSON string or object for nested stringified JSON
@@ -41,7 +42,18 @@ export const parseJsonPrioritised = (
json: string,
): JsonNested | string | undefined => {
try {
return JSON.parse(json);
return parse(json, null, (value) => {
if (isNumber(value)) {
if (isSafeNumber(value)) {
// Safe numbers (integers and decimals) can be converted to Number
return Number(value.valueOf());
} else {
// For large integers beyond safe limits, preserve string representation
return value.toString();
}
}
return value;
}) as JsonNested;
} catch (error) {
return json;
}
+302 -239
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
FROM --platform=${TARGETPLATFORM:-linux/amd64} node:20.18.3-alpine3.20 AS alpine
FROM --platform=${TARGETPLATFORM:-linux/amd64} node:20-alpine3.20 AS alpine
# It's important to update the index before installing packages to ensure you're getting the latest versions.
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
+8 -7
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.43.0",
"version": "3.45.2",
"private": true,
"license": "MIT",
"engines": {
@@ -14,12 +14,12 @@
"prettier": "prettier --write ./src *.{ts,js}",
"clean": "rm -rf node_modules",
"start": "dotenv -e ../.env -- sh -c 'NEXT_MANUAL_SIG_HANDLE=true next start'",
"test": "dotenv -e ../.env -- jest --silent false --verbose false --runInBand --detectOpenHandles --selectProjects async-server",
"test-sync": "dotenv -e ../.env -- jest --silent false --verbose false --runInBand --detectOpenHandles --selectProjects sync-server",
"test-client": "dotenv -e ../.env -- jest --silent false --verbose false --runInBand --detectOpenHandles --selectProjects client",
"test:watch": "dotenv -e ../.env -- jest --watch --runInBand",
"test": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env -- jest --silent false --verbose false --runInBand --detectOpenHandles --selectProjects async-server",
"test-sync": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env -- jest --silent false --verbose false --runInBand --detectOpenHandles --selectProjects sync-server",
"test-client": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env -- jest --silent false --verbose false --runInBand --detectOpenHandles --selectProjects client",
"test:watch": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env -- jest --watch --runInBand",
"test:e2e": "dotenv -e ../.env -- playwright test",
"test:e2e:server": "dotenv -e ../.env -- jest --runInBand --detectOpenHandles --verbose --selectProjects e2e-server"
"test:e2e:server": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env -- jest --runInBand --detectOpenHandles --verbose --selectProjects e2e-server"
},
"dependencies": {
"@anthropic-ai/tokenizer": "^0.0.4",
@@ -122,7 +122,7 @@
"langchain": "^0.3.6",
"lodash": "^4.17.21",
"lucide-react": "^0.462.0",
"next": "^14.2.21",
"next": "^14.2.25",
"next-auth": "^4.24.11",
"next-query-params": "^5.0.1",
"next-themes": "^0.3.0",
@@ -181,6 +181,7 @@
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^7.12.0",
"autoprefixer": "^10.4.19",
"cross-env": "^7.0.3",
"dotenv-cli": "^7.4.2",
"eslint": "^8.56.0",
"eslint-config-next": "^14.2.15",
+4
View File
@@ -4995,11 +4995,13 @@ components:
type: object
additionalProperties:
type: integer
nullable: true
nullable: true
completion_tokens_details:
type: object
additionalProperties:
type: integer
nullable: true
nullable: true
required:
- prompt_tokens
@@ -5020,11 +5022,13 @@ components:
type: object
additionalProperties:
type: integer
nullable: true
nullable: true
output_tokens_details:
type: object
additionalProperties:
type: integer
nullable: true
nullable: true
required:
- input_tokens
@@ -0,0 +1,85 @@
import {
createOrgProjectAndApiKey,
getDatasetRunItemsTableCount,
} from "@langfuse/shared/src/server";
import { v4 as uuidv4 } from "uuid";
import { prisma } from "@langfuse/shared/src/db";
import { type FilterState } from "@langfuse/shared";
const generateFilter = (datasetIds: string[]): FilterState => {
return [
{
column: "Dataset",
operator: "any of",
type: "stringOptions",
value: datasetIds,
},
];
};
describe("trpc.datasets", () => {
let projectId: string;
let datasetIds: string[];
beforeAll(async () => {
const { projectId: newProjectId } = await createOrgProjectAndApiKey();
const datasetItemIds = [uuidv4(), uuidv4()];
const datasetRunIds = [uuidv4(), uuidv4()];
projectId = newProjectId;
datasetIds = [uuidv4(), uuidv4()];
await prisma.dataset.createMany({
data: datasetIds.map((datasetId, index) => ({
id: datasetId,
projectId: projectId,
name: `test-${index}`,
})),
});
await prisma.datasetItem.createMany({
data: datasetIds.map((datasetId, index) => ({
id: datasetItemIds[index],
projectId: projectId,
datasetId: datasetId,
})),
});
await prisma.datasetRuns.createMany({
data: datasetRunIds.map((datasetRunId, index) => ({
id: datasetRunId,
projectId: projectId,
datasetId: datasetIds[index],
name: `test-${index}`,
})),
});
await prisma.datasetRunItems.createMany({
data: datasetItemIds.map((datasetItemId, index) => ({
id: uuidv4(),
projectId: projectId,
datasetItemId: datasetItemId,
traceId: uuidv4(),
datasetRunId: datasetRunIds[index],
})),
});
});
describe("GET datasetItems.countAll", () => {
it("should GET all dataset run items with no filter", async () => {
const { totalCount } = await getDatasetRunItemsTableCount({
projectId: projectId,
filter: [],
});
expect(totalCount).toBe(2);
});
it("should GET all dataset run items with filter", async () => {
const { totalCount } = await getDatasetRunItemsTableCount({
projectId: projectId,
filter: generateFilter([datasetIds[0]]),
});
expect(totalCount).toBe(1);
});
});
});
@@ -497,6 +497,88 @@ describe("/api/public/ingestion API Endpoint", () => {
});
});
it.each([
["string", { testId: "this is a string metadata" }],
["big-number", { testId: "1983516295378495150" }],
["small-number", { testId: 5 }],
["float-number", { testId: 5.5 }],
])(
"#6123: should treat %s metadata for traces as such",
async (_type, metadataValue) => {
const traceId = randomUUID();
const entity = {
id: randomUUID(),
type: "trace-create",
timestamp: new Date().toISOString(),
body: {
id: traceId,
timestamp: new Date().toISOString(),
metadata: metadataValue,
},
};
const response = await makeAPICall("POST", "/api/public/ingestion", {
batch: [entity],
});
expect(response.status).toBe(207);
await waitForExpect(async () => {
const trace = await getTraceById(traceId, projectId);
expect(trace).toBeDefined();
expect(trace!.id).toBe(traceId);
expect(JSON.stringify(trace!.metadata)).toBe(
JSON.stringify(metadataValue),
);
});
},
);
it.each([
["string", { testId: "this is a string metadata" }],
["big-number", { testId: "1983516295378495150" }],
["small-number", { testId: 5 }],
["float-number", { testId: 5.5 }],
])(
"#6123: should treat %s metadata for observations as such",
async (_type, metadataValue) => {
const observationId = randomUUID();
const traceId = randomUUID();
const entity = {
id: randomUUID(),
type: "span-create",
timestamp: new Date().toISOString(),
body: {
id: observationId,
traceId: traceId,
startTime: new Date().toISOString(),
metadata: metadataValue,
},
};
const response = await makeAPICall("POST", "/api/public/ingestion", {
batch: [entity],
});
expect(response.status).toBe(207);
await waitForExpect(async () => {
const observation = await getObservationById(
observationId,
projectId,
true,
);
expect(observation).toBeDefined();
expect(observation!.id).toBe(observationId);
expect(JSON.stringify(observation!.metadata)).toBe(
JSON.stringify(metadataValue),
);
});
},
);
it("#4900: should clear score comment on update with `null`", async () => {
const scoreId = randomUUID();
const score1 = {
@@ -1,5 +1,12 @@
import { prepareUsageDataForTimeseriesChart } from "@/src/features/dashboard/components/ModelUsageChart";
import { orderByTimeSeries } from "@langfuse/shared/src/server";
import {
orderByTimeSeries,
getObservationUsageByTypeByTime,
createOrgProjectAndApiKey,
createTrace,
createTracesCh,
createObservationsCh,
createObservation,
} from "@langfuse/shared/src/server";
describe("orderByTimeSeries", () => {
it("should return correct bucket size and query for 1 hour time range", () => {
@@ -89,55 +96,102 @@ describe("orderByTimeSeries", () => {
);
});
describe("aggregate time series for model cost and usage", () => {
it("should aggregate time series for model cost and usage", async () => {
const metricHistory = prepareUsageDataForTimeseriesChart(
["gpt-4o-mini", "text-embedding-ada-002"],
[
{
startTime: "2025-02-10T13:30:00.000Z",
units: {
input: 422,
output: 61,
total: 483,
},
cost: {
input: 0.0000633,
output: 0.0000366,
total: 0.0000999,
},
model: "gpt-4o-mini",
},
{
startTime: "2025-02-10T13:30:00.000Z",
units: {
input: 6,
total: 6,
},
cost: {
total: 6e-7,
},
model: "text-embedding-ada-002",
},
],
describe("getObservationUsageByTypeByTime", () => {
const mockFilter = [
{
type: "datetime" as const,
column: "timestamp",
operator: ">=" as const,
value: new Date("2024-01-01T00:00:00Z"),
},
{
type: "datetime" as const,
column: "timestamp",
operator: "<=" as const,
value: new Date("2024-01-02T01:00:00Z"),
},
];
it("should return usage data grouped by time and type", async () => {
const { projectId } = await createOrgProjectAndApiKey();
const trace = createTrace({
name: "trace-name",
project_id: projectId,
timestamp: new Date("2024-01-01T01:00:00Z").getTime(),
});
const trace2 = createTrace({
name: "trace-name",
project_id: projectId,
timestamp: new Date("2024-01-01T04:00:00Z").getTime(),
});
await createTracesCh([trace, trace2]);
const obs1 = createObservation({
trace_id: trace.id,
project_id: trace.project_id,
usage_details: { input: 1, output: 2, total: 3 },
start_time: new Date("2024-01-01T01:00:00Z").getTime(),
});
const obs2 = createObservation({
trace_id: trace.id,
project_id: trace.project_id,
usage_details: { input: 4, output: 5, total: 9 },
start_time: new Date("2024-01-01T01:00:00Z").getTime(),
});
const obs3 = createObservation({
trace_id: trace2.id,
project_id: trace.project_id,
usage_details: { input: 400, output: 500, total: 900 },
start_time: new Date("2024-01-01T04:00:00Z").getTime(),
});
await createObservationsCh([obs1, obs2, obs3]);
const result = await getObservationUsageByTypeByTime(
projectId,
mockFilter,
);
expect(metricHistory.get("total")).toEqual([
{
startTime: "2025-02-10T13:30:00.000Z",
units: 483,
cost: 0.0000999,
model: "gpt-4o-mini",
usageType: "total",
},
{
startTime: "2025-02-10T13:30:00.000Z",
units: 6,
cost: 6e-7,
model: "text-embedding-ada-002",
usageType: "total",
},
]);
// Verify the structure of the returned data
expect(result).toEqual(
expect.arrayContaining([
{
intervalStart: new Date("2024-01-01T01:00:00Z"),
key: "input",
sum: 5,
},
{
intervalStart: new Date("2024-01-01T01:00:00Z"),
key: "output",
sum: 7,
},
{
intervalStart: new Date("2024-01-01T01:00:00Z"),
key: "total",
sum: 12,
},
{
intervalStart: new Date("2024-01-01T04:00:00Z"),
key: "input",
sum: 400,
},
{
intervalStart: new Date("2024-01-01T04:00:00Z"),
key: "output",
sum: 500,
},
{
intervalStart: new Date("2024-01-01T04:00:00Z"),
key: "total",
sum: 900,
},
]),
);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,903 @@
import { randomUUID } from "crypto";
import {
createTrace,
createTracesCh,
createObservation,
createObservationsCh,
getTotalTraces,
getTracesGroupedByName,
getObservationsCostGroupedByName,
getScoreAggregate,
groupTracesByTime,
} from "@langfuse/shared/src/server";
import { type FilterState } from "@langfuse/shared";
import { type QueryType } from "@/src/features/query/types";
import { executeQuery } from "@/src/features/dashboard/server/dashboard-router";
import { dashboardColumnDefinitions } from "@langfuse/shared";
/**
* Test suite for testing the self-serve dashboards functionality
* This tests that the new query builder produces the same results as the existing dashboard queries
*/
describe("selfServeDashboards", () => {
// Single project ID for all tests
const projectId = randomUUID();
// Time references
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 3600000);
const twoHoursAgo = new Date(now.getTime() - 7200000);
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 3600000);
// Time ranges for queries - converted to ClickHouse DateTime format (YYYY-MM-DD HH:MM:SS.SSS)
const defaultFromTime = threeDaysAgo.toISOString();
const defaultToTime = new Date(now.getTime() + 3600000).toISOString(); // 1 hour in future
// Test data statistics for verification
const stats = {
totalTraces: 0,
productionTraces: 0,
developmentTraces: 0,
stagingTraces: 0,
recentProductionTraces: 0, // within the last hour
traceCounts: {} as Record<string, number>, // counts by trace name
environmentCounts: {} as Record<string, number>, // counts by environment
};
beforeAll(async () => {
// Create a diverse set of traces with different characteristics
const traces = [
// Production environment - common names
...Array(5)
.fill(0)
.map((_, i) =>
createTrace({
project_id: projectId,
name: "chat-completion",
environment: "production",
timestamp: now.getTime() - i * 10000, // Slightly different timestamps
user_id: "user-A",
}),
),
...Array(3)
.fill(0)
.map((_, i) =>
createTrace({
project_id: projectId,
name: "embeddings",
environment: "production",
timestamp: now.getTime() - i * 15000,
user_id: "user-B",
}),
),
// Production environment - older traces
...Array(2)
.fill(0)
.map((_, i) =>
createTrace({
project_id: projectId,
name: "chat-completion",
environment: "production",
timestamp: twoHoursAgo.getTime() - i * 10000,
user_id: "user-C",
}),
),
// Development environment - recent
...Array(4)
.fill(0)
.map((_, i) =>
createTrace({
project_id: projectId,
name: "chat-completion",
environment: "development",
timestamp: oneHourAgo.getTime() - i * 20000,
user_id: "user-D",
}),
),
...Array(2)
.fill(0)
.map((_, i) =>
createTrace({
project_id: projectId,
name: "summarize",
environment: "development",
timestamp: now.getTime() - i * 5000,
user_id: "user-E",
}),
),
// Staging environment
...Array(3)
.fill(0)
.map((_, i) =>
createTrace({
project_id: projectId,
name: "qa-bot",
environment: "staging",
timestamp: now.getTime() - i * 30000,
user_id: "user-F",
}),
),
];
// Insert traces into ClickHouse
await createTracesCh(traces);
// Create observations for some of these traces
const observations = [];
// Add observations for chat-completion traces in production
for (let i = 0; i < 3; i++) {
const traceId = traces[i].id;
observations.push(
createObservation({
project_id: projectId,
trace_id: traceId,
name: "gpt-4-turbo",
type: "generation",
environment: "production",
start_time: now.getTime() - i * 10000,
completion_start_time: now.getTime() - i * 10000 + 800, // 800ms time to first token
end_time: now.getTime() - i * 10000 + 3000, // 3000ms total duration
provided_model_name: "gpt-4-turbo",
}),
);
}
// Add observations for embeddings traces
for (let i = 0; i < 2; i++) {
const traceId = traces[5 + i].id; // embeddings traces start at index 5
observations.push(
createObservation({
project_id: projectId,
trace_id: traceId,
name: "text-embedding-ada-002",
type: "generation",
environment: "production",
start_time: now.getTime() - i * 15000,
completion_start_time: now.getTime() - i * 15000 + 200, // 200ms time to first token
end_time: now.getTime() - i * 15000 + 500, // 500ms total duration
provided_model_name: "text-embedding-ada-002",
}),
);
}
// Add observations for development traces
for (let i = 0; i < 2; i++) {
const traceId = traces[10 + i].id; // development traces start at index 10
observations.push(
createObservation({
project_id: projectId,
trace_id: traceId,
name: "claude-3-opus",
type: "generation",
environment: "development",
start_time: oneHourAgo.getTime() - i * 20000,
completion_start_time: oneHourAgo.getTime() - i * 20000 + 1200, // 1200ms time to first token
end_time: oneHourAgo.getTime() - i * 20000 + 4000, // 4000ms total duration
provided_model_name: "claude-3-opus",
}),
);
}
// Insert observations into ClickHouse
await createObservationsCh(observations);
// Calculate statistics for verification
stats.totalTraces = traces.length;
// Count by environment
traces.forEach((trace) => {
stats.environmentCounts[trace.environment] =
(stats.environmentCounts[trace.environment] || 0) + 1;
});
stats.productionTraces = stats.environmentCounts["production"] || 0;
stats.developmentTraces = stats.environmentCounts["development"] || 0;
stats.stagingTraces = stats.environmentCounts["staging"] || 0;
// Count traces by name
traces.forEach((trace) => {
stats.traceCounts[trace.name || ""] =
(stats.traceCounts[trace.name || ""] || 0) + 1;
});
// Count recent production traces (within the last hour)
stats.recentProductionTraces = traces.filter(
(t) =>
t.environment === "production" && t.timestamp >= oneHourAgo.getTime(),
).length;
});
describe("traces-total query", () => {
it("should return the same result with query builder as with legacy function", async () => {
// Empty filter for this test
const filter: FilterState = [
{
type: "datetime",
operator: ">=",
column: "timestamp",
value: new Date("1970-01-02"),
},
];
// 1. Get result using the legacy function
const legacyResult = await getTotalTraces(projectId, filter);
// 2. Define the equivalent query for the query builder
const queryBuilderQuery: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// 3. Get result using the query builder
const queryBuilderResult = await executeQuery(
projectId,
queryBuilderQuery,
);
// 4. Assert that both results match
expect(queryBuilderResult).toHaveLength(1);
expect(Number(queryBuilderResult[0].count_count)).toBe(stats.totalTraces);
expect(Number(legacyResult?.[0]?.countTraceId)).toBe(stats.totalTraces);
});
it("should filter traces by environment correctly", async () => {
// 1. Define a filter for production environment in the legacy format
const prodLegacyFilter: FilterState = [
{
type: "datetime",
operator: ">=",
column: "timestamp",
value: new Date("1970-01-02"),
},
{
type: "string",
operator: "=",
column: "environment",
value: "production",
},
];
const prodLegacyResult = await getTotalTraces(
projectId,
prodLegacyFilter,
);
// 2. Define the equivalent query with filter for the query builder
const prodQueryBuilderQuery: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [
{
column: "environment",
operator: "=",
value: "production",
type: "string",
},
],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
const prodQueryBuilderResult = await executeQuery(
projectId,
prodQueryBuilderQuery,
);
// 3. Assert that both results match and only count production traces
expect(prodQueryBuilderResult).toHaveLength(1);
expect(Number(prodQueryBuilderResult[0].count_count)).toBe(
stats.productionTraces,
);
expect(Number(prodLegacyResult?.[0]?.countTraceId)).toBe(
stats.productionTraces,
);
// 4. Test another filter for development environment
const devLegacyFilter: FilterState = [
{
type: "datetime",
operator: ">=",
column: "timestamp",
value: new Date("1970-01-02"),
},
{
type: "string",
operator: "=",
column: "environment",
value: "development",
},
];
const devLegacyResult = await getTotalTraces(projectId, devLegacyFilter);
const devQueryBuilderQuery: QueryType = {
...prodQueryBuilderQuery,
filters: [
{
column: "environment",
operator: "=",
value: "development",
type: "string",
},
],
};
const devQueryBuilderResult = await executeQuery(
projectId,
devQueryBuilderQuery,
);
// 5. Assert development environment results
expect(devQueryBuilderResult).toHaveLength(1);
expect(Number(devQueryBuilderResult[0].count_count)).toBe(
stats.developmentTraces,
);
expect(Number(devLegacyResult?.[0]?.countTraceId)).toBe(
stats.developmentTraces,
);
});
it("should handle multiple filter conditions", async () => {
// 1. Define a filter for recent production traces in the legacy format
const recentProdLegacyFilter: FilterState = [
{
type: "string",
operator: "=",
column: "environment",
value: "production",
},
{
type: "datetime",
operator: ">=",
column: "timestamp",
value: oneHourAgo,
},
];
const legacyResult = await getTotalTraces(
projectId,
recentProdLegacyFilter,
);
// 2. Define the equivalent query with multiple filters for the query builder
const queryBuilderQuery: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [
{
column: "environment",
operator: "=",
value: "production",
type: "string",
},
{
column: "timestamp",
operator: ">=",
value: new Date(oneHourAgo),
type: "datetime",
},
],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
const queryBuilderResult = await executeQuery(
projectId,
queryBuilderQuery,
);
// 3. Assert that both results match and only count recent production traces
expect(queryBuilderResult).toHaveLength(1);
expect(Number(queryBuilderResult[0].count_count)).toBe(
stats.recentProductionTraces,
);
expect(legacyResult?.[0]?.countTraceId).toBe(
`${stats.recentProductionTraces}`,
);
});
});
describe("traces-grouped-by-name query", () => {
it("should return the same result with query builder as with legacy function", async () => {
// 1. Get result using the legacy function
const legacyResult = await getTracesGroupedByName(
projectId,
dashboardColumnDefinitions,
[], // empty filter
);
// 2. Define the equivalent query for the query builder
const queryBuilderQuery: QueryType = {
view: "traces",
dimensions: [{ field: "name" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// 3. Get result using the query builder
const queryBuilderResult = await executeQuery(
projectId,
queryBuilderQuery,
);
// 4. Verify both results
const legacyResultMap = new Map(
legacyResult.map((item) => [item.name, item.count]),
);
// Verify results match the expected trace counts by name
Object.keys(stats.traceCounts).forEach((traceName) => {
const countFromLegacy = legacyResultMap.get(traceName);
const resultRow = queryBuilderResult.find(
(row: any) => row.name === traceName,
);
expect(countFromLegacy).toBe(`${stats.traceCounts[traceName]}`);
expect(Number(resultRow?.count_count)).toBe(
stats.traceCounts[traceName],
);
});
// Verify both result sets have the same number of rows
expect(legacyResult.length).toBe(Object.keys(stats.traceCounts).length);
expect(queryBuilderResult.length).toBe(
Object.keys(stats.traceCounts).length,
);
});
it("should filter traces by environment when grouping by name", async () => {
// 1. Define a filter for production environment
const prodFilter: FilterState = [
{
type: "string",
operator: "=",
column: "environment",
value: "production",
},
];
// 2. Get legacy result with filter
const legacyResult = await getTracesGroupedByName(
projectId,
dashboardColumnDefinitions,
prodFilter,
);
// 3. Define the equivalent query for the query builder
const queryBuilderQuery: QueryType = {
view: "traces",
dimensions: [{ field: "name" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [
{
column: "environment",
operator: "=",
value: "production",
type: "string",
},
],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// 4. Get result using the query builder
const queryBuilderResult = await executeQuery(
projectId,
queryBuilderQuery,
);
// 5. Verify results
// Get only traces with production environment
const productionTraceNames = ["chat-completion", "embeddings"];
// Verify both results have the expected number of rows
expect(legacyResult.length).toBe(productionTraceNames.length);
expect(queryBuilderResult.length).toBe(productionTraceNames.length);
// Create easy-to-use maps for comparison
const legacyResultMap = new Map(
legacyResult.map((item) => [item.name, item.count]),
);
// Check each production trace name is present with correct count
productionTraceNames.forEach((traceName) => {
const countFromLegacy = legacyResultMap.get(traceName);
const resultRow = queryBuilderResult.find(
(row: any) => row.name === traceName,
);
expect(countFromLegacy).toBeDefined();
expect(resultRow).toBeDefined();
expect(resultRow?.count_count).toBe(countFromLegacy);
});
});
});
describe("observations-model-cost query", () => {
it("should return the same result with query builder as with legacy function", async () => {
// 1. Get result using the legacy function
const legacyResult = await getObservationsCostGroupedByName(
projectId,
[], // empty filter
);
// 2. Define the equivalent query for the query builder
const queryBuilderQuery: QueryType = {
view: "observations",
dimensions: [{ field: "providedModelName" }],
metrics: [
{ measure: "totalCost", aggregation: "sum" },
{ measure: "totalTokens", aggregation: "sum" },
],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// 3. Get result using the query builder
const queryBuilderResult = await executeQuery(
projectId,
queryBuilderQuery,
);
// 4. Verify both results
expect(queryBuilderResult).toBeDefined();
expect(legacyResult).toBeDefined();
// Create maps for easier comparison
const legacyResultMap = new Map(
legacyResult.map((item) => [item.name, item]),
);
// Verify each model's costs and token usage match
queryBuilderResult.forEach((row: any) => {
const modelName = row.provided_model_name;
const legacyModelData = legacyResultMap.get(modelName);
expect(legacyModelData).toBeDefined();
expect(row.sum_total_cost).toBe(legacyModelData?.sum_cost_details);
expect(row.sum_total_tokens).toBe(legacyModelData?.sum_usage_details);
});
// Verify both result sets have the same number of models
expect(legacyResult.length).toBe(queryBuilderResult.length);
});
it("should filter observations by environment", async () => {
// 1. Define a filter for production environment
const prodFilter: FilterState = [
{
type: "string",
operator: "=",
column: "environment",
value: "production",
},
];
// 2. Get legacy result with filter
const legacyResult = await getObservationsCostGroupedByName(
projectId,
prodFilter,
);
// 3. Define the equivalent query for the query builder
const queryBuilderQuery: QueryType = {
view: "observations",
dimensions: [{ field: "providedModelName" }],
metrics: [
{ measure: "totalCost", aggregation: "sum" },
{ measure: "totalTokens", aggregation: "sum" },
],
filters: [
{
column: "environment",
operator: "=",
value: "production",
type: "string",
},
],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// 4. Get result using the query builder
const queryBuilderResult = await executeQuery(
projectId,
queryBuilderQuery,
);
// 5. Verify results
// Production models should only include gpt-4-turbo and text-embedding-ada-002
const productionModels = ["gpt-4-turbo", "text-embedding-ada-002"];
// Verify both results have the expected number of rows
expect(legacyResult.length).toBe(productionModels.length);
expect(queryBuilderResult.length).toBe(productionModels.length);
// Create maps for easier comparison
const legacyResultMap = new Map(
legacyResult.map((item) => [item.name, item]),
);
// Verify each production model is present with correct costs
queryBuilderResult.forEach((row: any) => {
const modelName = row.provided_model_name;
const legacyModelData = legacyResultMap.get(modelName);
expect(productionModels).toContain(modelName);
expect(legacyModelData).toBeDefined();
expect(row.sum_total_cost).toBe(legacyModelData?.sum_cost_details);
expect(row.sum_total_tokens).toBe(legacyModelData?.sum_usage_details);
});
});
});
describe("score-aggregate query", () => {
it("should return the same result with query builder as with legacy function", async () => {
// 1. Get result using the legacy function
const legacyResult = await getScoreAggregate(projectId, [
{
type: "datetime",
operator: ">=",
column: "timestamp",
value: new Date("1970-01-02"),
},
]);
// 2. Define the equivalent query for the query builder
const queryBuilderNumericQuery: QueryType = {
view: "scores-numeric",
dimensions: [
{ field: "name" },
{ field: "source" },
{ field: "dataType" },
],
metrics: [
{ measure: "value", aggregation: "avg" },
{ measure: "count", aggregation: "count" },
],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// 3. Get results using the query builder for numeric scores
const queryBuilderNumericResult = await executeQuery(
projectId,
queryBuilderNumericQuery,
);
// 4. Check categorical scores separately (optional, as the test dataset may not include them)
const queryCategoricalQuery: QueryType = {
view: "scores-categorical",
dimensions: [
{ field: "name" },
{ field: "source" },
{ field: "dataType" },
],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// Get results for categorical scores
const queryBuilderCatResult = await executeQuery(
projectId,
queryCategoricalQuery,
);
// 5. Verify both results
expect(queryBuilderNumericResult).toBeDefined();
expect(legacyResult).toBeDefined();
// Check that all numeric scores from legacy query are present in new query
// Note: This test assumes numeric scores. If you have categorical scores, you'd need to
// handle them separately by checking against queryBuilderCatResult
legacyResult.forEach((legacyScore) => {
// Only check numeric scores here
if (legacyScore.data_type === "numeric") {
const matchingRow = queryBuilderNumericResult.find(
(row: any) =>
row.name === legacyScore.name &&
row.source === legacyScore.source &&
row.data_type === legacyScore.data_type,
);
expect(matchingRow).toBeDefined();
// Check count matches
expect(Number(matchingRow?.count_count)).toBe(
Number(legacyScore.count),
);
// Check average value is approximately the same
expect(Number(matchingRow?.value_avg)).toBeCloseTo(
Number(legacyScore.avg_value),
);
}
});
// If we have categorical scores in our test data, verify those too
const categoricalScores = legacyResult.filter(
(score) => score.data_type === "categorical",
);
if (categoricalScores.length > 0 && queryBuilderCatResult.length > 0) {
categoricalScores.forEach((legacyScore) => {
const matchingRow = queryBuilderCatResult.find(
(row: any) =>
row.name === legacyScore.name &&
row.source === legacyScore.source &&
row.data_type === legacyScore.data_type,
);
expect(matchingRow).toBeDefined();
// Check count matches
expect(Number(matchingRow?.count_count)).toBe(
Number(legacyScore.count),
);
});
}
});
it("should filter scores by environment", async () => {
// 1. Define a filter for production environment
const prodFilter: FilterState = [
{
type: "string",
operator: "=",
column: "environment",
value: "production",
},
{
type: "datetime",
operator: ">=",
column: "timestamp",
value: new Date("1970-01-02"),
},
];
// 2. Get legacy result with filter
const legacyResult = await getScoreAggregate(projectId, prodFilter);
// 3. Define the equivalent query for the query builder
const queryBuilderQuery: QueryType = {
view: "scores-numeric", // We'll just test numeric scores for simplicity
dimensions: [
{ field: "name" },
{ field: "source" },
{ field: "dataType" },
],
metrics: [
{ measure: "value", aggregation: "avg" },
{ measure: "count", aggregation: "count" },
],
filters: [
{
column: "environment",
operator: "=",
value: "production",
type: "string",
},
],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// 4. Get result using the query builder
const queryBuilderResult = await executeQuery(
projectId,
queryBuilderQuery,
);
// 5. Verify results
// Production environment should only include certain scores (based on test data)
// We can check if both results have the same number of scores for production
expect(queryBuilderResult.length).toBe(
legacyResult.filter((score) => score.data_type === "numeric").length,
);
// Check that all numeric scores in production environment from legacy query match the new query
legacyResult
.filter((score) => score.data_type === "numeric")
.forEach((legacyScore) => {
const matchingRow = queryBuilderResult.find(
(row: any) =>
row.name === legacyScore.name &&
row.source === legacyScore.source &&
row.data_type === legacyScore.data_type,
);
expect(matchingRow).toBeDefined();
// Check count matches
expect(Number(matchingRow?.count_count)).toBe(
Number(legacyScore.count),
);
// Check average value is approximately the same
expect(Number(matchingRow?.value_avg)).toBeCloseTo(
Number(legacyScore.avg_value),
);
});
});
});
describe("traces-timeseries query", () => {
it("should return the same result with query builder as with legacy function", async () => {
// 1. Get result using the legacy function
const legacyResult = await groupTracesByTime(projectId, [
{
type: "datetime",
operator: ">=",
column: "timestamp",
value: new Date(defaultFromTime),
},
{
type: "datetime",
operator: "<=",
column: "timestamp",
value: new Date(defaultToTime),
},
]);
// 2. Define the equivalent query for the query builder
const queryBuilderQuery: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: {
granularity: "hour",
},
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// 3. Get result using the query builder
const queryBuilderResult = await executeQuery(
projectId,
queryBuilderQuery,
);
// 4. Verify both results
// Both results should be between 70 and 80 rows
expect(legacyResult.length).toBeGreaterThanOrEqual(70);
expect(legacyResult.length).toBeLessThanOrEqual(80);
expect(queryBuilderResult.length).toBeGreaterThanOrEqual(70);
expect(queryBuilderResult.length).toBeLessThanOrEqual(80);
// Compare the non-zero results to each other. The legacy setup should have more empty records in the future
// so the indexing should match up.
legacyResult.forEach((result, index) => {
if (result.countTraceId > 0) {
const queryBuilderRow = queryBuilderResult[index];
expect(queryBuilderRow).toBeDefined();
expect(Number(queryBuilderRow.count_count)).toBe(result.countTraceId);
}
});
});
});
});
@@ -0,0 +1,458 @@
import { randomUUID } from "crypto";
import { QueryBuilder } from "@/src/features/query/server/queryBuilder";
import { type QueryType } from "@/src/features/query/types";
import { TRPCError } from "@trpc/server";
import { executeQuery } from "@/src/features/dashboard/server/dashboard-router";
/**
* Test suite for testing SQL injection vulnerabilities in the QueryBuilder
*/
describe("QueryBuilder SQL Injection Tests", () => {
// Single project ID for all tests
const projectId = randomUUID();
// Time references
const now = new Date();
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 3600000);
// Time ranges for queries - converted to ClickHouse DateTime format
const defaultFromTime = threeDaysAgo.toISOString();
const defaultToTime = now.toISOString();
// Create a mock ClickHouse client for testing
const mockClickhouseClient = {
query: jest.fn().mockImplementation(({ query, query_params }) => {
// Return the query and params for inspection in tests
return Promise.resolve({
json: jest.fn().mockReturnValue({
data: [],
query,
params: query_params,
}),
});
}),
};
// Helper function to build a query without executing it
const buildQueryWithoutExecuting = (query: QueryType, projectId: string) => {
const queryBuilder = new QueryBuilder(mockClickhouseClient as any);
return queryBuilder.build(query, projectId);
};
beforeEach(() => {
jest.clearAllMocks();
});
describe("SQL Injection via View Parameter", () => {
it("should prevent injection via invalid view name", async () => {
// Comment: The view property is restricted to specific enum values,
// but a determined attacker might try to bypass zod validation or
// supply a maliciously crafted view name
const maliciousQuery = {
view: "traces; DROP TABLE users" as any,
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
};
// Should throw an error rather than allow the injection
expect(() =>
buildQueryWithoutExecuting(maliciousQuery, projectId),
).toThrow("Invalid query");
});
});
describe("SQL Injection via Dimension Fields", () => {
it("should prevent injection via dimension field name", async () => {
// Comment: The field names in dimensions should be validated against allowed fields
// in the view declaration. This test checks if an attacker can inject arbitrary SQL
// by manipulating the dimension field name.
const maliciousQuery: QueryType = {
view: "traces",
dimensions: [{ field: "name; DROP TABLE traces; --" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// Should throw an error for invalid dimension
expect(() =>
buildQueryWithoutExecuting(maliciousQuery, projectId),
).toThrow("Invalid dimension");
});
it("should safely handle special characters in valid dimension fields", async () => {
// Comment: Even with valid fields, we need to ensure special characters
// don't lead to injections when building the SQL query
const query: QueryType = {
view: "traces",
dimensions: [{ field: "name" }], // Valid field
metrics: [{ measure: "count", aggregation: "count" }],
filters: [
{
column: "name",
operator: "=",
value: "chat'; DROP TABLE traces; --", // SQL injection in value
type: "string",
},
],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// Should build a valid query using parameterized queries for safety
const result = buildQueryWithoutExecuting(query, projectId);
// Ensure the value is parameterized and not directly included in the SQL
expect(result.query).not.toContain("chat'; DROP TABLE traces; --");
expect(Object.values(result.parameters)).toContain(
"chat'; DROP TABLE traces; --",
);
});
});
describe("SQL Injection via Metrics", () => {
it("should prevent injection via metric measure name", async () => {
// Comment: Similar to dimensions, metrics should be validated against allowed measures
// This test checks if an attacker can inject SQL via the measure property
const maliciousQuery: QueryType = {
view: "traces",
dimensions: [],
metrics: [
{
measure: "count); DROP TABLE traces; --",
aggregation: "count",
},
],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// Should throw an error for invalid metric
expect(() =>
buildQueryWithoutExecuting(maliciousQuery, projectId),
).toThrow("Invalid metric");
});
it("should prevent injection via metric aggregation", async () => {
// Comment: The aggregation function could be another injection vector if
// not properly validated
const maliciousQuery: QueryType = {
view: "traces",
dimensions: [],
metrics: [
{
measure: "count",
aggregation: "count); DROP TABLE traces; --" as any,
},
],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// Should throw an error for invalid aggregation
expect(() =>
buildQueryWithoutExecuting(maliciousQuery, projectId),
).toThrow("Invalid query");
});
});
describe("SQL Injection via Filters", () => {
it("should prevent injection via filter field name", async () => {
// Comment: Filter field names must be validated like dimensions
const maliciousQuery: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [
{
column: "environment); DROP TABLE traces; --",
operator: "=",
value: "production",
type: "string",
},
],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// Should throw an error for invalid filter field
expect(() =>
buildQueryWithoutExecuting(maliciousQuery, projectId),
).toThrow("Invalid filter");
});
it("should prevent injection via filter operator", async () => {
// Comment: Filter operators should be validated against a list of allowed operators
const maliciousQuery: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [
{
column: "environment",
operator: "=; DROP TABLE traces; --" as any,
value: "production",
type: "string",
},
],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// Should throw an error for invalid operator
expect(() =>
buildQueryWithoutExecuting(maliciousQuery, projectId),
).toThrow("Invalid query");
});
it("should safely handle special characters in filter values", async () => {
// Comment: Filter values should be parameterized to prevent injection
const query: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [
{
column: "environment",
operator: "=",
type: "string",
value: "production'; DROP TABLE traces; --",
},
],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// Should build a valid query with parameterization
const result = buildQueryWithoutExecuting(query, projectId);
// Check for parameterization
expect(result.query).not.toContain("production'; DROP TABLE traces; --");
expect(Object.values(result.parameters)).toContain(
"production'; DROP TABLE traces; --",
);
});
it("should safely handle array values in IN operators", async () => {
// Comment: IN operators with arrays need special handling for SQL injection prevention
const query: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [
{
column: "environment",
operator: "any of",
value: ["production", "development'); DROP TABLE traces; --"],
type: "stringOptions",
},
],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// Should parameterize the values
const result = buildQueryWithoutExecuting(query, projectId);
expect(result.query).not.toContain(
"production,development'); DROP TABLE traces; --",
);
});
});
describe("SQL Injection via Time Dimension", () => {
it("should prevent injection via time dimension granularity", async () => {
// Comment: Time dimension granularity should be validated against allowed values
const maliciousQuery: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: {
granularity: "minute; DROP TABLE traces; --" as any,
},
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// Should throw an error for invalid granularity
expect(() =>
buildQueryWithoutExecuting(maliciousQuery, projectId),
).toThrow("Invalid query");
});
});
describe("SQL Injection via Timestamp Parameters", () => {
it("should safely handle malicious timestamp strings", async () => {
// Comment: Timestamps need to be properly validated and converted
// to prevent SQL injection
const maliciousQuery: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: null,
fromTimestamp: "2023-01-01'); DROP TABLE traces; --",
toTimestamp: defaultToTime,
orderBy: null,
};
// Should throw an error for invalid timestamp format
expect(() =>
buildQueryWithoutExecuting(maliciousQuery, projectId),
).toThrow("Invalid query");
});
});
describe("SQL Injection via Project ID", () => {
it("should safely handle malicious project ID", async () => {
// Comment: Project ID is a critical parameter that must be properly sanitized
const query: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
const maliciousProjectId = "fake-id'; DROP TABLE traces; --";
// Should parameterize the project ID
const result = buildQueryWithoutExecuting(query, maliciousProjectId);
// Check for parameterization
expect(result.query).not.toContain(maliciousProjectId);
expect(Object.values(result.parameters)).toContain(maliciousProjectId);
});
});
describe("SQL Injection via OrderBy Parameters", () => {
it("should prevent injection via orderBy field name", async () => {
// Comment: The field names in orderBy should be validated against dimension and metric fields
// to prevent SQL injection via field name
const maliciousQuery: QueryType = {
view: "traces",
dimensions: [{ field: "name" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: [
{
field: "name; DROP TABLE traces; --",
direction: "asc",
},
],
};
// Should throw an error for invalid orderBy field
expect(() =>
buildQueryWithoutExecuting(maliciousQuery, projectId),
).toThrow("Invalid orderBy field");
});
it("should prevent injection via orderBy direction", async () => {
// Comment: The direction value should be validated to prevent SQL injection
const maliciousQuery: QueryType = {
view: "traces",
dimensions: [{ field: "name" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: [
{
field: "name",
direction: "asc; DROP TABLE traces; --" as any,
},
],
};
// Should throw an error for invalid direction
expect(() =>
buildQueryWithoutExecuting(maliciousQuery, projectId),
).toThrow("Invalid query");
});
it("should prevent injection via non-existing metric field in orderBy", async () => {
// Comment: The field must exist as a metric with proper aggregation prefix
const maliciousQuery: QueryType = {
view: "traces",
dimensions: [{ field: "name" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: [
{
field: "sum_malicious_metric; DROP TABLE traces; --",
direction: "asc",
},
],
};
// Should throw an error for invalid orderBy field
expect(() =>
buildQueryWithoutExecuting(maliciousQuery, projectId),
).toThrow("Invalid orderBy field");
});
});
describe("Integration with executeQuery function", () => {
it("should safely handle malicious query parameters through executeQuery", async () => {
// Comment: This tests the integration with the dashboard router's executeQuery function
// to ensure SQL injection protection works end-to-end
jest.spyOn(console, "error").mockImplementation(() => {});
const maliciousQuery: QueryType = {
view: "traces",
dimensions: [],
metrics: [
{
measure: "count); DELETE FROM traces; --" as any,
aggregation: "count",
},
],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// Expect the executeQuery function to throw a TRPC error
// rather than allowing the injection
await expect(executeQuery(projectId, maliciousQuery)).rejects.toThrow(
TRPCError,
);
});
});
});
@@ -730,6 +730,36 @@ describe("OTel Resource Span Mapping", () => {
entityAttributeValue: "gpt-4o-mini",
},
],
[
"#6084: should map input to input for pydantic",
{
entity: "observation",
otelAttributeKey: "input",
otelAttributeValue: {
stringValue: JSON.stringify({
task: "Play some chess",
stream: false,
}),
},
entityAttributeKey: "input",
entityAttributeValue: JSON.stringify({
task: "Play some chess",
stream: false,
}),
},
],
[
"#6084: should map model_config to modelParameters",
{
entity: "observation",
otelAttributeKey: "model_config",
otelAttributeValue: {
stringValue: '{"max_tokens": 4096}',
},
entityAttributeKey: "modelParameters.max_tokens",
entityAttributeValue: 4096,
},
],
[
"#5412: should map input.value to input for smolagents",
{
@@ -796,6 +826,54 @@ describe("OTel Resource Span Mapping", () => {
entityAttributeValue: '{"foo": "bar"}',
},
],
[
"should map langfuse.metadata string to top-level metadata for trace",
{
entity: "trace",
otelAttributeKey: "langfuse.metadata",
otelAttributeValue: {
stringValue: '{"customer_id": "123", "experiment": "test-run-1"}',
},
entityAttributeKey: "metadata.customer_id",
entityAttributeValue: "123",
},
],
[
"should map langfuse.metadata string to top-level metadata for observation",
{
entity: "observation",
otelAttributeKey: "langfuse.metadata",
otelAttributeValue: {
stringValue: '{"customer_id": "123", "experiment": "test-run-1"}',
},
entityAttributeKey: "metadata.customer_id",
entityAttributeValue: "123",
},
],
[
"should extract metadata from langfuse.metadata.* keys for trace",
{
entity: "trace",
otelAttributeKey: "langfuse.metadata.user_type",
otelAttributeValue: {
stringValue: "premium",
},
entityAttributeKey: "metadata.user_type",
entityAttributeValue: "premium",
},
],
[
"should extract metadata from langfuse.metadata.* keys for observation",
{
entity: "observation",
otelAttributeKey: "langfuse.metadata.user_type",
otelAttributeValue: {
stringValue: "premium",
},
entityAttributeKey: "metadata.user_type",
entityAttributeValue: "premium",
},
],
])(
"Attributes: %s",
(
@@ -882,6 +960,30 @@ describe("OTel Resource Span Mapping", () => {
entityAttributeValue: "default",
},
],
[
"should extract metadata from resource attributes",
{
entity: "observation",
otelResourceAttributeKey: "langfuse.metadata",
otelResourceAttributeValue: {
stringValue: '{"resource_id": "xyz", "region": "us-west-2"}',
},
entityAttributeKey: "metadata.resource_id",
entityAttributeValue: "xyz",
},
],
[
"should extract metadata from langfuse.metadata.* resource attributes",
{
entity: "observation",
otelResourceAttributeKey: "langfuse.metadata.server_name",
otelResourceAttributeValue: {
stringValue: "web-server-01",
},
entityAttributeKey: "metadata.server_name",
entityAttributeValue: "web-server-01",
},
],
])(
"ResourceAttributes: %s",
(
@@ -917,9 +1019,11 @@ describe("OTel Resource Span Mapping", () => {
// Then
const entity: { body: Record<string, any> } =
spec.entity === "trace" ? langfuseEvents[0] : langfuseEvents[1];
expect(entity.body[spec.entityAttributeKey]).toEqual(
spec.entityAttributeValue,
);
expect(
spec.entityAttributeKey // This logic allows to follow a path in the object, e.g. foo.bar.baz.
.split(".")
.reduce((acc: any, key: string) => acc && acc[key], entity.body),
).toEqual(spec.entityAttributeValue);
},
);
+2
View File
@@ -46,6 +46,8 @@ describe("parseJsonPrioritised", () => {
'{"array": [1, 2], "nested": {"key": "value"}}',
{ array: [1, 2], nested: { key: "value" } },
], // Complex object
["1983516295378495150", "1983516295378495150"], // Large number
["3.4", 3.4], // Decimal number
])(
"should parse input correctly (%s, %s)",
(input: string, expectedOutput: any) => {
+53 -38
View File
@@ -1,3 +1,4 @@
import React from "react";
import { Lock, AlertCircle, Sparkle } from "lucide-react";
import { Button, type ButtonProps } from "@/src/components/ui/button";
import {
@@ -28,19 +29,25 @@ interface ActionButtonProps extends ButtonProps {
href?: string;
}
export function ActionButton({
loading = false,
hasAccess = true,
hasEntitlement = true,
limitValue,
limit = false,
disabled = false,
children,
icon,
className,
href,
...buttonProps
}: ActionButtonProps) {
export const ActionButton = React.forwardRef<
HTMLButtonElement,
ActionButtonProps
>(function ActionButton(
{
loading = false,
hasAccess = true,
hasEntitlement = true,
limitValue,
limit = false,
disabled = false,
children,
icon,
className,
href,
...buttonProps
},
ref,
) {
const hasReachedLimit =
typeof limit === "number" &&
limitValue !== undefined &&
@@ -65,6 +72,7 @@ export function ActionButton({
const btnContent = (
<ButtonContent
ref={ref}
icon={icon}
isDisabled={isDisabled}
loading={loading}
@@ -95,31 +103,37 @@ export function ActionButton({
}
return btnContent;
}
});
function ButtonContent({
icon,
isDisabled,
loading,
hasAccess,
hasEntitlement,
hasReachedLimit,
className,
buttonProps,
children,
href,
}: {
icon?: React.ReactNode;
isDisabled: boolean;
loading: boolean;
hasAccess: boolean;
hasEntitlement: boolean;
hasReachedLimit: boolean;
className?: string;
buttonProps: Omit<ButtonProps, "disabled" | "loading" | "className">;
children: React.ReactNode;
href?: string;
}) {
const ButtonContent = React.forwardRef<
HTMLButtonElement,
{
icon?: React.ReactNode;
isDisabled: boolean;
loading: boolean;
hasAccess: boolean;
hasEntitlement: boolean;
hasReachedLimit: boolean;
className?: string;
buttonProps: Omit<ButtonProps, "disabled" | "loading" | "className">;
children: React.ReactNode;
href?: string;
}
>(function ButtonContent(
{
icon,
isDisabled,
loading,
hasAccess,
hasEntitlement,
hasReachedLimit,
className,
buttonProps,
children,
href,
},
ref,
) {
const content = (
<>
{!hasAccess ? (
@@ -139,6 +153,7 @@ function ButtonContent({
return (
<Button
ref={ref}
disabled={isDisabled}
loading={loading}
className={className}
@@ -148,4 +163,4 @@ function ButtonContent({
{renderLink ? <Link href={href}>{content}</Link> : content}
</Button>
);
}
});
+22 -21
View File
@@ -3,9 +3,12 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuLabel,
} from "@/src/components/ui/dropdown-menu";
import { Button } from "@/src/components/ui/button";
import { ChevronDownIcon, Loader } from "lucide-react";
import { Download, Loader } from "lucide-react";
import {
type BatchExportTableName,
exportOptions,
@@ -69,31 +72,29 @@ export const BatchExportTableButton: React.FC<BatchExportTableButtonProps> = (
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-auto whitespace-nowrap">
<span className="hidden @6xl:inline">
{props.filterState.length > 0 || props.searchQuery
? "Export selection"
: "Export all"}{" "}
</span>
<span className="@6xl:hidden">Export</span>
<Button variant="outline" size="icon" title="Export">
{isExporting ? (
<Loader className="ml-2 h-4 w-4 animate-spin" />
<Loader className="h-4 w-4 animate-spin" />
) : (
<ChevronDownIcon className="ml-2 h-4 w-4" />
<Download className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{Object.entries(exportOptions).map(([key, options]) => (
<DropdownMenuItem
key={key}
className="capitalize"
onClick={() => void handleExport(key as BatchExportFileFormat)}
>
as {options.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
<DropdownMenuPortal>
<DropdownMenuContent>
<DropdownMenuLabel>Export</DropdownMenuLabel>
<DropdownMenuSeparator />
{Object.entries(exportOptions).map(([key, options]) => (
<DropdownMenuItem
key={key}
className="capitalize"
onClick={() => void handleExport(key as BatchExportFileFormat)}
>
as {options.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
);
};
+1 -1
View File
@@ -70,7 +70,7 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
modelParamsKey="model"
disabled={formDisabled}
value={modelParams.model.value}
options={availableModels}
options={[...new Set(availableModels)]}
updateModelParam={updateModelParamValue}
modelParamsDescription={modelParamsDescription}
/>
+2 -3
View File
@@ -20,7 +20,6 @@ import {
isTableDataRangeOptionAvailable,
isDashboardDateRangeOptionAvailable,
} from "@/src/utils/date-range-utils";
import { Clock } from "lucide-react";
import { useEntitlementLimit } from "@/src/features/entitlements/hooks";
import { useMemo } from "react";
import {
@@ -45,8 +44,8 @@ const BaseDateRangeDropdown = <T extends string>({
}: BaseDateRangeDropdownProps<T>) => {
return (
<Select value={selectedOption} onValueChange={onSelectionChange}>
<SelectTrigger className="w-[130px] hover:bg-accent hover:text-accent-foreground focus:ring-0 focus:ring-offset-0">
<Clock className="h-4 w-4" />
<SelectTrigger className="w-fit font-medium hover:bg-accent hover:text-accent-foreground focus:ring-0 focus:ring-offset-0">
{selectedOption !== "All time" && <span>Past</span>}
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="popper" defaultValue={60}>
+2 -2
View File
@@ -62,8 +62,8 @@ const PageHeader = ({
</div>
)}
<div className="relative inline-block max-w-md md:max-w-none">
<h2 className="inline text-lg font-semibold leading-7">
<span className="break-words">
<h2 className="line-clamp-1 text-lg font-semibold leading-7">
<span className="break-words" title={title}>
{title}
{help && (
<span className="whitespace-nowrap">
+1 -1
View File
@@ -14,7 +14,7 @@ const Page = ({
children,
headerProps,
scrollable = false,
withPadding = true,
withPadding = false,
}: PageContainerProps) => {
return (
<div
+7
View File
@@ -24,6 +24,7 @@ import { SupportMenuDropdown } from "@/src/components/nav/support-menu-dropdown"
import { SidebarMenuButton } from "@/src/components/ui/sidebar";
import { useCommandMenu } from "@/src/features/command-k-menu/CommandMenuProvider";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { CloudStatusMenu } from "@/src/features/cloud-status-notification/components/CloudStatusMenu";
export type Route = {
title: string;
@@ -151,6 +152,12 @@ export const ROUTES: Route[] = [
organizationRbacScope: "langfuseCloudBilling:CRUD",
show: ({ organization }) => organization?.plan === "cloud:hobby",
},
{
title: "Cloud Status",
bottom: true,
pathname: "",
menuNode: <CloudStatusMenu />,
},
{
title: "Settings",
pathname: "/project/[projectId]/settings",
@@ -0,0 +1,13 @@
import { Card } from "@/src/components/ui/card";
export const SettingsTableCard = ({
children,
}: {
children: React.ReactNode;
}) => {
return (
<Card className="mb-4 flex max-h-[60dvh] flex-col overflow-hidden [&>:first-child>:first-child]:border-t-0">
{children}
</Card>
);
};
@@ -9,6 +9,7 @@ import {
ArrowUpRight,
Bug,
Github,
Radio,
LibraryBig,
LifeBuoy,
Lightbulb,
@@ -31,6 +32,7 @@ import {
} from "@/src/features/support-chat/chat";
import { Switch } from "@/src/components/ui/switch";
import { SiDiscord } from "react-icons/si";
import { env } from "@/src/env.mjs";
type SupportMenuItem = {
title: string;
@@ -105,6 +107,13 @@ export const SupportMenuDropdown = () => {
icon: LibraryBig,
customizableHref: "documentationHref",
});
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) {
items.push({
title: "Status Page",
pathname: "https://status.langfuse.com",
icon: Radio,
});
}
items.push("separator");
if (uiCustomization?.feedbackHref) {
+21 -18
View File
@@ -192,6 +192,7 @@ export const SessionPage: React.FC<{
return (
<Page
withPadding
scrollable
headerProps={{
title: sessionId,
@@ -220,24 +221,26 @@ export const SessionPage: React.FC<{
/>
</div>
),
actionButtonsRight: [
<DetailPageNav
key="nav"
currentId={encodeURIComponent(sessionId)}
path={(entry) =>
`/project/${projectId}/sessions/${encodeURIComponent(entry.id)}`
}
listKey="sessions"
/>,
<CommentDrawerButton
key="comment"
variant="outline"
projectId={projectId}
objectId={sessionId}
objectType="SESSION"
count={sessionCommentCounts.data?.get(sessionId)}
/>,
],
actionButtonsRight: (
<>
<DetailPageNav
key="nav"
currentId={encodeURIComponent(sessionId)}
path={(entry) =>
`/project/${projectId}/sessions/${encodeURIComponent(entry.id)}`
}
listKey="sessions"
/>
<CommentDrawerButton
key="comment"
variant="outline"
projectId={projectId}
objectId={sessionId}
objectType="SESSION"
count={sessionCommentCounts.data?.get(sessionId)}
/>
</>
),
}}
>
<div className="flex flex-wrap gap-2">
+5 -1
View File
@@ -25,9 +25,13 @@ export function StarToggle({
<Button
variant="ghost"
size={size}
onClick={() => void onClick(!value)}
onClick={(e) => {
e.stopPropagation();
void onClick(!value);
}}
disabled={disabled}
loading={isLoading}
aria-label="bookmark"
>
<StarIcon
className="h-4 w-4"
@@ -1,65 +0,0 @@
import { useState, type ReactNode } from "react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/src/components/ui/card";
import { Button } from "@/src/components/ui/button";
import { ChevronRight } from "lucide-react";
import { Separator } from "@/src/components/ui/separator";
export const TableWithMetadataWrapper = ({
tableComponent,
cardTitleChildren,
cardContentChildren,
}: {
tableComponent: ReactNode;
cardTitleChildren: ReactNode;
cardContentChildren: ReactNode;
}) => {
const [isCollapsed, setIsCollapsed] = useState(false);
return (
<div
className={`grid flex-1 ${isCollapsed ? "grid-cols-[2fr,auto]" : "grid-cols-[2fr,1fr]"} gap-4 overflow-hidden`}
>
<div className="flex h-full flex-col overflow-hidden">
{tableComponent}
</div>
<div
className={`flex flex-row ${isCollapsed ? "w-8" : "w-full"} h-full overflow-hidden`}
>
<div className="grid h-full w-full grid-cols-[auto,1fr] items-start gap-2 overflow-hidden">
<div className="grid h-full w-full grid-rows-[auto,1fr] gap-2">
<Button
variant="outline"
size="icon"
onClick={() => setIsCollapsed(!isCollapsed)}
>
<ChevronRight
className={`h-4 w-4 transform ${isCollapsed ? "rotate-180" : ""}`}
/>
</Button>
<Separator orientation="vertical" className="ml-4 h-full" />
</div>
<div
className={`${isCollapsed ? "hidden" : "block"} grid max-h-full w-full grid-rows-[auto,1fr] overflow-hidden p-2`}
>
<div className="my-2 flex h-6 flex-wrap items-center gap-2 @container" />
<Card className="flex h-full flex-col overflow-hidden">
<CardHeader className="flex h-full w-full flex-col space-y-4">
<CardTitle className="flex justify-between text-xl font-bold leading-7 sm:tracking-tight">
{cardTitleChildren}
</CardTitle>
<CardContent className="flex-1 space-y-4 overflow-y-auto p-0">
{cardContentChildren}
</CardContent>
</CardHeader>
</Card>
</div>
</div>
</div>
</div>
);
};
@@ -24,7 +24,6 @@ import {
Check,
ChevronDown,
ChevronRight,
Columns,
Component,
Menu,
} from "lucide-react";
@@ -73,11 +72,12 @@ const calculateColumnCounts = <TData, TValue>(
);
acc.count += groupCounts.count;
acc.total += groupCounts.total;
} else if (column.enableHiding) {
} else {
acc.total++;
if (
column.accessorKey in columnVisibility &&
columnVisibility[column.accessorKey]
(column.accessorKey in columnVisibility &&
columnVisibility[column.accessorKey]) ||
!column.enableHiding
) {
acc.count++;
}
@@ -323,8 +323,8 @@ export function DataTableColumnVisibilityFilter<TData, TValue>({
asChild
>
<Button variant="outline" title="Show/hide columns">
<Columns className="mr-2 h-4 w-4" />
<span className="text-xs text-muted-foreground">{`(${count}/${total})`}</span>
<span>Columns</span>
<div className="ml-1 rounded-sm bg-input px-1 text-xs">{`${count}/${total}`}</div>
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -9,7 +9,7 @@ export function DataTableSelectAllBanner({
totalCount,
}: MultiSelect) {
return (
<div className="my-2 flex flex-wrap items-center justify-center gap-2 rounded-sm bg-input p-2 @container">
<div className="mb-2 flex flex-wrap items-center justify-center gap-2 rounded-sm bg-input p-2 @container">
{selectAll ? (
<span className="text-sm">
All <span className="font-semibold">{totalCount}</span> items are
@@ -1,6 +1,16 @@
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
import { Button } from "@/src/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuPortal,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuCheckboxItem,
} from "@/src/components/ui/dropdown-menu";
import useLocalStorage from "@/src/components/useLocalStorage";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { Rows3 } from "lucide-react";
import {
MdDensityLarge,
MdDensityMedium,
@@ -39,30 +49,34 @@ export const DataTableRowHeightSwitch = ({
}) => {
const capture = usePostHogClientCapture();
return (
<Tabs
//defaultValue={height}
value={rowHeight}
onValueChange={(e) => {
capture("table:row_height_switch_select", {
rowHeight: e,
});
setRowHeight(e as any);
}}
key="height"
>
<TabsList className="gap-1 border bg-transparent px-2">
{heightOptions.map(({ id, label, icon }) => (
<TabsTrigger
key={id}
value={id}
className="px-2 shadow-none data-[state=active]:bg-input data-[state=active]:ring-border"
>
<span role="img" aria-label={`${label} size`}>
{icon}
</span>
</TabsTrigger>
))}
</TabsList>
</Tabs>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" title="Row height">
<Rows3 className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
<DropdownMenuLabel>Row height</DropdownMenuLabel>
<DropdownMenuSeparator />
{heightOptions.map(({ id, label }) => (
<DropdownMenuCheckboxItem
key={id}
checked={rowHeight === id}
onClick={(e) => {
// Prevent closing the dropdown menu to allow the user to adjust their selection
e.preventDefault();
capture("table:row_height_switch_select", {
rowHeight: id,
});
setRowHeight(id);
}}
>
{label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
);
};
+24 -20
View File
@@ -24,6 +24,7 @@ import {
} from "@/src/utils/date-range-utils";
import { DataTableSelectAllBanner } from "@/src/components/table/data-table-multi-select-actions/data-table-select-all-banner";
import { MultiSelect } from "@/src/features/filters/components/multi-select";
import { cn } from "@/src/utils/tailwind";
export interface MultiSelect {
selectAll: boolean;
@@ -68,6 +69,7 @@ interface DataTableToolbarProps<TData, TValue> {
onValueChange: (values: string[]) => void;
options: { value: string }[];
};
className?: string;
}
export function DataTableToolbar<TData, TValue>({
@@ -88,6 +90,7 @@ export function DataTableToolbar<TData, TValue>({
setDateRangeAndOption,
multiSelect,
environmentFilter,
className,
}: DataTableToolbarProps<TData, TValue>) {
const [searchString, setSearchString] = useState(
searchConfig?.currentQuery ?? "",
@@ -95,10 +98,20 @@ export function DataTableToolbar<TData, TValue>({
const capture = usePostHogClientCapture();
return (
<>
<div className={cn("grid h-fit w-full gap-0 px-2", className)}>
<div className="my-2 flex flex-wrap items-center gap-2 @container">
{searchConfig && (
<div className="flex max-w-md items-center">
<div className="flex max-w-md items-center rounded-md border">
<Button
variant="ghost"
size="icon"
onClick={() => {
capture("table:search_submit");
searchConfig.updateQuery(searchString);
}}
>
<Search className="h-4 w-4" />
</Button>
<Input
autoFocus
placeholder={searchConfig.placeholder}
@@ -110,20 +123,16 @@ export function DataTableToolbar<TData, TValue>({
searchConfig.updateQuery(searchString);
}
}}
className="w-[150px] rounded-r-none @6xl:w-[250px]"
className="min-w-0 max-w-fit border-none px-0"
/>
<Button
variant="outline"
onClick={() => {
capture("table:search_submit");
searchConfig.updateQuery(searchString);
}}
className="rounded-l-none border-l-0 p-3"
>
<Search className="h-4 w-4" />
</Button>
</div>
)}
{selectedOption && setDateRangeAndOption && (
<TableDateRangeDropdown
selectedOption={selectedOption}
setDateRangeAndOption={setDateRangeAndOption}
/>
)}
{!!filterColumnDefinition && !!filterState && !!setFilterState && (
<PopoverFilterBuilder
columns={filterColumnDefinition}
@@ -142,12 +151,7 @@ export function DataTableToolbar<TData, TValue>({
className="my-0 w-auto overflow-hidden"
/>
)}
{selectedOption && setDateRangeAndOption && (
<TableDateRangeDropdown
selectedOption={selectedOption}
setDateRangeAndOption={setDateRangeAndOption}
/>
)}
<div className="flex flex-row flex-wrap gap-2 pr-0.5 @6xl:ml-auto">
{!!columnVisibility && !!setColumnVisibility && (
<DataTableColumnVisibilityFilter
@@ -172,6 +176,6 @@ export function DataTableToolbar<TData, TValue>({
multiSelect.selectedRowIds.length === multiSelect.pageSize && (
<DataTableSelectAllBanner {...multiSelect} />
)}
</>
</div>
);
}
+67 -17
View File
@@ -1,7 +1,6 @@
"use client";
import { type OrderByState } from "@langfuse/shared";
import React, { useState, useMemo } from "react";
import DocPopup from "@/src/components/layouts/doc-popup";
import { DataTablePagination } from "@/src/components/table/data-table-pagination";
import {
@@ -32,6 +31,16 @@ import {
type RowSelectionState,
type VisibilityState,
} from "@tanstack/react-table";
import { useRouter } from "next/router";
import {
TablePeekView,
type DataTablePeekViewProps,
} from "@/src/components/table/peek";
type PeekViewProps<TData> = Omit<
DataTablePeekViewProps<TData>,
"selectedRowId"
>;
interface DataTableProps<TData, TValue> {
columns: LangfuseColumnDef<TData, TValue>[];
@@ -53,10 +62,10 @@ interface DataTableProps<TData, TValue> {
help?: { description: string; href: string };
rowHeight?: RowHeight;
className?: string;
paginationClassName?: string;
isBorderless?: boolean;
shouldRenderGroupHeaders?: boolean;
onRowClick?: (row: TData) => void;
peekView?: PeekViewProps<TData>;
pinFirstColumn?: boolean;
}
export interface AsyncTableData<T> {
@@ -106,14 +115,16 @@ export function DataTable<TData extends object, TValue>({
setOrderBy,
rowHeight,
className,
paginationClassName,
isBorderless = false,
shouldRenderGroupHeaders = false,
onRowClick,
peekView,
pinFirstColumn = false,
}: DataTableProps<TData, TValue>) {
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const rowheighttw = getRowHeightTailwindClass(rowHeight);
const capture = usePostHogClientCapture();
const router = useRouter();
const peekViewId = router.query.peek as string | undefined;
const flattedColumnsByGroup = useMemo(() => {
const flatColumnsByGroup = new Map<string, string[]>();
@@ -127,6 +138,24 @@ export function DataTable<TData extends object, TValue>({
return flatColumnsByGroup;
}, [columns]);
const handleOnRowClick = (row: TData) => {
if (peekView) {
const rowId =
"id" in row && typeof row.id === "string" ? row.id : undefined;
// If clicking the same row that's already open, close it
if (rowId === peekViewId) {
peekView.onOpenChange(false);
}
// If clicking a different row, update without closing first
else {
peekView.onOpenChange(true, row);
}
}
onRowClick?.(row);
};
const hasRowClickAction = !!onRowClick || !!peekView;
const table = useReactTable({
data: data.data ?? [],
columns,
@@ -200,15 +229,12 @@ export function DataTable<TData extends object, TValue>({
<>
<div
className={cn(
"flex w-full max-w-full flex-1 flex-col gap-1 overflow-auto",
"flex w-full max-w-full flex-1 flex-col overflow-auto",
className,
)}
>
<div
className={cn(
"relative w-full overflow-auto",
isBorderless ? "" : "rounded-md border",
)}
className={cn("relative w-full overflow-auto border-t")}
style={{ ...columnSizeVars }}
>
<Table>
@@ -234,6 +260,9 @@ export function DataTable<TData extends object, TValue>({
className={cn(
"group p-1 first:pl-2",
sortingEnabled && "cursor-pointer",
pinFirstColumn &&
header.index === 0 &&
"sticky left-0 z-20 border-r bg-background",
)}
style={{ width }}
onClick={(event) => {
@@ -321,7 +350,9 @@ export function DataTable<TData extends object, TValue>({
columns={columns}
data={data}
help={help}
onRowClick={onRowClick}
onRowClick={hasRowClickAction ? handleOnRowClick : undefined}
peekViewId={peekViewId}
pinFirstColumn={pinFirstColumn}
/>
) : (
<TableBodyComponent
@@ -330,18 +361,22 @@ export function DataTable<TData extends object, TValue>({
columns={columns}
data={data}
help={help}
onRowClick={onRowClick}
onRowClick={hasRowClickAction ? handleOnRowClick : undefined}
peekViewId={peekViewId}
pinFirstColumn={pinFirstColumn}
/>
)}
</Table>
</div>
<div className="grow"></div>
</div>
{peekView && peekViewId && (
<TablePeekView selectedRowId={peekViewId} {...peekView} />
)}
{pagination !== undefined ? (
<div
className={cn(
"sticky bottom-0 z-10 flex w-full justify-end bg-background font-medium",
paginationClassName,
"sticky bottom-0 z-10 flex w-full justify-end border-t bg-background py-2 pr-2 font-medium",
)}
>
<DataTablePagination
@@ -373,6 +408,8 @@ interface TableBodyComponentProps<TData> {
data: AsyncTableData<TData[]>;
help?: { description: string; href: string };
onRowClick?: (row: TData) => void;
peekViewId?: string;
pinFirstColumn?: boolean;
}
function TableBodyComponent<TData>({
@@ -382,6 +419,8 @@ function TableBodyComponent<TData>({
data,
help,
onRowClick,
peekViewId,
pinFirstColumn = false,
}: TableBodyComponentProps<TData>) {
return (
<TableBody>
@@ -398,10 +437,18 @@ function TableBodyComponent<TData>({
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-row-index={row.index}
onClick={() => onRowClick?.(row.original)}
className={
onRowClick ? "cursor-pointer hover:bg-accent" : undefined
}
onKeyDown={(e) => {
if (e.key === "Enter") {
onRowClick?.(row.original);
}
}}
className={cn(
"hover:bg-accent",
!!onRowClick ? "cursor-pointer" : "cursor-default",
peekViewId && peekViewId === row.id ? "bg-accent" : undefined,
)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
@@ -409,6 +456,9 @@ function TableBodyComponent<TData>({
className={cn(
"overflow-hidden border-b p-1 text-xs first:pl-2",
rowheighttw === "s" && "whitespace-nowrap",
pinFirstColumn &&
cell.column.getIndex() === 0 &&
"sticky left-0 border-r bg-background",
)}
style={{
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
+133
View File
@@ -0,0 +1,133 @@
import { Button } from "@/src/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/src/components/ui/sheet";
import { Expand, ExternalLink } from "lucide-react";
import { Separator } from "@/src/components/ui/separator";
import { ItemBadge, type LangfuseItemType } from "@/src/components/ItemBadge";
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
import { useRouter } from "next/router";
type PeekViewItemType = Extract<LangfuseItemType, "TRACE">;
export type DataTablePeekViewProps<TData> = {
itemType: PeekViewItemType;
selectedRowId: string | null;
onOpenChange: (open: boolean, row?: TData) => void;
onExpand: (openInNewTab: boolean) => void;
render: () => React.ReactNode;
};
const mapItemTypeToPageUrl: Record<PeekViewItemType, string> = {
TRACE: "traces",
} as const;
export function TablePeekView<TData>({
itemType,
selectedRowId,
onOpenChange,
onExpand,
render,
}: DataTablePeekViewProps<TData>) {
const router = useRouter();
const pageUrl = mapItemTypeToPageUrl[itemType];
return (
<Sheet
open={!!selectedRowId}
onOpenChange={(open) => {
// Ignore close events from checkbox or bookmark star toggle clicks to ensure integrity of table row actions
if (
!open &&
(document.activeElement?.closest('[role="checkbox"]') ||
document.activeElement?.closest('[aria-label="bookmark"]'))
) {
return;
}
onOpenChange(open);
}}
modal={false}
>
<SheetContent
onPointerDownOutside={(e) => {
// Prevent the default behavior of closing when clicking outside when we set modal={false}
e.preventDefault();
}}
side="right"
className="flex max-h-full min-h-0 min-w-[60vw] flex-col gap-0 overflow-hidden rounded-l-xl p-0"
>
<SheetHeader className="flex min-h-12 flex-row justify-between rounded-t-xl bg-header px-2">
<SheetTitle className="!mt-0 ml-2 flex flex-row items-center gap-2">
<ItemBadge type={itemType} showLabel />
<span
className="text-sm font-medium focus:outline-none"
tabIndex={0}
>
{selectedRowId}
</span>
</SheetTitle>
<div className="!mt-0 flex flex-row items-center gap-2">
{selectedRowId && (
<DetailPageNav
currentId={selectedRowId}
path={(entry) => {
const { projectId } = router.query;
const url = new URL(window.location.href);
// Update the path part
url.pathname = `/project/${projectId as string}/${pageUrl}`;
// Keep all existing query params
const params = new URLSearchParams(url.search);
// Update timestamp if it exists in entry.params
if (entry.params) {
if (entry.params.timestamp)
params.set(
"timestamp",
encodeURIComponent(entry.params.timestamp),
);
params.delete("observation");
}
// Update peek param to the new id
params.set("peek", entry.id);
// Set the search part of the URL
return `${url.pathname}?${params.toString()}`;
}}
listKey={pageUrl}
/>
)}
<div className="!mt-0 mr-8 flex h-full flex-row items-center gap-1 border-l">
<Button
variant="ghost"
size="icon-xs"
title="Open in current tab"
className="ml-2"
onClick={() => onExpand(false)}
>
<Expand className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-xs"
title="Open in new tab"
onClick={() => onExpand(true)}
>
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</div>
</SheetHeader>
<Separator />
<div className="flex max-h-full min-h-0 flex-1 flex-col">
<div className="flex-1 overflow-auto">{render()}</div>
</div>
</SheetContent>
</Sheet>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { cn } from "@/src/utils/tailwind";
export default function TableId({
value,
className,
}: {
value: string;
className?: string;
}) {
return (
<div
title={value}
className={cn(
"inline-block max-w-full overflow-hidden text-ellipsis text-nowrap rounded py-0.5 text-xs font-semibold",
className,
)}
>
{value}
</div>
);
}
+35 -32
View File
@@ -27,6 +27,7 @@ import { UpsertModelFormDrawer } from "@/src/features/models/components/UpsertMo
import { ActionButton } from "@/src/components/ActionButton";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { SettingsTableCard } from "@/src/components/layouts/settings-table-card";
export type ModelTableRow = {
modelId: string;
@@ -290,39 +291,41 @@ export default function ModelTable({ projectId }: { projectId: string }) {
</ActionButton>
</UpsertModelFormDrawer>
}
className="px-0"
/>
<DataTable
className="flex max-h-[60dvh] flex-col overflow-hidden"
columns={columns}
data={
models.isLoading
? { isLoading: true, isError: false }
: models.isError
? {
isLoading: false,
isError: true,
error: models.error.message,
}
: {
isLoading: false,
isError: false,
data: models.data.models.map((t) => convertToTableRow(t)),
}
}
pagination={{
totalCount,
onChange: setPaginationState,
state: paginationState,
}}
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
rowHeight={rowHeight}
onRowClick={(row) => {
router.push(`/project/${projectId}/settings/models/${row.modelId}`);
}}
/>
<SettingsTableCard>
<DataTable
columns={columns}
data={
models.isLoading
? { isLoading: true, isError: false }
: models.isError
? {
isLoading: false,
isError: true,
error: models.error.message,
}
: {
isLoading: false,
isError: false,
data: models.data.models.map((t) => convertToTableRow(t)),
}
}
pagination={{
totalCount,
onChange: setPaginationState,
state: paginationState,
}}
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
rowHeight={rowHeight}
onRowClick={(row) => {
router.push(`/project/${projectId}/settings/models/${row.modelId}`);
}}
/>
</SettingsTableCard>
</>
);
}
@@ -301,7 +301,7 @@ export default function ObservationsTable({
accessorKey: "type",
id: "type",
header: "Type",
size: 100,
size: 120,
enableSorting: true,
cell: ({ row }) => {
const value: ObservationType = row.getValue("type");
@@ -856,7 +856,7 @@ export default function ObservationsTable({
filterState={inputFilterState}
setFilterState={useDebounce(setInputFilterState)}
searchConfig={{
placeholder: "Search by id, name, traceName, model",
placeholder: "Search (by id, name, trace name, model)",
updateQuery: setSearchQuery,
currentQuery: searchQuery ?? undefined,
}}
@@ -1,5 +1,4 @@
import React from "react";
import { Card } from "@/src/components/ui/card";
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
import { api } from "@/src/utils/api";
@@ -29,6 +28,8 @@ import {
} from "@/src/components/ui/popover";
import useLocalStorage from "@/src/components/useLocalStorage";
import useColumnOrder from "@/src/features/column-visibility/hooks/useColumnOrder";
import { CreateScoreConfigButton } from "@/src/features/scores/components/CreateScoreConfigButton";
import { SettingsTableCard } from "@/src/components/layouts/settings-table-card";
type ScoreConfigTableRow = {
id: string;
@@ -248,8 +249,10 @@ export function ScoreConfigsTable({ projectId }: { projectId: string }) {
setColumnOrder={setColumnOrder}
rowHeight={rowHeight}
setRowHeight={setRowHeight}
actionButtons={<CreateScoreConfigButton projectId={projectId} />}
className="px-0"
/>
<Card className="mb-4 flex max-h-[60dvh] flex-col overflow-hidden">
<SettingsTableCard>
<DataTable
columns={columns}
data={
@@ -291,10 +294,8 @@ export function ScoreConfigsTable({ projectId }: { projectId: string }) {
onColumnOrderChange={setColumnOrder}
rowHeight={rowHeight}
className="gap-2"
paginationClassName="-mx-2 mb-2"
isBorderless
/>
</Card>
</SettingsTableCard>
</>
);
}
+106 -18
View File
@@ -1,7 +1,6 @@
import { StarTraceToggle } from "@/src/components/star-toggle";
import { DataTable } from "@/src/components/table/data-table";
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
import TableLink from "@/src/components/table/table-link";
import { Badge } from "@/src/components/ui/badge";
import { type LangfuseColumnDef } from "@/src/components/table/types";
import { TagTracePopover } from "@/src/features/tag/components/TagTracePopver";
@@ -75,6 +74,9 @@ import {
DropdownMenuTrigger,
} from "@/src/components/ui/dropdown-menu";
import { Button } from "@/src/components/ui/button";
import { Trace } from "@/src/components/trace";
import router from "next/router";
import TableId from "@/src/components/table/table-id";
import {
useEnvironmentFilter,
convertSelectedEnvironmentsToFilter,
@@ -122,6 +124,7 @@ export type TracesTableProps = {
projectId: string;
userId?: string;
omittedFilter?: string[];
pinFirstColumn?: boolean;
};
export default function TracesTable({
@@ -130,13 +133,17 @@ export default function TracesTable({
omittedFilter = [],
}: TracesTableProps) {
const utils = api.useUtils();
const [peekViewId] = useQueryParam("peek", withDefault(StringParam, null));
const [selectedRows, setSelectedRows] = useState<RowSelectionState>({});
const { setDetailPageList } = useDetailPageLists();
const [searchQuery, setSearchQuery] = useQueryParam(
"search",
withDefault(StringParam, null),
);
const [selectedTab, setSelectedTab] = useQueryParam(
"display",
withDefault(StringParam, "details"),
);
const { selectedOption, dateRange, setDateRangeAndOption } =
useTableDateRange(projectId);
const [userFilterState, setUserFilterState] = useQueryFilterState(
@@ -441,14 +448,9 @@ export default function TracesTable({
isPinned: true,
cell: ({ row }) => {
const value: TracesTableRow["id"] = row.getValue("id");
const timestamp: TracesTableRow["timestamp"] =
row.getValue("timestamp");
return value && typeof value === "string" ? (
<TableLink
path={`/project/${projectId}/traces/${encodeURIComponent(value)}?timestamp=${encodeURIComponent(timestamp.toISOString())}`}
value={value}
/>
<TableId value={value} />
) : undefined;
},
enableSorting: true,
@@ -504,10 +506,7 @@ export default function TracesTable({
cell: ({ row }) => {
const value: TracesTableRow["userId"] = row.getValue("userId");
return value && typeof value === "string" ? (
<TableLink
path={`/project/${projectId}/users/${encodeURIComponent(value)}`}
value={value}
/>
<TableId value={value} />
) : undefined;
},
enableHiding: true,
@@ -526,10 +525,7 @@ export default function TracesTable({
cell: ({ row }) => {
const value: TracesTableRow["sessionId"] = row.getValue("sessionId");
return value && typeof value === "string" ? (
<TableLink
path={`/project/${projectId}/sessions/${encodeURIComponent(value)}`}
value={value}
/>
<TableId value={value} />
) : undefined;
},
enableHiding: true,
@@ -544,7 +540,9 @@ export default function TracesTable({
cell: ({ row }) => {
const value: TracesTableRow["latency"] = row.getValue("latency");
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
return value !== undefined ? formatIntervalSeconds(value) : undefined;
return value !== undefined ? (
<span>{formatIntervalSeconds(value)}</span>
) : undefined;
},
enableHiding: true,
enableSorting: true,
@@ -969,7 +967,7 @@ export default function TracesTable({
columns={columns}
filterColumnDefinition={transformFilterOptions(traceFilterOptions.data)}
searchConfig={{
placeholder: "Search by id, name, user id",
placeholder: "Search (by id, name, trace name, user id)",
updateQuery: setSearchQuery,
currentQuery: searchQuery ?? undefined,
}}
@@ -1048,6 +1046,96 @@ export default function TracesTable({
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
rowHeight={rowHeight}
pinFirstColumn
peekView={{
itemType: "TRACE",
onOpenChange: (open: boolean, row?: TracesTableRow) => {
const url = new URL(window.location.href);
const params = new URLSearchParams(url.search);
if (!open || !row) {
// Remove peek and timestamp params while keeping others
params.delete("peek");
params.delete("timestamp");
params.delete("observation");
params.delete("display");
} else if (open && row.id && peekViewId !== row.id) {
// Update or add peek and timestamp params
params.set("peek", row.id);
params.set("timestamp", row.timestamp.toISOString());
params.delete("observation");
} else {
return;
}
router.replace(
{
pathname: `/project/${projectId}/traces`,
query: params.toString(),
},
undefined,
{ shallow: true },
);
},
onExpand: (openInNewTab: boolean) => {
if (peekViewId) {
const url = new URL(window.location.href);
const params = new URLSearchParams(url.search);
const timestamp = params.get("timestamp");
const display = params.get("display") ?? "details";
if (openInNewTab) {
window.open(
`/project/${projectId}/traces/${encodeURIComponent(peekViewId)}?timestamp=${timestamp}&display=${display}`,
"_blank",
);
} else {
router.replace(
`/project/${projectId}/traces/${encodeURIComponent(peekViewId)}?timestamp=${timestamp}&display=${display}`,
);
}
}
},
render: () => {
const { peek, timestamp } = router.query;
const trace = api.traces.byIdWithObservationsAndScores.useQuery(
{
traceId: peek as string,
timestamp:
typeof timestamp === "string"
? new Date(timestamp)
: undefined,
projectId,
},
{
enabled: !!peek && !!timestamp,
retry(failureCount, error) {
if (
error.data?.code === "UNAUTHORIZED" ||
error.data?.code === "NOT_FOUND"
)
return false;
return failureCount < 3;
},
},
);
return !trace.data ? (
<Skeleton className="h-full w-full" />
) : (
<Trace
key={trace.data.id}
trace={trace.data}
scores={trace.data.scores}
projectId={trace.data.projectId}
observations={trace.data.observations}
selectedTab={selectedTab}
setSelectedTab={setSelectedTab}
/>
);
},
}}
/>
</>
);
+23 -7
View File
@@ -2,7 +2,7 @@ import { JSONView } from "@/src/components/ui/CodeJsonViewer";
import { z } from "zod";
import { type Prisma, deepParseJson } from "@langfuse/shared";
import { cn } from "@/src/utils/tailwind";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/src/components/ui/button";
import { Fragment } from "react";
import { StringOrMarkdownSchema } from "@/src/components/schemas/MarkdownSchema";
@@ -226,26 +226,42 @@ export const OpenAiMessageView: React.FC<{
collapseLongHistory && messages.length > COLLAPSE_THRESHOLD ? true : null,
);
const shouldRenderContent = (message: ChatMlMessageSchema) => {
return message.content != null || !!message.audio;
};
const shouldRenderJson = (message: ChatMlMessageSchema) => {
return !!message.json;
};
const messagesToRender = useMemo(
() =>
messages.filter(
(message) => shouldRenderContent(message) || shouldRenderJson(message),
),
[messages],
);
return (
<div className="ph-no-capture flex max-h-full min-h-0 flex-col gap-2">
{title && <SubHeaderLabel title={title} className="mt-1" />}
<div className="flex max-h-full min-h-0 flex-col gap-2">
<div className="flex flex-col gap-2">
{messages
{messagesToRender
.filter(
(_, i) =>
// show all if not collapsed or null; show first and last n if collapsed
!isCollapsed ||
i == 0 ||
i > messages.length - COLLAPSE_THRESHOLD,
i > messagesToRender.length - COLLAPSE_THRESHOLD,
)
.map((message, index) => (
<Fragment key={index}>
{(!!message.content || !!message.audio) &&
{shouldRenderContent(message) &&
(shouldRenderMarkdown ? (
<MarkdownJsonView
title={message.name ?? message.role}
content={message.content}
content={message.content || '""'}
className={cn(!!message.json && "rounded-b-none")}
customCodeHeaderClassName={cn(
message.role === "assistant" && "bg-secondary",
@@ -261,7 +277,7 @@ export const OpenAiMessageView: React.FC<{
className={cn(!!message.json && "rounded-b-none")}
/>
))}
{!!message.json && (
{shouldRenderJson(message) && (
<JSONView
title={
message.content
@@ -282,7 +298,7 @@ export const OpenAiMessageView: React.FC<{
onClick={() => setCollapsed((v) => !v)}
>
{isCollapsed
? `Show ${messages.length - COLLAPSE_THRESHOLD} more ...`
? `Show ${messagesToRender.length - COLLAPSE_THRESHOLD} more ...`
: "Hide history"}
</Button>
) : null}
+10 -19
View File
@@ -25,7 +25,6 @@ import {
calculateDisplayTotalCost,
heatMapTextColor,
nestObservations,
treeItemColors,
unnestObservation,
} from "@/src/components/trace/lib/helpers";
import { CommentCountIcon } from "@/src/features/comments/CommentCountIcon";
@@ -110,18 +109,19 @@ export const ObservationTree = ({
{props.minLevel && hiddenObservationsCount > 0 ? (
<span className="flex items-center gap-1 p-2 py-4">
<InfoIcon className="h-4 w-4 text-muted-foreground" />
<span className="flex flex-row gap-1 text-sm text-muted-foreground">
<p>
{hiddenObservationsCount} observations below {props.minLevel}{" "}
level are hidden.
</p>
<p
<p className="text-sm text-muted-foreground">
<span>
{hiddenObservationsCount}{" "}
{hiddenObservationsCount === 1 ? "observation" : "observations"}{" "}
below {props.minLevel} level are hidden.{" "}
</span>
<span
className="cursor-pointer underline"
onClick={() => props.setMinLevel?.(ObservationLevel.DEBUG)}
>
Show all
</p>
</span>
</span>
</p>
</span>
) : null}
</div>
@@ -512,14 +512,5 @@ const ObservationTreeNodeCard = ({
export const ColorCodedObservationType = (props: {
observationType: ObservationType;
}) => {
return (
<span
className={cn(
"self-start rounded-sm px-1 py-0.5 text-xs",
treeItemColors.get(props.observationType),
)}
>
{props.observationType}
</span>
);
return <ItemBadge type={props.observationType} showLabel />;
};
-1
View File
@@ -91,7 +91,6 @@ export function TracePage({
return (
<Page
withPadding={false}
headerProps={{
title: trace.data.name
? `${trace.data.name}: ${trace.data.id}`
+5 -12
View File
@@ -1,21 +1,14 @@
import {
ObservationLevel,
type ObservationLevelType,
ObservationType,
} from "@langfuse/shared";
import { type NestedObservation } from "@/src/utils/types";
import { type ObservationReturnType } from "@/src/server/api/routers/traces";
import Decimal from "decimal.js";
import {
type ObservationType,
type ObservationLevelType,
ObservationLevel,
} from "@langfuse/shared";
export type TreeItemType = ObservationType | "TRACE";
export const treeItemColors: Map<TreeItemType, string> = new Map([
[ObservationType.SPAN, "bg-muted-blue"],
[ObservationType.GENERATION, "bg-muted-magenta"],
[ObservationType.EVENT, "bg-muted-green"],
["TRACE", "bg-input"],
]);
export function nestObservations(
list: ObservationReturnType[],
minLevel?: ObservationLevelType,
+1 -1
View File
@@ -21,7 +21,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-8 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"flex h-8 w-full items-center justify-between gap-1 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
+1 -1
View File
@@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-150 data-[state=open]:duration-200",
{
variants: {
side: {
+164
View File
@@ -0,0 +1,164 @@
import { useState, type ReactNode } from "react";
import { Button } from "@/src/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { SubHeader } from "@/src/components/layouts/header";
import { cn } from "@/src/utils/tailwind";
import React from "react";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/src/components/ui/sheet";
import { useIsMobile } from "@/src/hooks/use-mobile";
import { Separator } from "@/src/components/ui/separator";
import useSessionStorage from "@/src/components/useSessionStorage";
const SidePanelContext = React.createContext<{
showPanel: boolean;
setShowPanel: (value: boolean) => void;
isMobile: boolean;
} | null>(null);
const SidePanel = ({
id,
children,
className,
mobileTitle,
}: {
id: string;
children: ReactNode;
className?: string;
mobileTitle?: string;
}) => {
const [showPanel, setShowPanel] = useSessionStorage<boolean>(
`${id}-showPanel`,
true,
);
const [isOpen, setIsOpen] = useState(false);
const isMobile = useIsMobile();
const contextValue = React.useMemo(
() => ({
showPanel,
setShowPanel,
isMobile,
}),
[showPanel, setShowPanel, isMobile],
);
if (isMobile) {
return (
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<div className="border-l px-1 pt-2">
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<ChevronLeft className="h-4 w-4" />
</Button>
</SheetTrigger>
</div>
<SheetContent>
<SheetHeader>
<SheetTitle>{mobileTitle}</SheetTitle>
</SheetHeader>
<div className="mt-2 flex h-full w-full flex-col gap-2">
{children}
</div>
</SheetContent>
</Sheet>
);
}
return (
<SidePanelContext.Provider value={contextValue}>
<div
className={cn(
"flex h-full flex-row overflow-hidden border-l",
showPanel ? "w-[25vw]" : "w-fit",
className,
)}
>
<div className="grid h-full w-full items-start gap-2 overflow-hidden">
<div
className={cn(
"flex h-full w-full flex-col gap-2",
showPanel ? "p-2" : "p-1 pr-2 pt-2",
)}
>
{children}
</div>
</div>
</div>
</SidePanelContext.Provider>
);
};
const SidePanelHeader = ({ children }: { children: ReactNode }) => {
const context = React.useContext(SidePanelContext);
// Don't throw if we're in mobile mode (no context)
if (!context) return null;
const { showPanel, setShowPanel } = context;
if (!showPanel) {
return (
<Button
variant="ghost"
size="icon"
onClick={() => setShowPanel(true)}
title="Show details"
>
<ChevronLeft className="h-4 w-4" />
</Button>
);
}
return (
<div className="flex flex-col gap-2">
<div className="flex h-fit w-full flex-row items-center justify-between gap-2">
<div className="flex flex-row items-center gap-1">{children}</div>
<Button
variant="outline"
size="icon"
onClick={() => setShowPanel(false)}
title="Hide details"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<Separator />
</div>
);
};
const SidePanelTitle = ({
children,
className,
}: {
children: ReactNode;
className?: string;
}) => <SubHeader title={children?.toString() ?? ""} className={className} />;
const SidePanelContent = ({
children,
className,
}: {
children: ReactNode;
className?: string;
}) => {
const context = React.useContext(SidePanelContext);
if (!context) return children;
const { showPanel } = context;
if (!showPanel) return null;
return (
<div className={cn("flex flex-col items-start gap-4", className)}>
{children}
</div>
);
};
export { SidePanel, SidePanelHeader, SidePanelTitle, SidePanelContent };
+5 -1
View File
@@ -88,7 +88,11 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
className={cn(
"p-4 align-middle [&:has([role=checkbox])]:pr-0",
"border-b [:last-child_>_&]:border-b-0",
className,
)}
{...props}
/>
));
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.43.0";
export const VERSION = "v3.45.2";
@@ -47,6 +47,7 @@ export const AnnotationQueuesItem = ({
return (
<Page
withPadding
headerProps={{
title: `${queue.data?.name}: ${itemId}`,
itemType: "QUEUE_ITEM",
@@ -236,9 +236,6 @@ export function AnnotationQueuesTable({ projectId }: { projectId: string }) {
setColumnOrder={setColumnOrder}
rowHeight={rowHeight}
setRowHeight={setRowHeight}
actionButtons={
<CreateOrEditAnnotationQueueButton projectId={projectId} />
}
/>
<DataTable
columns={columns}
@@ -4,7 +4,6 @@ import { AnnotationQueueItemsTable } from "@/src/ee/features/annotation-queues/c
import { CardDescription } from "@/src/components/ui/card";
import { Button } from "@/src/components/ui/button";
import { ClipboardPen, Lock } from "lucide-react";
import { Separator } from "@/src/components/ui/separator";
import { Badge } from "@/src/components/ui/badge";
import { getScoreDataTypeIcon } from "@/src/features/scores/components/ScoreDetailColumnHelpers";
import Link from "next/link";
@@ -13,8 +12,14 @@ import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAcces
import { useHasEntitlement } from "@/src/features/entitlements/hooks";
import { SupportOrUpgradePage } from "@/src/ee/features/billing/components/SupportOrUpgradePage";
import { Skeleton } from "@/src/components/ui/skeleton";
import { TableWithMetadataWrapper } from "@/src/components/table/TableWithMetadataWrapper";
import Page from "@/src/components/layouts/page";
import {
SidePanel,
SidePanelContent,
SidePanelHeader,
SidePanelTitle,
} from "@/src/components/ui/side-panel";
import { SubHeaderLabel } from "@/src/components/layouts/header";
export default function QueueItems() {
const router = useRouter();
@@ -65,43 +70,49 @@ export default function QueueItems() {
),
}}
>
<TableWithMetadataWrapper
tableComponent={
<div className="grid flex-1 grid-cols-[1fr,auto] overflow-hidden">
<div className="flex h-full flex-col overflow-hidden">
<AnnotationQueueItemsTable projectId={projectId} queueId={queueId} />
}
cardTitleChildren={
<div className="flex w-full flex-row items-center justify-between">
{queue.data ? (
<span>{queue.data.name}</span>
) : (
<Skeleton className="h-full w-1/2" />
)}
</div>
<SidePanel
mobileTitle={queue.data?.name ?? "Queue details"}
id="queue-details"
>
<SidePanelHeader>
<SidePanelTitle>
{queue.data?.name ?? "Queue details"}
</SidePanelTitle>
<CreateOrEditAnnotationQueueButton
projectId={projectId}
queueId={queueId}
/>
</div>
}
cardContentChildren={
<>
{queue.data?.description && (
<CardDescription className="text-sm">
{queue.data?.description}
</CardDescription>
</SidePanelHeader>
<SidePanelContent>
{queue.isLoading ? (
<Skeleton className="h-full w-full" />
) : (
<>
{queue.data?.description && (
<CardDescription className="text-sm">
{queue.data?.description}
</CardDescription>
)}
<div className="flex flex-col gap-2">
<SubHeaderLabel title="Score Configs" />
<div className="flex flex-wrap gap-2">
{queue.data?.scoreConfigs.map((scoreConfig) => (
<Badge key={scoreConfig.id} variant="outline">
{getScoreDataTypeIcon(scoreConfig.dataType)}
<span className="ml-0.5">{scoreConfig.name}</span>
</Badge>
))}
</div>
</div>
</>
)}
<Separator orientation="horizontal" />
<h5 className="text-md font-bold leading-7 sm:tracking-tight">
Score Configs
</h5>
{queue.data?.scoreConfigs.map((scoreConfig) => (
<Badge key={scoreConfig.id} className="mr-2" variant="outline">
{getScoreDataTypeIcon(scoreConfig.dataType)}
<span className="ml-0.5">{scoreConfig.name}</span>
</Badge>
))}
</>
}
/>
</SidePanelContent>
</SidePanel>
</div>
</Page>
);
}
@@ -6,6 +6,7 @@ import { SupportOrUpgradePage } from "@/src/ee/features/billing/components/Suppo
import Page from "@/src/components/layouts/page";
import { AnnotationQueuesOnboarding } from "@/src/components/onboarding/AnnotationQueuesOnboarding";
import { api } from "@/src/utils/api";
import { CreateOrEditAnnotationQueueButton } from "@/src/ee/features/annotation-queues/components/CreateOrEditAnnotationQueueButton";
export default function AnnotationQueues() {
const router = useRouter();
@@ -43,6 +44,12 @@ export default function AnnotationQueues() {
"Annotation queues are used to manage scoring workflows for your LLM projects. See docs to learn more.",
href: "https://langfuse.com/docs/scores/annotation",
},
actionButtonsRight: (
<CreateOrEditAnnotationQueueButton
projectId={projectId}
variant="default"
/>
),
}}
scrollable={showOnboarding}
>
@@ -30,7 +30,7 @@ export function AuditLogsSettingsPage(props: { projectId: string }) {
return (
<>
<Header title="Audit Logs" />
<p className="mb-3 text-sm text-muted-foreground">
<p className="mb-2 text-sm text-muted-foreground">
Track who changed what in your project and when. Monitor settings,
configurations, and data changes over time. Reach out to the Langfuse
team if you require more detailed/filtered audit logs.
@@ -12,6 +12,7 @@ import { cn } from "@/src/utils/tailwind";
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
import { type RouterOutputs } from "@/src/utils/api";
import { SettingsTableCard } from "@/src/components/layouts/settings-table-card";
type AuditLogRow = RouterOutputs["auditLogs"]["all"]["data"][number];
@@ -122,31 +123,34 @@ export function AuditLogsTable(props: { projectId: string }) {
columns={columns}
rowHeight={rowHeight}
setRowHeight={setRowHeight}
className="px-0"
/>
<DataTable
columns={columns}
data={
auditLogs.isLoading
? { isLoading: true, isError: false }
: auditLogs.isError
? {
isLoading: false,
isError: true,
error: auditLogs.error.message,
}
: {
isLoading: false,
isError: false,
data: auditLogs.data.data,
}
}
pagination={{
totalCount: auditLogs.data?.totalCount ?? 0,
onChange: setPaginationState,
state: paginationState,
}}
rowHeight={rowHeight}
/>
<SettingsTableCard>
<DataTable
columns={columns}
data={
auditLogs.isLoading
? { isLoading: true, isError: false }
: auditLogs.isError
? {
isLoading: false,
isError: true,
error: auditLogs.error.message,
}
: {
isLoading: false,
isError: false,
data: auditLogs.data.data,
}
}
pagination={{
totalCount: auditLogs.data?.totalCount ?? 0,
onChange: setPaginationState,
state: paginationState,
}}
rowHeight={rowHeight}
/>
</SettingsTableCard>
</>
);
}
@@ -0,0 +1,38 @@
import DocPopup from "@/src/components/layouts/doc-popup";
import { Label } from "@/src/components/ui/label";
export function VariableMappingDescription(p: {
title: string;
description: string;
href: string;
}) {
return (
<div className="flex w-1/2 items-center">
<Label className="muted-foreground text-sm font-light">{p.title}</Label>
<DocPopup description={p.description} href={p.href} />
</div>
);
}
export function TimeScopeDescription(props: {
projectId: string;
timeScope: ("NEW" | "EXISTING")[] | undefined;
target: "trace" | "dataset_item" | undefined;
}) {
if (!props.timeScope || props.timeScope.length === 0) {
return "Select a time scope to run this configuration on.";
}
return (
<div>
This configuration will run on{" "}
{props.timeScope?.includes("NEW") && props.timeScope?.includes("EXISTING")
? "all future and existing"
: props.timeScope?.includes("NEW")
? "all future"
: "all existing"}{" "}
{props.target === "trace" ? "traces" : "dataset run items"} that match
these filters.{" "}
</div>
);
}
@@ -47,6 +47,7 @@ export const EvalTemplateDetail = () => {
return (
<Page
withPadding
scrollable
headerProps={{
title: `${template.data?.name}: ${templateId}`,
@@ -13,7 +13,6 @@ import { useState } from "react";
import TableLink from "@/src/components/table/table-link";
import EvalLogTable from "@/src/ee/features/evals/components/eval-log";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { TableWithMetadataWrapper } from "@/src/components/table/TableWithMetadataWrapper";
import { StatusBadge } from "@/src/components/layouts/status-badge";
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
import { CardDescription } from "@/src/components/ui/card";
@@ -27,6 +26,12 @@ import {
DialogTrigger,
} from "@/src/components/ui/dialog";
import Page from "@/src/components/layouts/page";
import {
SidePanel,
SidePanelHeader,
SidePanelTitle,
} from "@/src/components/ui/side-panel";
import { SidePanelContent } from "@/src/components/ui/side-panel";
import { LevelCountsDisplay } from "@/src/components/level-counts-display";
import {
type JobExecutionState,
@@ -140,19 +145,22 @@ export const EvaluatorDetail = () => {
}}
>
{existingEvaluator && (
<TableWithMetadataWrapper
tableComponent={
<div className="grid flex-1 grid-cols-[1fr,auto] overflow-hidden">
<div className="flex h-full flex-col overflow-hidden">
<EvalLogTable
projectId={projectId}
jobConfigurationId={existingEvaluator.id}
/>
}
cardTitleChildren={
<div className="flex w-full flex-row items-center justify-between">
<span>Evaluator configuration</span>
</div>
<SidePanel
mobileTitle="Evaluator configuration"
id="evaluator-configuration"
>
<SidePanelHeader>
<SidePanelTitle>Evaluator configuration</SidePanelTitle>
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="flex items-center gap-2">
<Button variant="outline" size="icon">
<Edit className="h-4 w-4" />
</Button>
</DialogTrigger>
@@ -175,32 +183,33 @@ export const EvaluatorDetail = () => {
</div>
</DialogContent>
</Dialog>
</div>
}
cardContentChildren={
<>
<CardDescription className="flex items-center justify-between text-sm">
<span className="text-sm font-medium">Eval Template</span>
<TableLink
path={`/project/${projectId}/evals/templates/${existingEvaluator.evalTemplateId}`}
value={`${existingEvaluator.evalTemplate.name} (v${existingEvaluator.evalTemplate.version})`}
className="flex min-h-6 items-center"
/>
</CardDescription>
<div className="flex w-full flex-col items-start justify-between space-y-2 pb-4">
<EvaluatorForm
key={existingEvaluator.id}
projectId={projectId}
evalTemplates={allTemplates.data?.templates}
existingEvaluator={existingEvaluator}
disabled={true}
shouldWrapVariables={true}
/>
</div>
</>
}
/>
</SidePanelHeader>
<SidePanelContent>
<>
<CardDescription className="flex items-center justify-between text-sm">
<span className="mr-2 text-sm font-medium">
Eval Template
</span>
<TableLink
path={`/project/${projectId}/evals/templates/${existingEvaluator.evalTemplateId}`}
value={`${existingEvaluator.evalTemplate.name} (v${existingEvaluator.evalTemplate.version})`}
className="flex min-h-6 items-center"
/>
</CardDescription>
<div className="flex max-h-[80dvh] w-full flex-col items-start justify-between space-y-2 overflow-y-auto pb-4">
<EvaluatorForm
key={existingEvaluator.id}
projectId={projectId}
evalTemplates={allTemplates.data?.templates}
existingEvaluator={existingEvaluator}
disabled={true}
shouldWrapVariables={true}
/>
</div>
</>
</SidePanelContent>
</SidePanel>
</div>
)}
</Page>
);
@@ -1,52 +1,8 @@
import { useFieldArray, useForm } from "react-hook-form";
import { Input } from "@/src/components/ui/input";
import { Button } from "@/src/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/src/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/src/components/ui/select";
import { zodResolver } from "@hookform/resolvers/zod";
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
import {
tracesTableColsWithOptions,
evalTraceTableCols,
evalDatasetFormFilterCols,
singleFilter,
type JobConfiguration,
availableTraceEvalVariables,
datasetFormFilterColsWithOptions,
availableDatasetEvalVariables,
type langfuseObjects,
TimeScopeSchema,
} from "@langfuse/shared";
import * as z from "zod";
import { useEffect, useMemo, useState } from "react";
import { type JobConfiguration } from "@langfuse/shared";
import { useEffect, useState } from "react";
import { api } from "@/src/utils/api";
import { InlineFilterBuilder } from "@/src/features/filters/components/filter-builder";
import {
type EvalTemplate,
variableMapping,
wipVariableMapping,
} from "@langfuse/shared";
import router from "next/router";
import { Slider } from "@/src/components/ui/slider";
import { Card } from "@/src/components/ui/card";
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
import { Label } from "@/src/components/ui/label";
import DocPopup from "@/src/components/layouts/doc-popup";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { type EvalTemplate } from "@langfuse/shared";
import { CheckIcon, ChevronDown, ExternalLink } from "lucide-react";
import {
Popover,
@@ -66,31 +22,7 @@ import { cn } from "@/src/utils/tailwind";
import { Dialog, DialogContent, DialogTitle } from "@/src/components/ui/dialog";
import { EvalTemplateForm } from "@/src/ee/features/evals/components/template-form";
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
import { Checkbox } from "@/src/components/ui/checkbox";
import { compactNumberFormatter } from "@/src/utils/numbers";
export const fieldHasJsonSelectorOption = (
selectedColumnId: string | undefined | null,
): boolean =>
selectedColumnId === "input" ||
selectedColumnId === "output" ||
selectedColumnId === "expected_output";
const formSchema = z.object({
scoreName: z.string(),
target: z.string(),
filter: z.array(singleFilter).nullable(), // reusing the filter type from the tables
mapping: z.array(wipVariableMapping),
sampling: z.coerce.number().gt(0).lte(1),
delay: z.coerce.number().optional().default(10),
timeScope: TimeScopeSchema,
});
type LangfuseObject = (typeof langfuseObjects)[number];
const isTraceTarget = (target: string): boolean => target === "trace";
const isTraceOrDatasetObject = (object: LangfuseObject): boolean =>
object === "trace" || object === "dataset_item";
import { InnerEvaluatorForm } from "@/src/ee/features/evals/components/inner-evaluator-form";
export const EvaluatorForm = (props: {
projectId: string;
@@ -286,7 +218,7 @@ export const EvaluatorForm = (props: {
</DialogContent>
</Dialog>
{evalTemplate && currentTemplate ? (
<InnerEvalConfigForm
<InnerEvaluatorForm
key={evalTemplate}
projectId={props.projectId}
disabled={props.disabled}
@@ -302,775 +234,3 @@ export const EvaluatorForm = (props: {
</>
);
};
export const InnerEvalConfigForm = (props: {
projectId: string;
evalTemplate: EvalTemplate;
disabled?: boolean;
existingEvaluator?: JobConfiguration;
onFormSuccess?: () => void;
shouldWrapVariables?: boolean;
mode?: "create" | "edit";
}) => {
const [formError, setFormError] = useState<string | null>(null);
const capture = usePostHogClientCapture();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
disabled: props.disabled,
defaultValues: {
scoreName:
props.existingEvaluator?.scoreName ?? `${props.evalTemplate.name}`,
target: props.existingEvaluator?.targetObject ?? "trace",
filter: props.existingEvaluator?.filter
? z.array(singleFilter).parse(props.existingEvaluator.filter)
: [],
mapping: props.existingEvaluator?.variableMapping
? z
.array(variableMapping)
.parse(props.existingEvaluator.variableMapping)
: z.array(variableMapping).parse(
props.evalTemplate
? props.evalTemplate.vars.map((v) => ({
templateVariable: v,
langfuseObject: "trace" as const,
selectedColumnId: "input",
}))
: [],
),
sampling: props.existingEvaluator?.sampling
? props.existingEvaluator.sampling.toNumber()
: 1,
delay: props.existingEvaluator?.delay
? props.existingEvaluator.delay / 1000
: 10,
timeScope: (props.existingEvaluator?.timeScope ?? ["NEW"]).filter(
(option): option is "NEW" | "EXISTING" =>
["NEW", "EXISTING"].includes(option),
),
},
});
const traceFilterOptions = api.traces.filterOptions.useQuery(
{
projectId: props.projectId,
},
{
trpc: {
context: {
skipBatch: true,
},
},
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Infinity,
},
);
const datasets = api.datasets.allDatasetMeta.useQuery(
{
projectId: props.projectId,
},
{
trpc: {
context: {
skipBatch: true,
},
},
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Infinity,
},
);
const datasetFilterOptions = useMemo(() => {
if (!datasets.data) return undefined;
return {
datasetId: datasets.data?.map((d) => ({
value: d.id,
displayValue: d.name,
})),
};
}, [datasets.data]);
useEffect(() => {
if (props.evalTemplate && form.getValues("mapping").length === 0) {
form.setValue(
"mapping",
props.evalTemplate.vars.map((v) => ({
templateVariable: v,
langfuseObject: "trace" as const,
selectedColumnId: "input",
})),
);
form.setValue("scoreName", `${props.evalTemplate.name}`);
}
}, [form, props.evalTemplate]);
const { fields } = useFieldArray({
control: form.control,
name: "mapping",
});
const utils = api.useUtils();
const createJobMutation = api.evals.createJob.useMutation({
onSuccess: () => utils.models.invalidate(),
onError: (error) => setFormError(error.message),
});
const updateJobMutation = api.evals.updateEvalJob.useMutation({
onSuccess: () => utils.evals.invalidate(),
onError: (error) => setFormError(error.message),
});
const [availableVariables, setAvailableVariables] = useState<
typeof availableTraceEvalVariables | typeof availableDatasetEvalVariables
>(
isTraceTarget(props.existingEvaluator?.targetObject ?? "trace")
? availableTraceEvalVariables
: availableDatasetEvalVariables,
);
function onSubmit(values: z.infer<typeof formSchema>) {
capture(
props.mode === "edit"
? "eval_config:update"
: "eval_config:new_form_submit",
);
const validatedFilter = z.array(singleFilter).safeParse(values.filter);
if (
props.existingEvaluator?.timeScope.includes("EXISTING") &&
props.mode === "edit" &&
!values.timeScope.includes("EXISTING")
) {
form.setError("timeScope", {
type: "manual",
message:
"The evaluator ran on existing traces already. This cannot be changed anymore.",
});
return;
}
if (form.getValues("timeScope").length === 0) {
form.setError("timeScope", {
type: "manual",
message: "Please select at least one.",
});
return;
}
if (validatedFilter.success === false) {
form.setError("filter", {
type: "manual",
message: "Please fill out all filter fields",
});
return;
}
const validatedVarMapping = z
.array(variableMapping)
.safeParse(values.mapping);
if (validatedVarMapping.success === false) {
console.log(validatedVarMapping.error);
form.setError("mapping", {
type: "manual",
message: "Please fill out all variable mappings",
});
return;
}
const delay = values.delay * 1000; // convert to ms
const sampling = values.sampling;
const mapping = validatedVarMapping.data;
const filter = validatedFilter.data;
const scoreName = values.scoreName;
(props.mode === "edit" && props.existingEvaluator
? updateJobMutation.mutateAsync({
projectId: props.projectId,
evalConfigId: props.existingEvaluator.id,
config: {
delay,
filter,
variableMapping: mapping,
sampling,
scoreName,
timeScope: values.timeScope,
},
})
: createJobMutation.mutateAsync({
projectId: props.projectId,
target: values.target,
evalTemplateId: props.evalTemplate.id,
scoreName,
filter,
mapping,
sampling,
delay,
timeScope: values.timeScope,
})
)
.then(() => {
form.reset();
props.onFormSuccess?.();
if (props.mode !== "edit") {
void router.push(`/project/${props.projectId}/evals`);
}
})
.catch((error) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if ("message" in error && typeof error.message === "string") {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
setFormError(error.message as string);
return;
} else {
setFormError(JSON.stringify(error));
console.error(error);
}
});
}
return (
<Form {...form}>
<form
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-full flex-col gap-4"
>
<div className="grid gap-4">
<FormField
control={form.control}
name="scoreName"
render={({ field }) => (
<FormItem>
<FormLabel>Score Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Card className="flex max-w-full flex-col gap-6 overflow-y-auto p-4">
<FormField
control={form.control}
name="target"
render={({ field }) => (
<FormItem>
<FormLabel>Target object</FormLabel>
<FormControl>
<Tabs
defaultValue="trace"
value={field.value}
onValueChange={(value) => {
const isTrace = isTraceTarget(value);
const langfuseObject: LangfuseObject = isTrace
? "trace"
: "dataset_item";
const newMapping = form
.getValues("mapping")
.map((field) => ({ ...field, langfuseObject }));
form.setValue("mapping", newMapping);
form.setValue("delay", isTrace ? 10 : 20);
setAvailableVariables(
isTrace
? availableTraceEvalVariables
: availableDatasetEvalVariables,
);
field.onChange(value);
}}
>
<TabsList>
<TabsTrigger
value="trace"
disabled={props.disabled || props.mode === "edit"}
>
Trace
</TabsTrigger>
<TabsTrigger
value="dataset"
disabled={props.disabled || props.mode === "edit"}
>
Dataset
</TabsTrigger>
</TabsList>
</Tabs>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex min-w-[300px]">
<FormField
control={form.control}
name="timeScope"
render={({ field }) => (
<FormItem>
<FormLabel>Evaluator runs on</FormLabel>
<FormControl>
<div className="flex flex-col gap-2">
<div className="items-top flex space-x-2">
<Checkbox
id="newObjects"
checked={field.value.includes("NEW")}
onCheckedChange={(checked) => {
const newValue = checked
? [...field.value, "NEW"]
: field.value.filter((v) => v !== "NEW");
field.onChange(newValue);
}}
disabled={props.disabled}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor="newObjects"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
New{" "}
{form.watch("target") === "trace"
? "traces"
: "dataset items"}
</label>
</div>
</div>
<div className="items-top flex space-x-2">
<Checkbox
id="existingObjects"
checked={field.value.includes("EXISTING")}
onCheckedChange={(checked) => {
const newValue = checked
? [...field.value, "EXISTING"]
: field.value.filter((v) => v !== "EXISTING");
field.onChange(newValue);
}}
disabled={
props.disabled ||
(props.mode === "edit" &&
field.value.includes("EXISTING"))
}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor="existingObjects"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Existing{" "}
{form.watch("target") === "trace"
? "traces"
: "dataset items"}
</label>
</div>
</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="filter"
render={({ field }) => (
<FormItem>
<FormLabel>Target filter</FormLabel>
{isTraceTarget(form.watch("target")) ? (
<>
<FormControl>
<InlineFilterBuilder
columns={tracesTableColsWithOptions(
traceFilterOptions.data,
evalTraceTableCols,
)}
filterState={field.value ?? []}
onChange={(value) => field.onChange(value)}
disabled={props.disabled}
/>
</FormControl>
<FormDescription>
<TimeScopeDescription
projectId={props.projectId}
timeScope={form.watch("timeScope")}
target="trace"
/>
</FormDescription>
<FormMessage />
</>
) : (
<>
<FormControl>
<InlineFilterBuilder
columns={datasetFormFilterColsWithOptions(
datasetFilterOptions,
evalDatasetFormFilterCols,
)}
filterState={field.value ?? []}
onChange={(value) => field.onChange(value)}
disabled={props.disabled}
/>
</FormControl>
<FormDescription>
<TimeScopeDescription
projectId={props.projectId}
timeScope={form.watch("timeScope")}
target="dataset_item"
/>
</FormDescription>
<FormMessage />
</>
)}
</FormItem>
)}
/>
</Card>
<Card className="p-4">
<FormField
control={form.control}
name="mapping"
render={() => (
<>
<FormLabel className="">Variable mapping</FormLabel>
<FormControl>
Here will some variable mapping be added.
</FormControl>
<div
className={cn(
"my-2 flex flex-col gap-2",
!props.shouldWrapVariables && "lg:flex-row",
)}
>
<JSONView
title={"Eval Template"}
json={props.evalTemplate.prompt ?? null}
className={cn(
"min-h-48",
!props.shouldWrapVariables && "lg:w-2/3",
)}
codeClassName="flex-1"
/>
<div
className={cn(
"flex flex-col gap-2",
!props.shouldWrapVariables && "lg:w-1/3",
)}
>
{fields.map((mappingField, index) => (
<Card className="flex flex-col gap-2 p-4" key={index}>
<div className="text-sm font-semibold">
{"{{"}
{mappingField.templateVariable}
{"}}"}
<DocPopup
description={
"Variable in the template to be replaced with the trace data."
}
href={
"https://langfuse.com/docs/scores/model-based-evals"
}
/>
</div>
<FormField
control={form.control}
key={`${mappingField.id}-langfuseObject`}
name={`mapping.${index}.langfuseObject`}
render={({ field }) => (
<div className="flex items-center gap-2">
<VariableMappingDescription
title="Object"
description={
"Langfuse object to retrieve the data from."
}
href={
"https://langfuse.com/docs/scores/model-based-evals"
}
/>
<FormItem className="w-2/3">
<FormControl>
<Select
disabled={props.disabled}
defaultValue={field.value}
onValueChange={field.onChange}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableVariables.map(
(evalObject) => (
<SelectItem
value={evalObject.id}
key={evalObject.id}
>
{evalObject.display}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</div>
)}
/>
{!isTraceOrDatasetObject(
form.watch(`mapping.${index}.langfuseObject`),
) ? (
<FormField
control={form.control}
key={`${mappingField.id}-objectName`}
name={`mapping.${index}.objectName`}
render={({ field }) => (
<div className="flex items-center gap-2">
<VariableMappingDescription
title={"Object Name"}
description={
"Name of the Langfuse object to retrieve the data from."
}
href={
"https://langfuse.com/docs/scores/model-based-evals"
}
/>
<FormItem className="w-2/3">
<FormControl>
<Input
{...field}
value={field.value ?? ""}
disabled={props.disabled}
/>
</FormControl>
<FormMessage />
</FormItem>
</div>
)}
/>
) : undefined}
<FormField
control={form.control}
key={`${mappingField.id}-selectedColumnId`}
name={`mapping.${index}.selectedColumnId`}
render={({ field }) => (
<div className="flex items-center gap-2">
<VariableMappingDescription
title={"Object Variable"}
description={
"Variable on the Langfuse object to insert into the template."
}
href={
"https://langfuse.com/docs/scores/model-based-evals"
}
/>
<FormItem className="w-2/3">
<FormControl>
<Select
disabled={props.disabled}
defaultValue={field.value ?? undefined}
onValueChange={(value) => {
const availableColumns =
availableVariables.find(
(evalObject) =>
evalObject.id ===
form.watch(
`mapping.${index}.langfuseObject`,
),
)?.availableColumns;
const column = availableColumns?.find(
(column) => column.id === value,
);
field.onChange(column?.id);
}}
>
<SelectTrigger>
<SelectValue placeholder="Object type" />
</SelectTrigger>
<SelectContent>
{availableVariables
.find(
(evalObject) =>
evalObject.id ===
form.watch(
`mapping.${index}.langfuseObject`,
),
)
?.availableColumns.map((column) => (
<SelectItem
value={column.id}
key={column.id}
>
{column.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</div>
)}
/>
{fieldHasJsonSelectorOption(
form.watch(`mapping.${index}.selectedColumnId`),
) ? (
<FormField
control={form.control}
key={`${mappingField.id}-jsonSelector`}
name={`mapping.${index}.jsonSelector`}
render={({ field }) => (
<div className="flex items-center gap-2">
<VariableMappingDescription
title={"JsonPath"}
description={
"Optional selection: Use JsonPath syntax to select from a JSON object stored on a trace. If not selected, we will pass the entire object into the prompt."
}
href={
"https://langfuse.com/docs/scores/model-based-evals"
}
/>
<FormItem className="w-2/3">
<FormControl>
<Input
{...field}
value={field.value ?? ""}
disabled={props.disabled}
placeholder="Optional"
/>
</FormControl>
<FormMessage />
</FormItem>
</div>
)}
/>
) : undefined}
</Card>
))}
</div>
</div>
<FormDescription>
Insert trace data into the prompt template.
</FormDescription>
<FormMessage />
</>
)}
/>
</Card>
<Card className="flex flex-col gap-6 p-4">
<FormField
control={form.control}
name="sampling"
render={({ field }) => (
<FormItem>
<FormLabel>Sampling</FormLabel>
<FormControl>
<Slider
disabled={props.disabled}
min={0}
max={1}
step={0.01}
value={[field.value]}
onValueChange={(value) => field.onChange(value[0])}
/>
</FormControl>
<div className="flex flex-col">
<FormDescription className="flex justify-between">
<span>0%</span>
<span>100%</span>
</FormDescription>
<FormDescription className="mt-1 flex flex-row gap-1">
<span>Percentage of traces to evaluate.</span>
<span>
Currently set to {(field.value * 100).toFixed(0)}%.
</span>
</FormDescription>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="delay"
render={({ field }) => (
<FormItem>
<FormLabel>Delay (seconds)</FormLabel>
<FormControl>
<Input {...field} type="number" />
</FormControl>
<FormDescription>
Time between first Trace/Dataset run event and evaluation
execution to ensure all data is available
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</Card>
</div>
{!props.disabled ? (
<Button
type="submit"
loading={createJobMutation.isLoading || updateJobMutation.isLoading}
className="mt-3"
>
Save
</Button>
) : null}
</form>
{formError ? (
<p className="text-red text-center">
<span className="font-bold">Error:</span> {formError}
</p>
) : null}
</Form>
);
};
function VariableMappingDescription(p: {
title: string;
description: string;
href: string;
}) {
return (
<div className="flex w-1/2 items-center">
<Label className="muted-foreground text-sm font-light">{p.title}</Label>
<DocPopup description={p.description} href={p.href} />
</div>
);
}
export const TimeScopeDescription = (props: {
projectId: string;
timeScope: ("NEW" | "EXISTING")[] | undefined;
target: "trace" | "dataset_item" | undefined;
}) => {
if (!props.timeScope || props.timeScope.length === 0) {
return "Select a time scope to run this configuration on.";
}
const globalConfig = api.evals.globalJobConfigs.useQuery({
projectId: props.projectId,
});
return (
<div>
This configuration will run on{" "}
{props.timeScope?.includes("NEW") && props.timeScope?.includes("EXISTING")
? "all future and existing"
: props.timeScope?.includes("NEW")
? "all future"
: "all existing"}{" "}
{props.target === "trace" ? "traces" : "dataset items"} that match these
filters.{" "}
{globalConfig.data && props.timeScope?.includes("EXISTING")
? `We execute the evaluation on up to ${compactNumberFormatter(globalConfig.data)} historic evaluations.`
: null}
</div>
);
};
@@ -0,0 +1,86 @@
import { InfoIcon, Loader } from "lucide-react";
import {
type EvalFormType,
isTraceTarget,
} from "@/src/ee/features/evals/utils/evaluator-form-utils";
import { api } from "@/src/utils/api";
import { useState } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/src/components/ui/tooltip";
import { compactNumberFormatter } from "@/src/utils/numbers";
type ExecutionCountTooltipProps = {
projectId: string;
item: string;
filter: EvalFormType["filter"];
};
export const ExecutionCountTooltip = ({
projectId,
item,
filter,
}: ExecutionCountTooltipProps) => {
const [isOpen, setIsOpen] = useState(false);
const globalConfig = api.evals.globalJobConfigs.useQuery({
projectId,
});
const baseAllCountFilter = {
projectId,
filter,
};
const tracesAllCountFilter = {
...baseAllCountFilter,
searchQuery: null,
orderBy: null,
};
// utilize `isOpen` to only query if user hovers over tooltip to avoid unnecessary queries
const totalCountQuery = api.traces.countAll.useQuery(tracesAllCountFilter, {
enabled: isOpen && isTraceTarget(item),
});
const datasetCountQuery = api.datasets.countAllDatasetItems.useQuery(
baseAllCountFilter,
{
enabled: isOpen && !isTraceTarget(item),
},
);
const loading = isTraceTarget(item)
? totalCountQuery.isLoading
: datasetCountQuery.isLoading;
const totalCount = isTraceTarget(item)
? totalCountQuery.data?.totalCount
: datasetCountQuery.data?.totalCount;
return (
<Tooltip open={isOpen} onOpenChange={setIsOpen}>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent>
<div className="text-sm">
We execute the evaluation on{" "}
{loading ? (
<Loader className="inline-block h-4 w-4 animate-spin" />
) : (
compactNumberFormatter(
!globalConfig.data ||
(totalCount && totalCount < globalConfig.data)
? totalCount
: globalConfig.data,
)
)}{" "}
{isTraceTarget(item) ? "traces" : "dataset run items"}.
</div>
</TooltipContent>
</Tooltip>
);
};
@@ -0,0 +1,801 @@
import { useFieldArray, useForm } from "react-hook-form";
import { Input } from "@/src/components/ui/input";
import { Button } from "@/src/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/src/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/src/components/ui/select";
import { zodResolver } from "@hookform/resolvers/zod";
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
import {
tracesTableColsWithOptions,
evalTraceTableCols,
evalDatasetFormFilterCols,
singleFilter,
type JobConfiguration,
availableTraceEvalVariables,
datasetFormFilterColsWithOptions,
availableDatasetEvalVariables,
} from "@langfuse/shared";
import * as z from "zod";
import { useEffect, useMemo, useState } from "react";
import { api } from "@/src/utils/api";
import { InlineFilterBuilder } from "@/src/features/filters/components/filter-builder";
import { type EvalTemplate, variableMapping } from "@langfuse/shared";
import router from "next/router";
import { Slider } from "@/src/components/ui/slider";
import { Card } from "@/src/components/ui/card";
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
import DocPopup from "@/src/components/layouts/doc-popup";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { cn } from "@/src/utils/tailwind";
import { Checkbox } from "@/src/components/ui/checkbox";
import {
evalConfigFormSchema,
isTraceOrDatasetObject,
isTraceTarget,
type LangfuseObject,
} from "@/src/ee/features/evals/utils/evaluator-form-utils";
import { ExecutionCountTooltip } from "@/src/ee/features/evals/components/execution-count-tooltip";
import {
TimeScopeDescription,
VariableMappingDescription,
} from "@/src/ee/features/evals/components/eval-form-descriptions";
const fieldHasJsonSelectorOption = (
selectedColumnId: string | undefined | null,
): boolean =>
selectedColumnId === "input" ||
selectedColumnId === "output" ||
selectedColumnId === "expected_output";
export const InnerEvaluatorForm = (props: {
projectId: string;
evalTemplate: EvalTemplate;
disabled?: boolean;
existingEvaluator?: JobConfiguration;
onFormSuccess?: () => void;
shouldWrapVariables?: boolean;
mode?: "create" | "edit";
}) => {
const [formError, setFormError] = useState<string | null>(null);
const capture = usePostHogClientCapture();
const form = useForm<z.infer<typeof evalConfigFormSchema>>({
resolver: zodResolver(evalConfigFormSchema),
disabled: props.disabled,
defaultValues: {
scoreName:
props.existingEvaluator?.scoreName ?? `${props.evalTemplate.name}`,
target: props.existingEvaluator?.targetObject ?? "trace",
filter: props.existingEvaluator?.filter
? z.array(singleFilter).parse(props.existingEvaluator.filter)
: [],
mapping: props.existingEvaluator?.variableMapping
? z
.array(variableMapping)
.parse(props.existingEvaluator.variableMapping)
: z.array(variableMapping).parse(
props.evalTemplate
? props.evalTemplate.vars.map((v) => ({
templateVariable: v,
langfuseObject: "trace" as const,
selectedColumnId: "input",
}))
: [],
),
sampling: props.existingEvaluator?.sampling
? props.existingEvaluator.sampling.toNumber()
: 1,
delay: props.existingEvaluator?.delay
? props.existingEvaluator.delay / 1000
: 10,
timeScope: (props.existingEvaluator?.timeScope ?? ["NEW"]).filter(
(option): option is "NEW" | "EXISTING" =>
["NEW", "EXISTING"].includes(option),
),
},
});
const traceFilterOptions = api.traces.filterOptions.useQuery(
{
projectId: props.projectId,
},
{
trpc: {
context: {
skipBatch: true,
},
},
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Infinity,
},
);
const datasets = api.datasets.allDatasetMeta.useQuery(
{
projectId: props.projectId,
},
{
trpc: {
context: {
skipBatch: true,
},
},
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Infinity,
},
);
const datasetFilterOptions = useMemo(() => {
if (!datasets.data) return undefined;
return {
datasetId: datasets.data?.map((d) => ({
value: d.id,
displayValue: d.name,
})),
};
}, [datasets.data]);
useEffect(() => {
if (props.evalTemplate && form.getValues("mapping").length === 0) {
form.setValue(
"mapping",
props.evalTemplate.vars.map((v) => ({
templateVariable: v,
langfuseObject: "trace" as const,
selectedColumnId: "input",
})),
);
form.setValue("scoreName", `${props.evalTemplate.name}`);
}
}, [form, props.evalTemplate]);
const { fields } = useFieldArray({
control: form.control,
name: "mapping",
});
const utils = api.useUtils();
const createJobMutation = api.evals.createJob.useMutation({
onSuccess: () => utils.models.invalidate(),
onError: (error) => setFormError(error.message),
});
const updateJobMutation = api.evals.updateEvalJob.useMutation({
onSuccess: () => utils.evals.invalidate(),
onError: (error) => setFormError(error.message),
});
const [availableVariables, setAvailableVariables] = useState<
typeof availableTraceEvalVariables | typeof availableDatasetEvalVariables
>(
isTraceTarget(props.existingEvaluator?.targetObject ?? "trace")
? availableTraceEvalVariables
: availableDatasetEvalVariables,
);
function onSubmit(values: z.infer<typeof evalConfigFormSchema>) {
capture(
props.mode === "edit"
? "eval_config:update"
: "eval_config:new_form_submit",
);
const validatedFilter = z.array(singleFilter).safeParse(values.filter);
if (
props.existingEvaluator?.timeScope.includes("EXISTING") &&
props.mode === "edit" &&
!values.timeScope.includes("EXISTING")
) {
form.setError("timeScope", {
type: "manual",
message:
"The evaluator ran on existing traces already. This cannot be changed anymore.",
});
return;
}
if (form.getValues("timeScope").length === 0) {
form.setError("timeScope", {
type: "manual",
message: "Please select at least one.",
});
return;
}
if (validatedFilter.success === false) {
form.setError("filter", {
type: "manual",
message: "Please fill out all filter fields",
});
return;
}
const validatedVarMapping = z
.array(variableMapping)
.safeParse(values.mapping);
if (validatedVarMapping.success === false) {
form.setError("mapping", {
type: "manual",
message: "Please fill out all variable mappings",
});
return;
}
const delay = values.delay * 1000; // convert to ms
const sampling = values.sampling;
const mapping = validatedVarMapping.data;
const filter = validatedFilter.data;
const scoreName = values.scoreName;
(props.mode === "edit" && props.existingEvaluator
? updateJobMutation.mutateAsync({
projectId: props.projectId,
evalConfigId: props.existingEvaluator.id,
config: {
delay,
filter,
variableMapping: mapping,
sampling,
scoreName,
timeScope: values.timeScope,
},
})
: createJobMutation.mutateAsync({
projectId: props.projectId,
target: values.target,
evalTemplateId: props.evalTemplate.id,
scoreName,
filter,
mapping,
sampling,
delay,
timeScope: values.timeScope,
})
)
.then(() => {
form.reset();
props.onFormSuccess?.();
if (props.mode !== "edit") {
void router.push(`/project/${props.projectId}/evals`);
}
})
.catch((error) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if ("message" in error && typeof error.message === "string") {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
setFormError(error.message as string);
return;
} else {
setFormError(JSON.stringify(error));
console.error(error);
}
});
}
return (
<Form {...form}>
<form
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-full flex-col gap-4"
>
<div className="grid gap-4">
<FormField
control={form.control}
name="scoreName"
render={({ field }) => (
<FormItem>
<FormLabel>Score Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Card className="flex max-w-full flex-col gap-6 overflow-y-auto p-4">
<FormField
control={form.control}
name="target"
render={({ field }) => (
<FormItem>
<FormLabel>Target object</FormLabel>
<FormControl>
<Tabs
defaultValue="trace"
value={field.value}
onValueChange={(value) => {
const isTrace = isTraceTarget(value);
const langfuseObject: LangfuseObject = isTrace
? "trace"
: "dataset_item";
const newMapping = form
.getValues("mapping")
.map((field) => ({ ...field, langfuseObject }));
form.setValue("mapping", newMapping);
form.setValue("delay", isTrace ? 10 : 20);
setAvailableVariables(
isTrace
? availableTraceEvalVariables
: availableDatasetEvalVariables,
);
field.onChange(value);
}}
>
<TabsList>
<TabsTrigger
value="trace"
disabled={props.disabled || props.mode === "edit"}
>
Trace
</TabsTrigger>
<TabsTrigger
value="dataset"
disabled={props.disabled || props.mode === "edit"}
>
Dataset
</TabsTrigger>
</TabsList>
</Tabs>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex min-w-[300px]">
<FormField
control={form.control}
name="timeScope"
render={({ field }) => (
<FormItem>
<FormLabel>Evaluator runs on</FormLabel>
<FormControl>
<div className="flex flex-col gap-2">
<div className="items-top flex space-x-2">
<Checkbox
id="newObjects"
checked={field.value.includes("NEW")}
onCheckedChange={(checked) => {
const newValue = checked
? [...field.value, "NEW"]
: field.value.filter((v) => v !== "NEW");
field.onChange(newValue);
}}
disabled={props.disabled}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor="newObjects"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
New{" "}
{form.watch("target") === "trace"
? "traces"
: "dataset run items"}
</label>
</div>
</div>
<div className="items-top flex space-x-2">
<Checkbox
id="existingObjects"
checked={field.value.includes("EXISTING")}
onCheckedChange={(checked) => {
const newValue = checked
? [...field.value, "EXISTING"]
: field.value.filter((v) => v !== "EXISTING");
field.onChange(newValue);
}}
disabled={
props.disabled ||
(props.mode === "edit" &&
field.value.includes("EXISTING"))
}
/>
<div className="flex items-center gap-1.5 leading-none">
<label
htmlFor="existingObjects"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Existing{" "}
{form.watch("target") === "trace"
? "traces"
: "dataset run items"}
</label>
{field.value.includes("EXISTING") &&
props.mode !== "edit" &&
!props.disabled && (
<ExecutionCountTooltip
projectId={props.projectId}
item={form.watch("target")}
filter={form.watch("filter")}
/>
)}
</div>
</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="filter"
render={({ field }) => (
<FormItem>
<FormLabel>Target filter</FormLabel>
{isTraceTarget(form.watch("target")) ? (
<>
<FormControl>
<InlineFilterBuilder
columns={tracesTableColsWithOptions(
traceFilterOptions.data,
evalTraceTableCols,
)}
filterState={field.value ?? []}
onChange={(value) => field.onChange(value)}
disabled={props.disabled}
/>
</FormControl>
<FormDescription>
<TimeScopeDescription
projectId={props.projectId}
timeScope={form.watch("timeScope")}
target="trace"
/>
</FormDescription>
<FormMessage />
</>
) : (
<>
<FormControl>
<InlineFilterBuilder
columns={datasetFormFilterColsWithOptions(
datasetFilterOptions,
evalDatasetFormFilterCols,
)}
filterState={field.value ?? []}
onChange={(value) => field.onChange(value)}
disabled={props.disabled}
/>
</FormControl>
<FormDescription>
<TimeScopeDescription
projectId={props.projectId}
timeScope={form.watch("timeScope")}
target="dataset_item"
/>
</FormDescription>
<FormMessage />
</>
)}
</FormItem>
)}
/>
</Card>
<Card className="p-4">
<FormField
control={form.control}
name="mapping"
render={() => (
<>
<FormLabel className="">Variable mapping</FormLabel>
<FormControl>
Here will some variable mapping be added.
</FormControl>
<div
className={cn(
"my-2 flex flex-col gap-2",
!props.shouldWrapVariables && "lg:flex-row",
)}
>
<JSONView
title={"Eval Template"}
json={props.evalTemplate.prompt ?? null}
className={cn(
"min-h-48",
!props.shouldWrapVariables && "lg:w-2/3",
)}
codeClassName="flex-1"
/>
<div
className={cn(
"flex flex-col gap-2",
!props.shouldWrapVariables && "lg:w-1/3",
)}
>
{fields.map((mappingField, index) => (
<Card className="flex flex-col gap-2 p-4" key={index}>
<div className="text-sm font-semibold">
{"{{"}
{mappingField.templateVariable}
{"}}"}
<DocPopup
description={
"Variable in the template to be replaced with the trace data."
}
href={
"https://langfuse.com/docs/scores/model-based-evals"
}
/>
</div>
<FormField
control={form.control}
key={`${mappingField.id}-langfuseObject`}
name={`mapping.${index}.langfuseObject`}
render={({ field }) => (
<div className="flex items-center gap-2">
<VariableMappingDescription
title="Object"
description={
"Langfuse object to retrieve the data from."
}
href={
"https://langfuse.com/docs/scores/model-based-evals"
}
/>
<FormItem className="w-2/3">
<FormControl>
<Select
disabled={props.disabled}
defaultValue={field.value}
onValueChange={field.onChange}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableVariables.map(
(evalObject) => (
<SelectItem
value={evalObject.id}
key={evalObject.id}
>
{evalObject.display}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</div>
)}
/>
{!isTraceOrDatasetObject(
form.watch(`mapping.${index}.langfuseObject`),
) ? (
<FormField
control={form.control}
key={`${mappingField.id}-objectName`}
name={`mapping.${index}.objectName`}
render={({ field }) => (
<div className="flex items-center gap-2">
<VariableMappingDescription
title={"Object Name"}
description={
"Name of the Langfuse object to retrieve the data from."
}
href={
"https://langfuse.com/docs/scores/model-based-evals"
}
/>
<FormItem className="w-2/3">
<FormControl>
<Input
{...field}
value={field.value ?? ""}
disabled={props.disabled}
/>
</FormControl>
<FormMessage />
</FormItem>
</div>
)}
/>
) : undefined}
<FormField
control={form.control}
key={`${mappingField.id}-selectedColumnId`}
name={`mapping.${index}.selectedColumnId`}
render={({ field }) => (
<div className="flex items-center gap-2">
<VariableMappingDescription
title={"Object Variable"}
description={
"Variable on the Langfuse object to insert into the template."
}
href={
"https://langfuse.com/docs/scores/model-based-evals"
}
/>
<FormItem className="w-2/3">
<FormControl>
<Select
disabled={props.disabled}
defaultValue={field.value ?? undefined}
onValueChange={(value) => {
const availableColumns =
availableVariables.find(
(evalObject) =>
evalObject.id ===
form.watch(
`mapping.${index}.langfuseObject`,
),
)?.availableColumns;
const column = availableColumns?.find(
(column) => column.id === value,
);
field.onChange(column?.id);
}}
>
<SelectTrigger>
<SelectValue placeholder="Object type" />
</SelectTrigger>
<SelectContent>
{availableVariables
.find(
(evalObject) =>
evalObject.id ===
form.watch(
`mapping.${index}.langfuseObject`,
),
)
?.availableColumns.map((column) => (
<SelectItem
value={column.id}
key={column.id}
>
{column.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</div>
)}
/>
{fieldHasJsonSelectorOption(
form.watch(`mapping.${index}.selectedColumnId`),
) ? (
<FormField
control={form.control}
key={`${mappingField.id}-jsonSelector`}
name={`mapping.${index}.jsonSelector`}
render={({ field }) => (
<div className="flex items-center gap-2">
<VariableMappingDescription
title={"JsonPath"}
description={
"Optional selection: Use JsonPath syntax to select from a JSON object stored on a trace. If not selected, we will pass the entire object into the prompt."
}
href={
"https://langfuse.com/docs/scores/model-based-evals"
}
/>
<FormItem className="w-2/3">
<FormControl>
<Input
{...field}
value={field.value ?? ""}
disabled={props.disabled}
placeholder="Optional"
/>
</FormControl>
<FormMessage />
</FormItem>
</div>
)}
/>
) : undefined}
</Card>
))}
</div>
</div>
<FormDescription>
Insert trace data into the prompt template.
</FormDescription>
<FormMessage />
</>
)}
/>
</Card>
<Card className="flex flex-col gap-6 p-4">
<FormField
control={form.control}
name="sampling"
render={({ field }) => (
<FormItem>
<FormLabel>Sampling</FormLabel>
<FormControl>
<Slider
disabled={props.disabled}
min={0}
max={1}
step={0.01}
value={[field.value]}
onValueChange={(value) => field.onChange(value[0])}
/>
</FormControl>
<div className="flex flex-col">
<FormDescription className="flex justify-between">
<span>0%</span>
<span>100%</span>
</FormDescription>
<FormDescription className="mt-1 flex flex-row gap-1">
<span>Percentage of traces to evaluate.</span>
<span>
Currently set to {(field.value * 100).toFixed(0)}%.
</span>
</FormDescription>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="delay"
render={({ field }) => (
<FormItem>
<FormLabel>Delay (seconds)</FormLabel>
<FormControl>
<Input {...field} type="number" />
</FormControl>
<FormDescription>
Time between first Trace/Dataset run event and evaluation
execution to ensure all data is available
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</Card>
</div>
{!props.disabled ? (
<Button
type="submit"
loading={createJobMutation.isLoading || updateJobMutation.isLoading}
className="mt-3"
>
Save
</Button>
) : null}
</form>
{formError ? (
<p className="text-red text-center">
<span className="font-bold">Error:</span> {formError}
</p>
) : null}
</Form>
);
};
@@ -420,7 +420,7 @@ export const InnerEvalTemplateForm = (props: {
<CodeMirrorEditor
value={field.value}
onChange={field.onChange}
editable
editable={props.isEditing}
mode="prompt"
minHeight={200}
/>
@@ -107,7 +107,7 @@ export default function EvaluatorsPage() {
<ActionButton
hasAccess={hasWriteAccess}
icon={<Plus className="h-4 w-4" />}
variant="outline"
variant="default"
onClick={() => capture("eval_config:new_form_open")}
href={`/project/${projectId}/evals/new`}
limitValue={countsQuery.data?.configActiveCount ?? 0}
@@ -16,6 +16,7 @@ export default function NewEvaluatorPage() {
return (
<Page
withPadding
scrollable
headerProps={{
title: "Create evaluator",
@@ -18,6 +18,7 @@ export default function NewTemplatesPage() {
return (
<Page
withPadding
scrollable
headerProps={{
title: "Create eval template",
@@ -57,7 +57,7 @@ export default function TemplatesPage() {
disabled={!hasWriteAccess}
onClick={() => capture("eval_templates:new_form_open")}
asChild
variant="outline"
variant="default"
>
<Link
href={
@@ -0,0 +1,25 @@
import { z } from "zod";
import {
singleFilter,
type langfuseObjects,
TimeScopeSchema,
} from "@langfuse/shared";
import { wipVariableMapping } from "@langfuse/shared";
export const isTraceTarget = (target: string): boolean => target === "trace";
export const isTraceOrDatasetObject = (object: string): boolean =>
object === "trace" || object === "dataset_item";
export const evalConfigFormSchema = z.object({
scoreName: z.string(),
target: z.string(),
filter: z.array(singleFilter).nullable(), // reusing the filter type from the tables
mapping: z.array(wipVariableMapping),
sampling: z.coerce.number().gt(0).lte(1),
delay: z.coerce.number().optional().default(10),
timeScope: TimeScopeSchema,
});
export type EvalFormType = z.infer<typeof evalConfigFormSchema>;
export type LangfuseObject = (typeof langfuseObjects)[number];
@@ -79,6 +79,7 @@ import Link from "next/link";
import { useHasEntitlement } from "@/src/features/entitlements/hooks";
import { DropdownMenuItem } from "@/src/components/ui/dropdown-menu";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { useExperimentNameValidation } from "@/src/ee/features/experiments/hooks/useExperimentNameValidation";
const CreateExperimentData = z.object({
name: z
@@ -311,6 +312,17 @@ export const CreateExperimentsForm = ({
const archiveEvaluatorMutation = api.evals.updateEvalJob.useMutation();
const runNamesByDatasetId = api.datasets.baseRunDataByDatasetId.useQuery(
{ projectId, datasetId },
{ enabled: Boolean(datasetId) },
);
const allExperimentNames = useMemo(() => {
return runNamesByDatasetId.data?.map((experiment) => ({
value: experiment.name,
}));
}, [runNamesByDatasetId.data]);
// Watch model config changes and update form
useEffect(() => {
form.setValue("modelConfig", {
@@ -320,6 +332,12 @@ export const CreateExperimentsForm = ({
});
}, [modelParams, form]);
useExperimentNameValidation({
currentName: form.watch("name"),
allExperimentNames,
form,
});
const onSubmit = async (data: CreateExperiment) => {
capture("dataset_run:new_form_submit");
const experiment = {
@@ -895,8 +913,9 @@ export const CreateExperimentsForm = ({
<Button
type="submit"
disabled={
Boolean(promptId && datasetId) &&
!validationResult.data?.isValid
(Boolean(promptId && datasetId) &&
!validationResult.data?.isValid) ||
!!form.formState.errors.name
}
loading={form.formState.isSubmitting}
>
@@ -0,0 +1,33 @@
import { useEffect } from "react";
import { type UseFormReturn } from "react-hook-form";
interface UseExperimentNameValidationProps {
currentName: string | undefined;
allExperimentNames: { value: string }[] | undefined;
form: UseFormReturn<any>;
}
export const useExperimentNameValidation = ({
currentName,
allExperimentNames,
form,
}: UseExperimentNameValidationProps) => {
useEffect(() => {
if (!currentName) {
form.clearErrors("name");
return;
}
const isNewExperiment = !allExperimentNames
?.map((experiment) => experiment.value)
.includes(currentName);
if (!isNewExperiment) {
form.setError("name", {
message: "Experiment name already exists for this dataset.",
});
} else {
form.clearErrors("name");
}
}, [currentName, allExperimentNames, form]);
};
@@ -84,7 +84,7 @@ export const useModelParams = () => {
}
}, [availableModels, modelParams.model.value]);
// Update adapter and max temperature when provider changes
// Update adapter, max temperature, temperature, max_tokens, top_p when provider changes
useEffect(() => {
if (selectedProviderApiKey?.adapter) {
setModelParams((prev) => ({
@@ -93,15 +93,32 @@ export const useModelParams = () => {
value: selectedProviderApiKey.adapter,
enabled: true,
},
maxTemperature: getDefaultAdapterParams(selectedProviderApiKey.adapter)
.maxTemperature,
maxTemperature: {
value: getDefaultAdapterParams(selectedProviderApiKey.adapter)
.maxTemperature.value,
enabled: getDefaultAdapterParams(selectedProviderApiKey.adapter)
.maxTemperature.enabled,
},
temperature: {
value: Math.min(
prev.temperature.value,
getDefaultAdapterParams(selectedProviderApiKey.adapter)
.maxTemperature.value,
),
enabled: true,
enabled: getDefaultAdapterParams(selectedProviderApiKey.adapter)
.temperature.enabled,
},
max_tokens: {
value: getDefaultAdapterParams(selectedProviderApiKey.adapter)
.max_tokens.value,
enabled: getDefaultAdapterParams(selectedProviderApiKey.adapter)
.max_tokens.enabled,
},
top_p: {
value: getDefaultAdapterParams(selectedProviderApiKey.adapter).top_p
.value,
enabled: getDefaultAdapterParams(selectedProviderApiKey.adapter).top_p
.enabled,
},
}));
}
@@ -194,5 +211,16 @@ function getDefaultAdapterParams(
max_tokens: { value: 256, enabled: true },
top_p: { value: 1, enabled: true },
};
case LLMAdapter.Atla:
return {
adapter: {
value: adapter,
enabled: true,
},
temperature: { value: 0, enabled: false },
maxTemperature: { value: 1, enabled: false },
max_tokens: { value: 4096, enabled: false },
top_p: { value: 1, enabled: false },
};
}
}

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