Compare commits

...
77 Commits
Author SHA1 Message Date
Max Deichmann 1585bf5e14 chore: release v3.95.2 2025-08-07 20:11:10 +02:00
Max DeichmannandGitHub dec6d6f975 chore: add openai 5 models (#8378) 2025-08-07 20:09:36 +02:00
Max DeichmannandGitHub 36fa7ea15e chore: improve typing of users (#8376)
push
2025-08-07 17:28:10 +00:00
Marlies Mayerhofer 6a4d56a5a9 chore: release v3.95.1 2025-08-07 18:32:29 +02:00
Marc KlingenandGitHub 9f0a40949e fix: check projectMembers:read for trpc members.byProjectId (#8373) 2025-08-07 17:55:57 +02:00
Steffen SchmitzandGitHub 10fdd9e172 chore: add env to move short-term reads to AMTs (#8367) 2025-08-07 15:26:11 +00:00
marliessophieandGitHub fc58add111 chore(rbac): fix types for resolveProjectRole (#8371)
* chore(rbac): fix types for resolveProjectRole

* chore: eslint
2025-08-07 15:18:22 +00:00
Steffen SchmitzandGitHub 10993838d5 perf: truncate IO in table overviews and skip internal JSON parse on TRPC (#8364)
* perf: truncate IO in table overviews and skip internal JSON parse on TRPC

* chore: fix imports

* chore: align test case
2025-08-07 15:09:35 +00:00
Steffen SchmitzGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
148cd29e9f fix: handle invalid string lengths on traces_null table gracefully (#8369)
* fix: handle invalid string lengths on traces_null table gracefully

* Update worker/src/services/ClickhouseWriter/index.ts

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

* chore: test dropped record truncation

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-08-07 14:25:00 +00:00
Steffen SchmitzandGitHub 7a353e99af chore: disable usage alert configuration temporarily (#8360) 2025-08-07 10:22:08 +00:00
Marc KlingenandGitHub dc52106feb chore: improve multi-tenant sso error message (#8363) 2025-08-07 12:44:06 +02:00
Marlies Mayerhofer b2f8eb7078 chore: release v3.95.0 2025-08-07 11:55:36 +02:00
026d8e2ba0 feat(annotation): support assigning users to queues (#8325)
* chore(annotation-queue): add AnnotationQueueMembership model and related database schema updates

* chore(annotation-queue): add AnnotationQueueMembership POST and DEL APIs

* feat(annotation-queue): implement user assignment functionality for annotation queues

* fix: role logic for users

* chore: rn to assignments

* fixup: rn to assignments

* chore(annotation-queue): enhance user assignment section with debounced search and display of assigned users

* feat(annotation-queue): add MultiSelectCombobox for user assignment and enhance DataTable with row class name functionality

* refactor(rbac): replace generateUserQuery with generateUserProjectRolesQuery and introduce utility functions for project role resolution

* fixup: filter query for user ids

* fixup: resolve project role test

* fix: validate user project role before adding to annotation assignment

* chore: remove code

* chore: fix

* chore: fix filter

* chore: fix filter

* tests for auth logic

* push: fix api route test

* tests for auth logic

* fix

* fix

* chore: push

* chore: rename

* docs: api types

* fix: formatting

* fix: formatting

* chore: add disabled prop to MultiSelectCombobox

* chore: rename migration

---------

Co-authored-by: Max Deichmann <m.deichmann@tum.de>
2025-08-07 09:29:45 +00:00
Hassieb Pakzad 9a9f0b1743 chore: release v3.94.0 2025-08-07 10:30:22 +02:00
Hassieb PakzadandGitHub 74a510fbde feat(otel): add parsing for Vercel AI SDK (#8347)
* feat(otel): add parsing for Vercel AI SDK

* push

* push
2025-08-07 07:43:34 +00:00
Steffen SchmitzandGitHub c23b226e62 chore: move dashboard queries to new AMTs (#8308)
* chore: create background migrations for traces amt tables

* chore: add database migrations

* chore: move tests to use AMTs in one config

* chore: shift backfill from {} to map()

* chore: modify ingestion test timeouts

* chore: keep minDate updates tracked

* chore: remove android lib to save swap space

* chore: check disk space

* chore: check mnt usage

* chore: skip swap to check runner performance

* chore: add timestamp lines

* chore: handle deletions

* chore: update test cases

* chore: linting

* chore: allow opt-in creation for background migratoin record

* chore: update comparison query

* chore: concurrency config

* chore: update names

* chore: update names

* chore: update tiemout

* chore: add pre-query logs

* chore: refactor long running query into util function

* chore: refactor query exists checks

* chore: set correct tables for clustered configs

* chore: increase number of retries

* chore: extend logging

* chore: gracefully handle aborted query

* chore: handle errors during execution and raise them

* chore; skip in progress header updates

* Update migrateTracesToTracesAMTs.ts

* chore: use default clickhouse settings for ingestion

* chore: add trace_id index on traces_all_amt

* chore: update sorting behaviour of batchAction test

* chore: drop logger

* chore: update test

* chore: lint and renames

* chore: update test cases

* chore: patch score tests

* chore: add final for the checkTraceExists behaviour

* chore: remove background migration insert

* chore: move dashboard queries to new AMTs

* chore: make sure to replace all references to old traces table

* chore: revert replacement behaviour

* chore: formatting

* chore: typo

* chore: lint and refactor

* chore: update import path

* chore: patch test behaviour

* chore: remove outdated comment
2025-08-06 13:57:15 +00:00
Steffen SchmitzandGitHub 2f50b38a68 chore: adjust stalled settings for eval execution queue (#8343)
* chore: tune queue stalled checks for higher reliability

* chore: only apply stalled to eval execution queue
2025-08-06 12:29:47 +00:00
marliessophieandGitHub 451ae15e00 feat(evaluator-form): add tooltips for evaluator usage and target in edit mode (#8340)
* feat(evaluator-form): add tooltip for existing evaluator usage in edit mode

* feat(evaluator-form): enhance target data label with tooltip for edit mode

* chore: lint
2025-08-06 10:35:43 +00:00
marliessophieandGitHub e34d81578b chore(evaluator-form): prevent form submission on Enter key press; hide controls in preview (#8337)
* chore(evaluator-form): prevent form submission on Enter key press

* chore: disable select control on evaluation traces preview
2025-08-06 08:28:13 +00:00
Max DeichmannandGitHub 214c9c7ed2 perf: increase clickhouse timeouts for blob storage exports (#8328)
push
2025-08-05 22:45:49 +00:00
Max DeichmannandGitHub 961b3bfa8d chore: truncate traces i/o for the traces table (#8326)
* push

* push

* push

* something

* something
2025-08-05 20:53:40 +00:00
Marc KlingenandGitHub a1dd5b22a2 docs: improve api reference for llm-connections api (#8327) 2025-08-05 20:43:45 +02:00
Marc KlingenandGitHub 49950f9706 chore: add validation to okta issuer (#8323) 2025-08-05 17:22:40 +00:00
Max DeichmannandGitHub cfdd0fdb73 chore: refactor IOTableCell (#8324) 2025-08-05 19:26:16 +02:00
Marc KlingenandGitHub 891e7e9716 feat(models): add claude-opus-4-1-20250805 (#8322) 2025-08-05 17:19:34 +00:00
Marc KlingenandGitHub 5b407dad53 feat(posthog-integration): add langfuse_trace_id to generations and scores (#8319)
feat(posthog-integration): add langfuse_trace_id to generations and scores
2025-08-05 15:25:31 +00:00
steffen911 26e3bf9a44 chore: release v3.93.0 2025-08-05 16:56:10 +02:00
Steffen SchmitzandGitHub 1f02e364e1 chore: create background migrations for traces amt tables (#7689)
* chore: create background migrations for traces amt tables

* chore: add database migrations

* chore: move tests to use AMTs in one config

* chore: shift backfill from {} to map()

* chore: modify ingestion test timeouts

* chore: keep minDate updates tracked

* chore: remove android lib to save swap space

* chore: check disk space

* chore: check mnt usage

* chore: skip swap to check runner performance

* chore: add timestamp lines

* chore: handle deletions

* chore: update test cases

* chore: linting

* chore: allow opt-in creation for background migratoin record

* chore: update comparison query

* chore: concurrency config

* chore: update names

* chore: update names

* chore: update tiemout

* chore: add pre-query logs

* chore: refactor long running query into util function

* chore: refactor query exists checks

* chore: set correct tables for clustered configs

* chore: increase number of retries

* chore: extend logging

* chore: gracefully handle aborted query

* chore: handle errors during execution and raise them

* chore; skip in progress header updates

* Update migrateTracesToTracesAMTs.ts

* chore: use default clickhouse settings for ingestion

* chore: add trace_id index on traces_all_amt

* chore: update sorting behaviour of batchAction test

* chore: drop logger

* chore: update test

* chore: lint and renames

* chore: update test cases

* chore: patch score tests

* chore: add final for the checkTraceExists behaviour

* chore: remove background migration insert

* chore: convert traces_mt to traces_null

* chore: patch tests

* chore: switch from minMap to maxMap

* chore: mark aggregation columns on traces as do not use

* chore: patch new test

* chore: switch to maxMap

* consistent naming
2025-08-05 14:20:49 +00:00
Leo WeigandandGitHub 90dca15d86 feat(settings): improve UX for managing LLM connections (#8312) 2025-08-05 14:30:23 +02:00
Steffen SchmitzandGitHub e3f8dc8bb3 fix(blob-storage): disable auto container check for Azure buckets for costsavings (#8317) 2025-08-05 11:44:03 +00:00
Hassieb Pakzad 585ede0919 chore: release v3.92.1 2025-08-05 11:24:29 +02:00
Hassieb PakzadandGitHub 0db425d120 fix(pagination): allow limit zero again (#8315) 2025-08-05 09:22:34 +00:00
Hassieb Pakzad 09e33c3059 chore: release v3.92.0 2025-08-05 10:01:57 +02:00
Hassieb PakzadandGitHub 66226011af feat(llm-api-keys): add public api methods (#8301)
* feat(llm-api-keys): add public api LIST and PATCH endpoints

* puhs

* push

* push

* add post endpoint

* add PUT endpoint

* push

* push
2025-08-05 07:32:18 +00:00
marliessophieandGitHub 571698ab2e fix(pretty-json-view): prevent infinite recursion and optimize child row processing (#8302) 2025-08-04 18:05:45 +00:00
Steffen SchmitzandGitHub 312066f735 chore: add operation_name tag to AMT query experiments (#8294) 2025-08-04 11:25:32 +00:00
marliessophieandGitHub 5abeaf8adb fix(dataset-run-items): fix pagination for consistent ordering across sources (#8292) 2025-08-04 10:23:48 +00:00
Steffen SchmitzandGitHub fac3c732de chore: optimize queries for session AMT accesses (#8289) 2025-08-04 09:55:17 +00:00
marliessophieandGitHub 5e2e3bb5fc feat(sessions-ui): allow filtering table by scores (#7858)
* fixup(sessions-ui): allow filtering table by scores

* fix(table-definitions): update scores label from "Scores (avg)" to "Scores (numeric)"

* feat: support scores filters on sessions

* chore

* chore: fix duplicate in query

* chore: hide whitespace

* chore: fix
2025-08-04 09:36:48 +00:00
Max Deichmann 0564df8e51 chore: release v3.91.0 2025-08-04 11:33:20 +02:00
Steffen SchmitzandGitHub a2801a3be9 chore: add experiment tags to clickhouse queries (#8288) 2025-08-04 09:17:07 +00:00
Hassieb PakzadGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
075eb58ecb chore: add fern JS SDK generator (#8095)
* chore: add fern JS SDK generator

* push

* return java generator

* push

* push

* push

* Update worker/src/services/IngestionService/tests/IngestionService.integration.test.ts

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

* Update worker/src/services/IngestionService/tests/IngestionService.integration.test.ts

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

* puhs

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-08-04 08:49:43 +00:00
Steffen SchmitzandGitHub 674d66d179 chore: record AMT duration distribution (#8287) 2025-08-04 10:32:10 +02:00
marliessophieandGitHub 163f2a02ff fix(dataset-run-items): include run metadata for runs without dataset_run_items (#8267)
* fix(dataset-run-items): include run metadata for runs without dataset_run_items

* chore: drop name from metrics query
2025-08-04 08:12:46 +00:00
Steffen SchmitzandGitHub 075836f210 chore: upsert traces into both traces tables (#8268) 2025-08-04 09:28:46 +02:00
marliessophieandGitHub 543b6ee0f2 feat(peek-evaluator-config): add edit mode functionality (#8285) 2025-08-04 07:12:59 +00:00
marliessophieandGitHub 8c8c488e4d feat(annotation-queues): add support for batch adding observations to annotation queues (#8261) 2025-08-04 07:10:48 +00:00
Hassieb PakzadandGitHub 6a0e0a4221 feat(llm-connections): remove atla integration (#8059) 2025-08-04 05:53:24 +00:00
Max DeichmannandGitHub 1b01a267df chore: only show 10 traces for evals (#8283) 2025-08-03 20:39:51 +00:00
Thorsten SpiekerandGitHub 69a9146894 Revert "refactor: break up SlackService and move to web/worker packages" (#8276)
Revert "refactor: break up SlackService and move to web/worker packages (#8247)"

This reverts commit e452004a6a.
2025-08-03 18:59:20 +00:00
380403e8ed fix(cloud): add csp headers needed for attachment upload to Plain support chat (#7813)
Update CSP headers to allow additional S3 image and connect sources

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-08-03 18:52:25 +02:00
Max DeichmannGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
296a6c3ee6 chore: limit batch sizes of trace deletions (#8274)
* push

* Update worker/src/env.ts

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

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-08-02 07:07:45 +00:00
Steffen SchmitzandGitHub 62904a9563 ci: debug docker build issues (#8269) 2025-08-01 18:07:42 +02:00
Steffen SchmitzandGitHub 90247ab64c chore: migrate additional queries to traces amts (#8249) 2025-08-01 15:06:16 +02:00
marliessophieandGitHub 5cf91c60d9 fix(layout): highlight nav items when viewing child routes (#8211) 2025-08-01 11:50:11 +00:00
Steffen SchmitzandGitHub 559ba6d05d chore: remove unused sessions query (#8264) 2025-08-01 13:17:32 +02:00
Steffen SchmitzandGitHub f071be69b6 build: add always() condition to run notifications on failure (#8265) 2025-08-01 12:54:30 +02:00
Max DeichmannandGitHub 52c261b422 perf: delete traces in batches (#8250) 2025-08-01 11:33:32 +02:00
Steffen SchmitzandGitHub 6f0a43ec65 build: setup slack alerts on failing CI (#8255) 2025-08-01 11:12:02 +02:00
marliessophieandGitHub 301bd6b569 feat(dataset-run-items): reads router (#8207)
* chore(dataset-run-items): GET /dataset-run-items

* fixup(dataset-run-items): GET /dataset-run-items

* chore(dataset-run-items): GET datasetRun/[runName]

* fix(dataset-run-items): return 0 for count when no records found

* fix(dataset-run-items): ensure offset is calculated only when page and limit are present

* feat(dataset-run-items): enhance dataset filtering by adding datasetId support and refactor related functions

* chore: rm comment

* refactor(dataset-run-items): rename dataset run name to ID across API and table definitions

* feat(dataset-run-items): add dataset_run_items to Clickhouse table names and refactor dataset filtering to use camelCase

* fix(dataset-run-items): update dataset filtering to ensure only the latest version of each dataset run item is retrieved

* chore: imports

* chore: fix de-duplication

* chore(dataset-router): mark runitemsByRunIdOrItemId as deprecated

* chore: refactor only, split out routes into byItemId and byRunId

* Revert "chore: refactor only, split out routes into byItemId and byRunId"

This reverts commit 2e1f2e3c14b9ed1173e2fb73b7f721c72d57bfbb.

* Revert "chore(dataset-router): mark runitemsByRunIdOrItemId as deprecated"

This reverts commit 5cb2232198d84baf7f027b00603c6fb3b9489c52.

* fixup: runitemsbyrunidoritemid

* chore: rewrite runitemsByRunIdOrItemId

* chore: rewrite runsByDatasetIdMetrics

* chore: ordering

* chore: order by created at and remove base data from query

* chore: add back name

* chore: push
2025-08-01 08:10:22 +00:00
NimarandGitHub 56fd3df2d2 fix(json-view): truncate cell after 2k chacacters (#8227)
* fix(json-view): truncate cell after 2k chacacters

* real value for truncated chars

* just rely on regex
2025-07-31 20:12:44 +00:00
Max Deichmann db433e2b72 chore: release v3.90.0 2025-07-31 20:52:35 +02:00
Thorsten SpiekerandGitHub e452004a6a refactor: break up SlackService and move to web/worker packages (#8247)
* refactor: break up SlackService and move to web/worker packages

* fix: pnpm lock file

* fix: tests

* fix
2025-07-31 18:33:03 +00:00
NimarandGitHub bce026d8b2 fix(dataset-items): add placeholder text for new items (#8228) 2025-07-31 19:11:48 +02:00
marliessophieandGitHub fbdf12bfa3 chore(dataset-run-items): add background migration for DRI (#8245) 2025-07-31 18:07:43 +02:00
marliessophieandGitHub 78f59bc543 chore: add dataset-run-items tables to clickhouse (#8243) 2025-07-31 16:50:25 +02:00
James IdzikandGitHub ea338ed83d fix: coerce LANGFUSE_INIT_PROJECT_RETENTION and other envs correctly to number (#8103)
fix: coerce init project retention, historic max eval limit envs
2025-07-31 16:01:53 +02:00
Steffen SchmitzandGitHub 8cf67fe329 chore: correctly parse input and output on traces_mt ingestion (#8242) 2025-07-31 15:21:36 +02:00
Steffen SchmitzandGitHub e3b23ea5ec feat: allow PUT operations on SCIM users endpoint (#8236)
* feat: allow PUT operations on SCIM users endpoint

* chore: make tests pass

* cleanup
2025-07-31 12:24:45 +00:00
Steffen SchmitzandGitHub 7be2791105 chore: patch docs for ingestion replay (#8238)
* chore: patch docs for ingestion replay

* chore: increase s3 parallelization
2025-07-31 11:47:10 +00:00
NimarandGitHub 683aae0069 fix(json-view): tighter ChatML validation (#8225) 2025-07-31 10:02:58 +00:00
Steffen SchmitzandGitHub 1c8ffc607d chore: write single rows into AMTs instead of pre-merged record (#8218)
* chore: write single rows into AMTs instead of pre-merged record

* chore: adjust assertion

* chore: drop debug log
2025-07-31 09:27:14 +00:00
Steffen SchmitzandGitHub 9c203e8b8a chore: allow return from aggregatingmergetrees without experiments (#8215) 2025-07-31 09:07:39 +00:00
Steffen SchmitzandGitHub c32cccce52 feat: allow redis username overwrites via env variables (#8234) 2025-07-31 09:52:03 +02:00
NimarandGitHub f1f8da5b74 fix(json-view): store formatted/json preference in local storage (#8222) 2025-07-30 19:47:22 +00:00
Max DeichmannandGitHub c23447a624 chore: improve slack links (#8219) 2025-07-30 18:31:44 +02:00
Thorsten SpiekerandGitHub 5e2225de46 fix: use different env variable in worker to construct links (#8212) 2025-07-30 17:02:11 +02:00
170 changed files with 10578 additions and 2278 deletions
+1
View File
@@ -68,6 +68,7 @@ LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE=true
LANGFUSE_S3_EVENT_UPLOAD_PREFIX=events/
LANGFUSE_USE_AZURE_BLOB=true
LANGFUSE_AZURE_SKIP_CONTAINER_CHECK=false
# Set during docker build of application
# Used to disable environment verification at build time
+8
View File
@@ -82,3 +82,11 @@ ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
# speeds up local development by not executing init scripts on server startup
NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
# Use the following settings to enforce running the new AMTs during the tests
LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES="true"
LANGFUSE_EXPERIMENT_COMPARE_READ_FROM_AGGREGATING_MERGE_TREES="true"
LANGFUSE_EXPERIMENT_WHITELISTED_PROJECT_IDS="7a88fb47-b4e2-43b8-a06c-a5ce950dc53a"
LANGFUSE_EXPERIMENT_ADD_QUERY_RESULT_TO_SPAN_PROJECT_IDS="7a88fb47-b4e2-43b8-a06c-a5ce950dc53a"
LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT="true"
LANGFUSE_EXPERIMENT_SAMPLING_RATE=1
+1
View File
@@ -172,6 +172,7 @@ OTEL_SERVICE_NAME="langfuse"
# REDIS_HOST=
# REDIS_PORT=
# REDIS_AUTH=
# REDIS_USERNAME=default
# REDIS_CONNECTION_STRING=
# REDIS_ENABLE_AUTO_PIPELINING=
+11 -1
View File
@@ -114,7 +114,9 @@ jobs:
run: echo "NEXT_PUBLIC_BUILD_ID=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Build and run both images from compose
run: |
docker compose -f docker-compose.build.yml up -d
docker compose --progress plain --verbose -f docker-compose.build.yml build --print > /tmp/bake.json
docker buildx bake -f /tmp/bake.json
docker compose --progress plain -f docker-compose.build.yml up -d
sleep 5 # Wait for PostgreSQL to accept connections
- name: Ensure no unhealthy status
run: |
@@ -490,6 +492,14 @@ jobs:
if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
run: exit 1
working-directory: .
- name: Notify Slack
uses: ravsamhq/notify-slack-action@v2
if: always() && github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
with:
status: ${{ job.status }}
notify_when: "failure"
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
push-docker-image:
needs: all-ci-passed
+1 -1
View File
@@ -50,7 +50,7 @@ yarn-error.log*
# typescript
*.tsbuildinfo
/generated/typescript-server
/generated
# openapi spec that is copied during build
/public/openapi*.yml
+1 -1
View File
@@ -19,6 +19,7 @@ services:
ports:
- 127.0.0.1:3030:3030
environment: &langfuse-worker-env
NEXTAUTH_URL: http://localhost:3000
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres # CHANGEME
SALT: "mysalt" # CHANGEME
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000" # CHANGEME: generate via `openssl rand -hex 32`
@@ -73,7 +74,6 @@ services:
- 3000:3000
environment:
<<: *langfuse-worker-env
NEXTAUTH_URL: http://localhost:3000
NEXTAUTH_SECRET: mysecret # CHANGEME
LANGFUSE_INIT_ORG_ID: ${LANGFUSE_INIT_ORG_ID:-}
LANGFUSE_INIT_ORG_NAME: ${LANGFUSE_INIT_ORG_NAME:-}
@@ -105,6 +105,28 @@ service:
docs: The unique identifier of the annotation queue item
response: DeleteAnnotationQueueItemResponse
createQueueAssignment:
docs: Create an assignment for a user to an annotation queue
method: POST
path: /annotation-queues/{queueId}/assignments
path-parameters:
queueId:
type: string
docs: The unique identifier of the annotation queue
request: AnnotationQueueAssignmentRequest
response: CreateAnnotationQueueAssignmentResponse
deleteQueueAssignment:
docs: Delete an assignment for a user to an annotation queue
method: DELETE
path: /annotation-queues/{queueId}/assignments
path-parameters:
queueId:
type: string
docs: The unique identifier of the annotation queue
request: AnnotationQueueAssignmentRequest
response: DeleteAnnotationQueueAssignmentResponse
types:
AnnotationQueueStatus:
enum:
@@ -163,3 +185,17 @@ types:
properties:
success: boolean
message: string
AnnotationQueueAssignmentRequest:
properties:
userId: string
DeleteAnnotationQueueAssignmentResponse:
properties:
success: boolean
CreateAnnotationQueueAssignmentResponse:
properties:
userId: string
queueId: string
projectId: string
@@ -0,0 +1,102 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
imports:
commons: ./commons.yml
pagination: ./utils/pagination.yml
service:
auth: true
base-path: /api/public
endpoints:
list:
method: GET
docs: Get all LLM connections in a project
path: /llm-connections
request:
name: GetLlmConnectionsRequest
query-parameters:
page:
type: optional<integer>
docs: page number, starts at 1
limit:
type: optional<integer>
docs: limit of items per page
response: PaginatedLlmConnections
upsert:
method: PUT
docs: Create or update an LLM connection. The connection is upserted on provider.
path: /llm-connections
request: UpsertLlmConnectionRequest
response: LlmConnection
types:
LlmConnection:
docs: LLM API connection configuration (secrets excluded)
properties:
id: string
provider:
type: string
docs: Provider name (e.g., 'openai', 'my-gateway'). Must be unique in project, used for upserting.
adapter:
type: string
docs: The adapter used to interface with the LLM
displaySecretKey:
type: string
docs: Masked version of the secret key for display purposes
baseURL:
type: optional<string>
docs: Custom base URL for the LLM API
customModels:
type: list<string>
docs: List of custom model names available for this connection
withDefaultModels:
type: boolean
docs: Whether to include default models for this adapter
extraHeaderKeys:
type: list<string>
docs: Keys of extra headers sent with requests (values excluded for security)
createdAt: datetime
updatedAt: datetime
PaginatedLlmConnections:
properties:
data: list<LlmConnection>
meta: pagination.MetaResponse
UpsertLlmConnectionRequest:
docs: Request to create or update an LLM connection (upsert)
properties:
provider:
type: string
docs: Provider name (e.g., 'openai', 'my-gateway'). Must be unique in project, used for upserting.
adapter:
type: LlmAdapter
docs: The adapter used to interface with the LLM
secretKey:
type: string
docs: Secret key for the LLM API.
baseURL:
type: optional<string>
docs: Custom base URL for the LLM API
customModels:
type: optional<list<string>>
docs: List of custom model names
withDefaultModels:
type: optional<boolean>
docs: Whether to include default models. Default is true.
extraHeaders:
type: optional<map<string, string>>
docs: Extra headers to send with requests
LlmAdapter:
enum:
- value: anthropic
name: Anthropic
- value: openai
name: OpenAI
- value: azure
name: Azure
- value: bedrock
name: Bedrock
- value: google-vertex-ai
name: GoogleVertexAI
- value: google-ai-studio
name: GoogleAIStudio
+26 -27
View File
@@ -1,3 +1,4 @@
# yaml-language-server: $schema=https://schema.buildwithfern.dev/generators-yml.json
default-group: local
groups:
local:
@@ -7,6 +8,7 @@ groups:
output:
location: local-file-system
path: ../../../web/public/generated/api
- name: fernapi/fern-python-sdk
version: 2.16.0
output:
@@ -19,35 +21,32 @@ groups:
pydantic_config:
require_optional_fields: false
use_str_enums: false
# - name: fernapi/fern-java-sdk
# version: 2.20.1
# output:
# location: local-file-system
# path: ../../../../langfuse-java/src/main/java/com/langfuse/client/
# config:
# client-class-name: LangfuseClient
# - name: fernapi/fern-java-sdk
# version: 2.20.1
# output:
# location: local-file-system
# path: ../../../../langfuse-java/src/main/java/com/langfuse/client/
# config:
# client-class-name: LangfuseClient
- name: fernapi/fern-typescript-node-sdk
version: 2.6.1
output:
location: local-file-system
path: ../../../generated/typescript
config:
namespaceExport: LangfuseAPI
outputSourceFiles: true
skipResponseValidation: true
fetchSupport: native
formDataSupport: Node18
fileResponseType: binary-response
streamType: web
omitFernHeaders: true
- name: fernapi/fern-postman
version: 0.0.45
output:
location: local-file-system
path: ../../../web/public/generated/postman
# published:
# generators:
# - name: fernapi/fern-python-sdk
# version: 0.3.7
# output:
# location: pypi
# url: pypi.buildwithfern.com
# package-name: finto-fern-langfuse
# config:
# namespaceExport: Langfuse
# allowCustomFetcher: true
# - name: fernapi/fern-typescript-node-sdk
# version: 0.7.1
# output:
# location: npm
# url: npm.buildwithfern.com
# package-name: "@finto-fern/langfuse-node"
# config:
# namespaceExport: Langfuse
# allowCustomFetcher: true
-1
View File
@@ -1 +0,0 @@
python/
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.89.0",
"version": "3.95.2",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
@@ -0,0 +1 @@
DROP TABLE dataset_run_items ON CLUSTER default;
@@ -0,0 +1,36 @@
CREATE TABLE dataset_run_items ON CLUSTER default (
-- primary identifiers
`id` String,
`project_id` String,
`dataset_run_id` String,
`dataset_item_id` String,
`dataset_id` String,
`trace_id` String,
`observation_id` Nullable(String),
-- error field
`error` Nullable(String),
-- timestamps
`created_at` DateTime64(3) DEFAULT now(),
`updated_at` DateTime64(3) DEFAULT now(),
-- denormalized immutable dataset run fields
`dataset_run_name` String,
`dataset_run_description` Nullable(String),
`dataset_run_metadata` Map(LowCardinality(String), String),
`dataset_run_created_at` DateTime64(3),
-- denormalized dataset item fields (mutable, but snapshots are relevant)
`dataset_item_input` Nullable(String) CODEC(ZSTD(3)), -- json
`dataset_item_expected_output` Nullable(String) CODEC(ZSTD(3)), -- json
`dataset_item_metadata` Map(LowCardinality(String), String),
-- clickhouse engine fields
`event_ts` DateTime64(3),
`is_deleted` UInt8,
-- For dataset item lookups
INDEX idx_dataset_item dataset_item_id TYPE bloom_filter(0.001) GRANULARITY 1,
) ENGINE = ReplacingMergeTree(event_ts, is_deleted)
ORDER BY (project_id, dataset_id, dataset_run_id, id);
@@ -0,0 +1,12 @@
-- Drop materialized views first
DROP VIEW IF EXISTS traces_30d_amt_mv ON CLUSTER default;
DROP VIEW IF EXISTS traces_7d_amt_mv ON CLUSTER default;
DROP VIEW IF EXISTS traces_all_amt_mv ON CLUSTER default;
-- Drop AMT tables
DROP TABLE IF EXISTS traces_30d_amt ON CLUSTER default;
DROP TABLE IF EXISTS traces_7d_amt ON CLUSTER default;
DROP TABLE IF EXISTS traces_all_amt ON CLUSTER default;
-- Drop the Null table
DROP TABLE IF EXISTS traces_null ON CLUSTER default;
@@ -0,0 +1,300 @@
-- Create a Null table that serves as a trigger for all materialized views.
-- We use a Null engine here to avoid storing intermediate results and save on storage.
CREATE TABLE traces_null ON CLUSTER default
(
-- Identifiers
`project_id` String,
`id` String,
`start_time` DateTime64(3),
`end_time` Nullable(DateTime64(3)),
`name` Nullable(String),
-- Metadata properties
`metadata` Map(LowCardinality(String), String),
`user_id` Nullable(String),
`session_id` Nullable(String),
`environment` String,
`tags` Array(String),
`version` Nullable(String),
`release` Nullable(String),
-- UI properties - We make them nullable to prevent absent values being interpreted as overwrites.
`bookmarked` Nullable(Bool),
`public` Nullable(Bool),
-- Aggregations -- DO NOT USE
`observation_ids` Array(String),
`score_ids` Array(String),
`cost_details` Map(String, Decimal64(12)),
`usage_details` Map(String, UInt64),
-- TODO: Do we want to aggregate/collect `levels` seen within the trace?
-- Input/Output
`input` String,
`output` String,
`created_at` DateTime64(3),
`updated_at` DateTime64(3),
`event_ts` DateTime64(3)
) Engine = Null();
-- Create the all AMT
CREATE TABLE traces_all_amt ON CLUSTER default
(
-- Identifiers
`project_id` String,
`id` String,
`timestamp` SimpleAggregateFunction(min, DateTime64(3)), -- Backward compatibility: redundant with start_time
`start_time` SimpleAggregateFunction(min, DateTime64(3)),
`end_time` SimpleAggregateFunction(max, Nullable(DateTime64(3))),
`name` SimpleAggregateFunction(anyLast, Nullable(String)),
-- Metadata properties
`metadata` SimpleAggregateFunction(maxMap, Map(String, String)),
`user_id` SimpleAggregateFunction(anyLast, Nullable(String)),
`session_id` SimpleAggregateFunction(anyLast, Nullable(String)),
`environment` SimpleAggregateFunction(anyLast, String),
`tags` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
`version` SimpleAggregateFunction(anyLast, Nullable(String)),
`release` SimpleAggregateFunction(anyLast, Nullable(String)),
-- UI properties
`bookmarked` AggregateFunction(argMax, Nullable(Bool), DateTime64(3)),
`public` AggregateFunction(argMax, Nullable(Bool), DateTime64(3)),
-- Aggregations -- DO NOT USE
`observation_ids` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
`score_ids` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
`cost_details` SimpleAggregateFunction(sumMap, Map(String, Decimal(38, 12))),
`usage_details` SimpleAggregateFunction(sumMap, Map(String, UInt64)),
-- Input/Output -> prefer correctness via argMax
`input` AggregateFunction(argMax, String, DateTime64(3)) CODEC (ZSTD(3)),
`output` AggregateFunction(argMax, String, DateTime64(3)) CODEC (ZSTD(3)),
`created_at` SimpleAggregateFunction(min, DateTime64(3)),
`updated_at` SimpleAggregateFunction(max, DateTime64(3)),
-- Indexes
INDEX idx_trace_id id TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_user_id user_id TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_session_id session_id TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_name name TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_version version TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_release release TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_tags tags TYPE bloom_filter(0.001) GRANULARITY 1
) Engine = AggregatingMergeTree()
ORDER BY (project_id, id);
-- Create materialized view for all_amt
CREATE MATERIALIZED VIEW IF NOT EXISTS traces_all_amt_mv ON CLUSTER default TO traces_all_amt AS
SELECT
-- Identifiers
tn.project_id as project_id,
tn.id as id,
min(tn.start_time) as timestamp, -- Backward compatibility: redundant with start_time
min(tn.start_time) as start_time,
max(coalesce(tn.end_time, tn.start_time)) as end_time,
anyLast(tn.name) as name,
-- Metadata properties
maxMap(tn.metadata) as metadata,
anyLast(tn.user_id) as user_id,
anyLast(tn.session_id) as session_id,
anyLast(tn.environment) as environment,
groupUniqArrayArray(tn.tags) as tags,
anyLast(tn.version) as version,
anyLast(tn.release) as release,
-- UI properties
argMaxState(tn.bookmarked, if(tn.bookmarked is not null, tn.event_ts, toDateTime64(0, 3))) as bookmarked,
argMaxState(tn.public, if(tn.public is not null, tn.event_ts, toDateTime64(0, 3))) as public,
-- Aggregations -- DO NOT USE
groupUniqArrayArray(tn.observation_ids) as observation_ids,
groupUniqArrayArray(tn.score_ids) as score_ids,
sumMap(tn.cost_details) as cost_details,
sumMap(tn.usage_details) as usage_details,
-- Input/Output
argMaxState(tn.input, if(tn.input <> '', tn.event_ts, toDateTime64(0, 3))) as input,
argMaxState(tn.output, if(tn.output <> '', tn.event_ts, toDateTime64(0, 3))) as output,
min(tn.created_at) as created_at,
max(tn.updated_at) as updated_at
FROM traces_null tn
GROUP BY project_id, id;
-- Create the 7-day TTL AMT
CREATE TABLE traces_7d_amt ON CLUSTER default
(
-- Identifiers
`project_id` String,
`id` String,
`timestamp` SimpleAggregateFunction(min, DateTime64(3)), -- Backward compatibility: redundant with start_time
`start_time` SimpleAggregateFunction(min, DateTime64(3)),
`end_time` SimpleAggregateFunction(max, Nullable(DateTime64(3))),
`name` SimpleAggregateFunction(anyLast, Nullable(String)),
-- Metadata properties
`metadata` SimpleAggregateFunction(maxMap, Map(String, String)),
`user_id` SimpleAggregateFunction(anyLast, Nullable(String)),
`session_id` SimpleAggregateFunction(anyLast, Nullable(String)),
`environment` SimpleAggregateFunction(anyLast, String),
`tags` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
`version` SimpleAggregateFunction(anyLast, Nullable(String)),
`release` SimpleAggregateFunction(anyLast, Nullable(String)),
-- UI properties
`bookmarked` AggregateFunction(argMax, Nullable(Bool), DateTime64(3)),
`public` AggregateFunction(argMax, Nullable(Bool), DateTime64(3)),
-- Aggregations -- DO NOT USE
`observation_ids` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
`score_ids` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
`cost_details` SimpleAggregateFunction(sumMap, Map(String, Decimal(38, 12))),
`usage_details` SimpleAggregateFunction(sumMap, Map(String, UInt64)),
-- Input/Output -> prefer correctness via argMax
`input` AggregateFunction(argMax, String, DateTime64(3)) CODEC (ZSTD(3)),
`output` AggregateFunction(argMax, String, DateTime64(3)) CODEC (ZSTD(3)),
`created_at` SimpleAggregateFunction(min, DateTime64(3)),
`updated_at` SimpleAggregateFunction(max, DateTime64(3)),
-- Indexes
INDEX idx_user_id user_id TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_session_id session_id TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_name name TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_version version TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_release release TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_tags tags TYPE bloom_filter(0.001) GRANULARITY 1
) Engine = AggregatingMergeTree()
ORDER BY (project_id, id)
TTL toDate(start_time) + INTERVAL 7 DAY;
-- Create materialized view for 7d_amt
CREATE MATERIALIZED VIEW IF NOT EXISTS traces_7d_amt_mv ON CLUSTER default TO traces_7d_amt AS
SELECT
-- Identifiers
tn.project_id as project_id,
tn.id as id,
min(tn.start_time) as timestamp, -- Backward compatibility: redundant with start_time
min(tn.start_time) as start_time,
max(coalesce(tn.end_time, tn.start_time)) as end_time,
anyLast(tn.name) as name,
-- Metadata properties
maxMap(tn.metadata) as metadata,
anyLast(tn.user_id) as user_id,
anyLast(tn.session_id) as session_id,
anyLast(tn.environment) as environment,
groupUniqArrayArray(tn.tags) as tags,
anyLast(tn.version) as version,
anyLast(tn.release) as release,
-- UI properties
argMaxState(tn.bookmarked, if(tn.bookmarked is not null, tn.event_ts, toDateTime64(0, 3))) as bookmarked,
argMaxState(tn.public, if(tn.public is not null, tn.event_ts, toDateTime64(0, 3))) as public,
-- Aggregations -- DO NOT USE
groupUniqArrayArray(tn.observation_ids) as observation_ids,
groupUniqArrayArray(tn.score_ids) as score_ids,
sumMap(tn.cost_details) as cost_details,
sumMap(tn.usage_details) as usage_details,
-- Input/Output
argMaxState(tn.input, if(tn.input <> '', tn.event_ts, toDateTime64(0, 3))) as input,
argMaxState(tn.output, if(tn.output <> '', tn.event_ts, toDateTime64(0, 3))) as output,
min(tn.created_at) as created_at,
max(tn.updated_at) as updated_at
FROM traces_null tn
GROUP BY project_id, id;
-- Create the 30-day TTL AMT
CREATE TABLE traces_30d_amt ON CLUSTER default
(
-- Identifiers
`project_id` String,
`id` String,
`timestamp` SimpleAggregateFunction(min, DateTime64(3)), -- Backward compatibility: redundant with start_time
`start_time` SimpleAggregateFunction(min, DateTime64(3)),
`end_time` SimpleAggregateFunction(max, Nullable(DateTime64(3))),
`name` SimpleAggregateFunction(anyLast, Nullable(String)),
-- Metadata properties
`metadata` SimpleAggregateFunction(maxMap, Map(String, String)),
`user_id` SimpleAggregateFunction(anyLast, Nullable(String)),
`session_id` SimpleAggregateFunction(anyLast, Nullable(String)),
`environment` SimpleAggregateFunction(anyLast, String),
`tags` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
`version` SimpleAggregateFunction(anyLast, Nullable(String)),
`release` SimpleAggregateFunction(anyLast, Nullable(String)),
-- UI properties
`bookmarked` AggregateFunction(argMax, Nullable(Bool), DateTime64(3)),
`public` AggregateFunction(argMax, Nullable(Bool), DateTime64(3)),
-- Aggregations -- DO NOT USE
`observation_ids` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
`score_ids` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
`cost_details` SimpleAggregateFunction(sumMap, Map(String, Decimal(38, 12))),
`usage_details` SimpleAggregateFunction(sumMap, Map(String, UInt64)),
-- Input/Output -> prefer correctness via argMax
`input` AggregateFunction(argMax, String, DateTime64(3)) CODEC (ZSTD(3)),
`output` AggregateFunction(argMax, String, DateTime64(3)) CODEC (ZSTD(3)),
`created_at` SimpleAggregateFunction(min, DateTime64(3)),
`updated_at` SimpleAggregateFunction(max, DateTime64(3)),
-- Indexes
INDEX idx_user_id user_id TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_session_id session_id TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_name name TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_version version TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_release release TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_tags tags TYPE bloom_filter(0.001) GRANULARITY 1
) Engine = AggregatingMergeTree()
ORDER BY (project_id, id)
TTL toDate(start_time) + INTERVAL 30 DAY;
-- Create materialized view for 30d_amt
CREATE MATERIALIZED VIEW IF NOT EXISTS traces_30d_amt_mv ON CLUSTER default TO traces_30d_amt AS
SELECT
-- Identifiers
tn.project_id as project_id,
tn.id as id,
min(tn.start_time) as timestamp, -- Backward compatibility: redundant with start_time
min(tn.start_time) as start_time,
max(coalesce(tn.end_time, tn.start_time)) as end_time,
anyLast(tn.name) as name,
-- Metadata properties
maxMap(tn.metadata) as metadata,
anyLast(tn.user_id) as user_id,
anyLast(tn.session_id) as session_id,
anyLast(tn.environment) as environment,
groupUniqArrayArray(tn.tags) as tags,
anyLast(tn.version) as version,
anyLast(tn.release) as release,
-- UI properties
argMaxState(tn.bookmarked, if(tn.bookmarked is not null, tn.event_ts, toDateTime64(0, 3))) as bookmarked,
argMaxState(tn.public, if(tn.public is not null, tn.event_ts, toDateTime64(0, 3))) as public,
-- Aggregations -- DO NOT USE
groupUniqArrayArray(tn.observation_ids) as observation_ids,
groupUniqArrayArray(tn.score_ids) as score_ids,
sumMap(tn.cost_details) as cost_details,
sumMap(tn.usage_details) as usage_details,
-- Input/Output
argMaxState(tn.input, if(tn.input <> '', tn.event_ts, toDateTime64(0, 3))) as input,
argMaxState(tn.output, if(tn.output <> '', tn.event_ts, toDateTime64(0, 3))) as output,
min(tn.created_at) as created_at,
max(tn.updated_at) as updated_at
FROM traces_null tn
GROUP BY project_id, id;
@@ -0,0 +1 @@
DROP TABLE dataset_run_items;
@@ -0,0 +1,36 @@
CREATE TABLE dataset_run_items (
-- primary identifiers
`id` String,
`project_id` String,
`dataset_run_id` String,
`dataset_item_id` String,
`dataset_id` String,
`trace_id` String,
`observation_id` Nullable(String),
-- error field
`error` Nullable(String),
-- timestamps
`created_at` DateTime64(3) DEFAULT now(),
`updated_at` DateTime64(3) DEFAULT now(),
-- denormalized immutable dataset run fields
`dataset_run_name` String,
`dataset_run_description` Nullable(String),
`dataset_run_metadata` Map(LowCardinality(String), String),
`dataset_run_created_at` DateTime64(3),
-- denormalized dataset item fields (mutable, but snapshots are relevant)
`dataset_item_input` Nullable(String) CODEC(ZSTD(3)), -- json
`dataset_item_expected_output` Nullable(String) CODEC(ZSTD(3)), -- json
`dataset_item_metadata` Map(LowCardinality(String), String),
-- clickhouse engine fields
`event_ts` DateTime64(3),
`is_deleted` UInt8,
-- For dataset item lookups
INDEX idx_dataset_item dataset_item_id TYPE bloom_filter(0.001) GRANULARITY 1,
) ENGINE = ReplacingMergeTree(event_ts, is_deleted)
ORDER BY (project_id, dataset_id, dataset_run_id, id);
@@ -0,0 +1,12 @@
-- Drop materialized views first
DROP VIEW IF EXISTS traces_30d_amt_mv;
DROP VIEW IF EXISTS traces_7d_amt_mv;
DROP VIEW IF EXISTS traces_all_amt_mv;
-- Drop AMT tables
DROP TABLE IF EXISTS traces_30d_amt;
DROP TABLE IF EXISTS traces_7d_amt;
DROP TABLE IF EXISTS traces_all_amt;
-- Drop the Null table
DROP TABLE IF EXISTS traces_null;
@@ -0,0 +1,300 @@
-- Create a Null table that serves as a trigger for all materialized views.
-- We use a Null engine here to avoid storing intermediate results and save on storage.
CREATE TABLE traces_null
(
-- Identifiers
`project_id` String,
`id` String,
`start_time` DateTime64(3),
`end_time` Nullable(DateTime64(3)),
`name` Nullable(String),
-- Metadata properties
`metadata` Map(LowCardinality(String), String),
`user_id` Nullable(String),
`session_id` Nullable(String),
`environment` String,
`tags` Array(String),
`version` Nullable(String),
`release` Nullable(String),
-- UI properties - We make them nullable to prevent absent values being interpreted as overwrites.
`bookmarked` Nullable(Bool),
`public` Nullable(Bool),
-- Aggregations
`observation_ids` Array(String),
`score_ids` Array(String),
`cost_details` Map(String, Decimal64(12)),
`usage_details` Map(String, UInt64),
-- TODO: Do we want to aggregate/collect `levels` seen within the trace?
-- Input/Output
`input` String,
`output` String,
`created_at` DateTime64(3),
`updated_at` DateTime64(3),
`event_ts` DateTime64(3)
) Engine = Null();
-- Create the all AMT
CREATE TABLE traces_all_amt
(
-- Identifiers
`project_id` String,
`id` String,
`timestamp` SimpleAggregateFunction(min, DateTime64(3)), -- Backward compatibility: redundant with start_time
`start_time` SimpleAggregateFunction(min, DateTime64(3)),
`end_time` SimpleAggregateFunction(max, Nullable(DateTime64(3))),
`name` SimpleAggregateFunction(anyLast, Nullable(String)),
-- Metadata properties
`metadata` SimpleAggregateFunction(maxMap, Map(String, String)),
`user_id` SimpleAggregateFunction(anyLast, Nullable(String)),
`session_id` SimpleAggregateFunction(anyLast, Nullable(String)),
`environment` SimpleAggregateFunction(anyLast, String),
`tags` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
`version` SimpleAggregateFunction(anyLast, Nullable(String)),
`release` SimpleAggregateFunction(anyLast, Nullable(String)),
-- UI properties
`bookmarked` AggregateFunction(argMax, Nullable(Bool), DateTime64(3)),
`public` AggregateFunction(argMax, Nullable(Bool), DateTime64(3)),
-- Aggregations
`observation_ids` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
`score_ids` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
`cost_details` SimpleAggregateFunction(sumMap, Map(String, Decimal(38, 12))),
`usage_details` SimpleAggregateFunction(sumMap, Map(String, UInt64)),
-- Input/Output -> prefer correctness via argMax
`input` AggregateFunction(argMax, String, DateTime64(3)) CODEC (ZSTD(3)),
`output` AggregateFunction(argMax, String, DateTime64(3)) CODEC (ZSTD(3)),
`created_at` SimpleAggregateFunction(min, DateTime64(3)),
`updated_at` SimpleAggregateFunction(max, DateTime64(3)),
-- Indexes
INDEX idx_trace_id id TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_user_id user_id TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_session_id session_id TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_name name TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_version version TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_release release TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_tags tags TYPE bloom_filter(0.001) GRANULARITY 1
) Engine = AggregatingMergeTree()
ORDER BY (project_id, id);
-- Create materialized view for all_amt
CREATE MATERIALIZED VIEW IF NOT EXISTS traces_all_amt_mv TO traces_all_amt AS
SELECT
-- Identifiers
tn.project_id as project_id,
tn.id as id,
min(tn.start_time) as timestamp, -- Backward compatibility: redundant with start_time
min(tn.start_time) as start_time,
max(coalesce(tn.end_time, tn.start_time)) as end_time,
anyLast(tn.name) as name,
-- Metadata properties
maxMap(tn.metadata) as metadata,
anyLast(tn.user_id) as user_id,
anyLast(tn.session_id) as session_id,
anyLast(tn.environment) as environment,
groupUniqArrayArray(tn.tags) as tags,
anyLast(tn.version) as version,
anyLast(tn.release) as release,
-- UI properties
argMaxState(tn.bookmarked, if(tn.bookmarked is not null, tn.event_ts, toDateTime64(0, 3))) as bookmarked,
argMaxState(tn.public, if(tn.public is not null, tn.event_ts, toDateTime64(0, 3))) as public,
-- Aggregations
groupUniqArrayArray(tn.observation_ids) as observation_ids,
groupUniqArrayArray(tn.score_ids) as score_ids,
sumMap(tn.cost_details) as cost_details,
sumMap(tn.usage_details) as usage_details,
-- Input/Output
argMaxState(tn.input, if(tn.input <> '', tn.event_ts, toDateTime64(0, 3))) as input,
argMaxState(tn.output, if(tn.output <> '', tn.event_ts, toDateTime64(0, 3))) as output,
min(tn.created_at) as created_at,
max(tn.updated_at) as updated_at
FROM traces_null tn
GROUP BY project_id, id;
-- Create the 7-day TTL AMT
CREATE TABLE traces_7d_amt
(
-- Identifiers
`project_id` String,
`id` String,
`timestamp` SimpleAggregateFunction(min, DateTime64(3)), -- Backward compatibility: redundant with start_time
`start_time` SimpleAggregateFunction(min, DateTime64(3)),
`end_time` SimpleAggregateFunction(max, Nullable(DateTime64(3))),
`name` SimpleAggregateFunction(anyLast, Nullable(String)),
-- Metadata properties
`metadata` SimpleAggregateFunction(maxMap, Map(String, String)),
`user_id` SimpleAggregateFunction(anyLast, Nullable(String)),
`session_id` SimpleAggregateFunction(anyLast, Nullable(String)),
`environment` SimpleAggregateFunction(anyLast, String),
`tags` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
`version` SimpleAggregateFunction(anyLast, Nullable(String)),
`release` SimpleAggregateFunction(anyLast, Nullable(String)),
-- UI properties
`bookmarked` AggregateFunction(argMax, Nullable(Bool), DateTime64(3)),
`public` AggregateFunction(argMax, Nullable(Bool), DateTime64(3)),
-- Aggregations
`observation_ids` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
`score_ids` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
`cost_details` SimpleAggregateFunction(sumMap, Map(String, Decimal(38, 12))),
`usage_details` SimpleAggregateFunction(sumMap, Map(String, UInt64)),
-- Input/Output -> prefer correctness via argMax
`input` AggregateFunction(argMax, String, DateTime64(3)) CODEC (ZSTD(3)),
`output` AggregateFunction(argMax, String, DateTime64(3)) CODEC (ZSTD(3)),
`created_at` SimpleAggregateFunction(min, DateTime64(3)),
`updated_at` SimpleAggregateFunction(max, DateTime64(3)),
-- Indexes
INDEX idx_user_id user_id TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_session_id session_id TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_name name TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_version version TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_release release TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_tags tags TYPE bloom_filter(0.001) GRANULARITY 1
) Engine = AggregatingMergeTree()
ORDER BY (project_id, id)
TTL toDate(start_time) + INTERVAL 7 DAY;
-- Create materialized view for 7d_amt
CREATE MATERIALIZED VIEW IF NOT EXISTS traces_7d_amt_mv TO traces_7d_amt AS
SELECT
-- Identifiers
tn.project_id as project_id,
tn.id as id,
min(tn.start_time) as timestamp, -- Backward compatibility: redundant with start_time
min(tn.start_time) as start_time,
max(coalesce(tn.end_time, tn.start_time)) as end_time,
anyLast(tn.name) as name,
-- Metadata properties
maxMap(tn.metadata) as metadata,
anyLast(tn.user_id) as user_id,
anyLast(tn.session_id) as session_id,
anyLast(tn.environment) as environment,
groupUniqArrayArray(tn.tags) as tags,
anyLast(tn.version) as version,
anyLast(tn.release) as release,
-- UI properties
argMaxState(tn.bookmarked, if(tn.bookmarked is not null, tn.event_ts, toDateTime64(0, 3))) as bookmarked,
argMaxState(tn.public, if(tn.public is not null, tn.event_ts, toDateTime64(0, 3))) as public,
-- Aggregations
groupUniqArrayArray(tn.observation_ids) as observation_ids,
groupUniqArrayArray(tn.score_ids) as score_ids,
sumMap(tn.cost_details) as cost_details,
sumMap(tn.usage_details) as usage_details,
-- Input/Output
argMaxState(tn.input, if(tn.input <> '', tn.event_ts, toDateTime64(0, 3))) as input,
argMaxState(tn.output, if(tn.output <> '', tn.event_ts, toDateTime64(0, 3))) as output,
min(tn.created_at) as created_at,
max(tn.updated_at) as updated_at
FROM traces_null tn
GROUP BY project_id, id;
-- Create the 30-day TTL AMT
CREATE TABLE traces_30d_amt
(
-- Identifiers
`project_id` String,
`id` String,
`timestamp` SimpleAggregateFunction(min, DateTime64(3)), -- Backward compatibility: redundant with start_time
`start_time` SimpleAggregateFunction(min, DateTime64(3)),
`end_time` SimpleAggregateFunction(max, Nullable(DateTime64(3))),
`name` SimpleAggregateFunction(anyLast, Nullable(String)),
-- Metadata properties
`metadata` SimpleAggregateFunction(maxMap, Map(String, String)),
`user_id` SimpleAggregateFunction(anyLast, Nullable(String)),
`session_id` SimpleAggregateFunction(anyLast, Nullable(String)),
`environment` SimpleAggregateFunction(anyLast, String),
`tags` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
`version` SimpleAggregateFunction(anyLast, Nullable(String)),
`release` SimpleAggregateFunction(anyLast, Nullable(String)),
-- UI properties
`bookmarked` AggregateFunction(argMax, Nullable(Bool), DateTime64(3)),
`public` AggregateFunction(argMax, Nullable(Bool), DateTime64(3)),
-- Aggregations
`observation_ids` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
`score_ids` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
`cost_details` SimpleAggregateFunction(sumMap, Map(String, Decimal(38, 12))),
`usage_details` SimpleAggregateFunction(sumMap, Map(String, UInt64)),
-- Input/Output -> prefer correctness via argMax
`input` AggregateFunction(argMax, String, DateTime64(3)) CODEC (ZSTD(3)),
`output` AggregateFunction(argMax, String, DateTime64(3)) CODEC (ZSTD(3)),
`created_at` SimpleAggregateFunction(min, DateTime64(3)),
`updated_at` SimpleAggregateFunction(max, DateTime64(3)),
-- Indexes
INDEX idx_user_id user_id TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_session_id session_id TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_name name TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_version version TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_release release TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_tags tags TYPE bloom_filter(0.001) GRANULARITY 1
) Engine = AggregatingMergeTree()
ORDER BY (project_id, id)
TTL toDate(start_time) + INTERVAL 30 DAY;
-- Create materialized view for 30d_amt
CREATE MATERIALIZED VIEW IF NOT EXISTS traces_30d_amt_mv TO traces_30d_amt AS
SELECT
-- Identifiers
tn.project_id as project_id,
tn.id as id,
min(tn.start_time) as timestamp, -- Backward compatibility: redundant with start_time
min(tn.start_time) as start_time,
max(coalesce(tn.end_time, tn.start_time)) as end_time,
anyLast(tn.name) as name,
-- Metadata properties
maxMap(tn.metadata) as metadata,
anyLast(tn.user_id) as user_id,
anyLast(tn.session_id) as session_id,
anyLast(tn.environment) as environment,
groupUniqArrayArray(tn.tags) as tags,
anyLast(tn.version) as version,
anyLast(tn.release) as release,
-- UI properties
argMaxState(tn.bookmarked, if(tn.bookmarked is not null, tn.event_ts, toDateTime64(0, 3))) as bookmarked,
argMaxState(tn.public, if(tn.public is not null, tn.event_ts, toDateTime64(0, 3))) as public,
-- Aggregations
groupUniqArrayArray(tn.observation_ids) as observation_ids,
groupUniqArrayArray(tn.score_ids) as score_ids,
sumMap(tn.cost_details) as cost_details,
sumMap(tn.usage_details) as usage_details,
-- Input/Output
argMaxState(tn.input, if(tn.input <> '', tn.event_ts, toDateTime64(0, 3))) as input,
argMaxState(tn.output, if(tn.output <> '', tn.event_ts, toDateTime64(0, 3))) as output,
min(tn.created_at) as created_at,
max(tn.updated_at) as updated_at
FROM traces_null tn
GROUP BY project_id, id;
+20 -1
View File
@@ -185,6 +185,14 @@ export type AnnotationQueue = {
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
};
export type AnnotationQueueAssignment = {
id: string;
project_id: string;
user_id: string;
queue_id: string;
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
};
export type AnnotationQueueItem = {
id: string;
queue_id: string;
@@ -628,6 +636,15 @@ export type OrganizationMembership = {
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
};
export type PendingDeletion = {
id: string;
project_id: string;
object: string;
object_id: string;
is_deleted: Generated<boolean>;
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
};
export type PosthogIntegration = {
project_id: string;
encrypted_posthog_api_key: string;
@@ -723,7 +740,7 @@ export type SlackIntegration = {
bot_token: string;
bot_user_id: string;
created_at: Generated<Timestamp>;
updated_at: Timestamp;
updated_at: Generated<Timestamp>;
};
export type SsoConfig = {
domain: string;
@@ -795,6 +812,7 @@ export type VerificationToken = {
export type DB = {
Account: Account;
actions: Action;
annotation_queue_assignments: AnnotationQueueAssignment;
annotation_queue_items: AnnotationQueueItem;
annotation_queues: AnnotationQueue;
api_keys: ApiKey;
@@ -827,6 +845,7 @@ export type DB = {
observations: LegacyPrismaObservation;
organization_memberships: OrganizationMembership;
organizations: Organization;
pending_deletions: PendingDeletion;
posthog_integrations: PosthogIntegration;
prices: Price;
project_memberships: ProjectMembership;
@@ -0,0 +1,2 @@
INSERT INTO background_migrations (id, name, script, args)
VALUES ('8d47f91b-3e5c-4a26-9f85-c12d6e4b9a3d', '20250731_1001_migrate_dataset_run_items_pg_to_ch', 'migrateDatasetRunItemsFromPostgresToClickhouse', '{}');
@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "pending_deletions" (
"id" TEXT NOT NULL,
"project_id" TEXT NOT NULL,
"object" TEXT NOT NULL,
"object_id" TEXT NOT NULL,
"is_deleted" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "pending_deletions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "pending_deletions_project_id_object_is_deleted_idx" ON "pending_deletions"("project_id", "object", "is_deleted");
-- CreateIndex
CREATE INDEX "pending_deletions_object_id_object_idx" ON "pending_deletions"("object_id", "object");
-- AddForeignKey
ALTER TABLE "pending_deletions" ADD CONSTRAINT "pending_deletions_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "annotation_queue_assignments" (
"id" TEXT NOT NULL,
"project_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"queue_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "annotation_queue_assignments_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "annotation_queue_assignments_project_id_queue_id_key" ON "annotation_queue_assignments"("project_id", "queue_id", "user_id");
-- AddForeignKey
ALTER TABLE "annotation_queue_assignments" ADD CONSTRAINT "annotation_queue_assignments_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "annotation_queue_assignments" ADD CONSTRAINT "annotation_queue_assignments_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "annotation_queue_assignments" ADD CONSTRAINT "annotation_queue_assignments_queue_id_fkey" FOREIGN KEY ("queue_id") REFERENCES "annotation_queues"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+117 -80
View File
@@ -65,29 +65,30 @@ model Session {
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime? @map("email_verified")
password String?
image String?
admin Boolean @default(false)
accounts Account[]
sessions Session[]
organizationMemberships OrganizationMembership[]
projectMemberships ProjectMembership[]
invitations MembershipInvitation[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
featureFlags String[] @default([]) @map("feature_flags")
annotatedLockedItem AnnotationQueueItem[] @relation("LockedByUser")
annotatedCompletedItem AnnotationQueueItem[] @relation("AnnotatorUser")
dashboardWidgetsCreated DashboardWidget[] @relation("CreatedByUser")
dashboardWidgetsUpdated DashboardWidget[] @relation("UpdatedByUser")
dashboardCreated Dashboard[] @relation("CreatedByUser")
dashboardUpdated Dashboard[] @relation("UpdatedByUser")
tableViewPresetCreated TableViewPreset[] @relation("CreatedByUser")
tableViewPresetUpdated TableViewPreset[] @relation("UpdatedByUser")
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime? @map("email_verified")
password String?
image String?
admin Boolean @default(false)
accounts Account[]
sessions Session[]
organizationMemberships OrganizationMembership[]
projectMemberships ProjectMembership[]
invitations MembershipInvitation[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
featureFlags String[] @default([]) @map("feature_flags")
annotatedLockedItem AnnotationQueueItem[] @relation("LockedByUser")
annotatedCompletedItem AnnotationQueueItem[] @relation("AnnotatorUser")
dashboardWidgetsCreated DashboardWidget[] @relation("CreatedByUser")
dashboardWidgetsUpdated DashboardWidget[] @relation("UpdatedByUser")
dashboardCreated Dashboard[] @relation("CreatedByUser")
dashboardUpdated Dashboard[] @relation("UpdatedByUser")
tableViewPresetCreated TableViewPreset[] @relation("CreatedByUser")
tableViewPresetUpdated TableViewPreset[] @relation("UpdatedByUser")
annotationQueueAssignment AnnotationQueueAssignment[]
@@map("users")
}
@@ -117,53 +118,55 @@ 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")
metadata Json?
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[]
LlmSchema LlmSchema[]
LlmTool LlmTool[]
PromptProtectedLabels PromptProtectedLabels[]
Dashboard Dashboard[]
DashboardWidget DashboardWidget[]
TableViewPreset TableViewPreset[]
actions Action[]
triggers Trigger[]
automationExecutions AutomationExecution[]
Automation Automation[]
DefaultLlmModel DefaultLlmModel[]
Price Price[]
SlackIntegration SlackIntegration?
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")
metadata Json?
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[]
LlmSchema LlmSchema[]
LlmTool LlmTool[]
PromptProtectedLabels PromptProtectedLabels[]
Dashboard Dashboard[]
DashboardWidget DashboardWidget[]
TableViewPreset TableViewPreset[]
actions Action[]
triggers Trigger[]
automationExecutions AutomationExecution[]
Automation Automation[]
DefaultLlmModel DefaultLlmModel[]
Price Price[]
SlackIntegration SlackIntegration?
PendingDeletion PendingDeletion[]
AnnotationQueueAssignment AnnotationQueueAssignment[]
@@index([orgId])
@@map("projects")
@@ -490,15 +493,16 @@ enum ScoreDataType {
}
model AnnotationQueue {
id String @id @default(cuid())
name String
description String?
scoreConfigIds String[] @default([]) @map("score_config_ids")
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
annotationQueueItem AnnotationQueueItem[]
id String @id @default(cuid())
name String
description String?
scoreConfigIds String[] @default([]) @map("score_config_ids")
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
annotationQueueItem AnnotationQueueItem[]
annotationQueueAssignment AnnotationQueueAssignment[]
@@unique([projectId, name])
@@index([id, projectId])
@@ -543,6 +547,21 @@ enum AnnotationQueueObjectType {
SESSION
}
model AnnotationQueueAssignment {
id String @id @default(cuid())
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
userId String @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
queueId String @map("queue_id")
queue AnnotationQueue @relation(fields: [queueId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
@@unique([projectId, queueId, userId])
@@map("annotation_queue_assignments")
}
model CronJobs {
name String @id
lastRun DateTime? @map("last_run")
@@ -1355,8 +1374,26 @@ model SlackIntegration {
botUserId String @map("bot_user_id") // Bot user ID for workspace
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
@@index([teamId])
@@map("slack_integrations")
}
// Pending Deletions: Tracks objects (like traces) that are scheduled for batch deletion
model PendingDeletion {
id String @id @default(cuid())
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
object String @map("object") // e.g., "trace", "observation", etc.
objectId String @map("object_id") // The ID of the object to be deleted
isDeleted Boolean @default(false) @map("is_deleted")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
@@index([projectId, object, isDeleted])
@@index([objectId, object])
@@map("pending_deletions")
}
+26
View File
@@ -15,6 +15,7 @@ const EnvSchema = z.object({
.default(6379)
.nullable(),
REDIS_AUTH: z.string().nullish(),
REDIS_USERNAME: z.string().nullish(),
REDIS_CONNECTION_STRING: z.string().nullish(),
REDIS_KEY_PREFIX: z.string().nullish(),
REDIS_TLS_ENABLED: z.enum(["true", "false"]).default("false"),
@@ -49,6 +50,10 @@ const EnvSchema = z.object({
.nonnegative()
.default(15_000),
LANGFUSE_INGESTION_QUEUE_SHARD_COUNT: z.coerce.number().positive().default(1),
LANGFUSE_TRACE_DELETE_DELAY_MS: z.coerce
.number()
.nonnegative()
.default(5_000),
SALT: z.string().optional(), // used by components imported by web package
LANGFUSE_LOG_LEVEL: z
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
@@ -87,6 +92,9 @@ const EnvSchema = z.object({
LANGFUSE_S3_MEDIA_UPLOAD_SSE: z.enum(["AES256", "aws:kms"]).optional(),
LANGFUSE_S3_MEDIA_UPLOAD_SSE_KMS_KEY_ID: z.string().optional(),
LANGFUSE_USE_AZURE_BLOB: z.enum(["true", "false"]).default("false"),
LANGFUSE_AZURE_SKIP_CONTAINER_CHECK: z
.enum(["true", "false"])
.default("true"),
LANGFUSE_USE_GOOGLE_CLOUD_STORAGE: z.enum(["true", "false"]).default("false"),
LANGFUSE_GOOGLE_CLOUD_STORAGE_CREDENTIALS: z.string().optional(),
STRIPE_SECRET_KEY: z.string().optional(),
@@ -140,6 +148,12 @@ const EnvSchema = z.object({
LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT: z
.enum(["true", "false"])
.default("false"),
LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT_SHORT_TERM: z
.enum(["true", "false"])
.default("false"),
LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES: z
.enum(["true", "false"])
.default("false"),
LANGFUSE_INGESTION_PROCESSING_SAMPLED_PROJECTS: z
.string()
.optional()
@@ -176,6 +190,18 @@ const EnvSchema = z.object({
SLACK_CLIENT_ID: z.string().optional(),
SLACK_CLIENT_SECRET: z.string().optional(),
SLACK_STATE_SECRET: z.string().optional(),
LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT: z.coerce
.number()
.int()
.positive()
.default(1_000),
LANGFUSE_CLICKHOUSE_DATA_EXPORT_REQUEST_TIMEOUT_MS: z.coerce
.number()
.int()
.positive()
.default(600_000), // 10 minutes
});
export const env: z.infer<typeof EnvSchema> =
@@ -91,4 +91,11 @@ export const CreateQueueData = z.object({
}),
});
export const CreateQueueWithAssignmentsData = CreateQueueData.extend({
newAssignmentUserIds: z.array(z.string()),
});
export type CreateQueue = z.infer<typeof CreateQueueData>;
export type CreateQueueWithAssignments = z.infer<
typeof CreateQueueWithAssignmentsData
>;
@@ -15,6 +15,7 @@ const ActionIdSchema = z.enum([
"trace-delete",
"trace-add-to-annotation-queue",
"session-add-to-annotation-queue",
"observation-add-to-annotation-queue",
]);
export type ActionId = z.infer<typeof ActionIdSchema>;
@@ -1,4 +1,4 @@
import { instrumentAsync } from "../instrumentation";
import { instrumentAsync, recordDistribution } from "../instrumentation";
import * as opentelemetry from "@opentelemetry/api";
import { env } from "../../env";
import { logger } from "../logger";
@@ -19,12 +19,17 @@ const executionWrapper = async <T, Y>(
return [res, duration];
};
/**
* Measures the execution time of two functions and returns the result based on the experiment configuration.
* This is used to compare the execution of AggregatingMergeTrees with the existing ReplacingMergeTree execution.
*/
export const measureAndReturn = async <T, Y>(args: {
operationName: string;
projectId: string;
input: T;
existingExecution: (input: T) => Promise<Y>; // eslint-disable-line no-unused-vars
newExecution: (input: T) => Promise<Y>; // eslint-disable-line no-unused-vars
minStartTime?: Date;
}): Promise<Y> => {
return instrumentAsync(
{
@@ -32,14 +37,34 @@ export const measureAndReturn = async <T, Y>(args: {
spanKind: opentelemetry.SpanKind.CLIENT,
},
async (currentSpan) => {
const { input, existingExecution, newExecution } = args;
const { input, existingExecution, newExecution, minStartTime } = args;
if (
env.LANGFUSE_EXPERIMENT_COMPARE_READ_FROM_AGGREGATING_MERGE_TREES !==
"true"
) {
currentSpan.setAttribute(`langfuse.experiment.amts.run`, "disabled");
return existingExecution(input);
// Check for short-term new result experiment
if (
env.LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT_SHORT_TERM === "true" &&
minStartTime
) {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
if (minStartTime >= thirtyDaysAgo) {
currentSpan.setAttribute(
`langfuse.experiment.amts.short-term`,
"true",
);
return newExecution(input);
}
}
return env.LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT === "true"
? newExecution(input)
: existingExecution(input);
}
// If not whitelisted, apply sampling logic
@@ -68,6 +93,14 @@ export const measureAndReturn = async <T, Y>(args: {
durationDifference,
);
recordDistribution(
"langfuse.experiment.amts.duration_difference_distribution",
durationDifference,
{
operation: args.operationName,
},
);
if (
env.LANGFUSE_EXPERIMENT_ADD_QUERY_RESULT_TO_SPAN_PROJECT_IDS.some(
(p) => p === args.projectId,
@@ -83,6 +116,23 @@ export const measureAndReturn = async <T, Y>(args: {
);
}
// Check for short-term new result experiment
if (
env.LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT_SHORT_TERM === "true" &&
minStartTime
) {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
if (minStartTime >= thirtyDaysAgo) {
currentSpan.setAttribute(
`langfuse.experiment.amts.short-term`,
"true",
);
return newResult;
}
}
return env.LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT === "true"
? newResult
: existingResult;
+2
View File
@@ -58,6 +58,7 @@ export * from "./logger";
export * from "./headerPropagation";
export * from "./queries";
export * from "./repositories";
export * from "./utils/rendering";
export * from "./redis/evalExecutionQueue";
export * from "./services/sessions-ui-table-service";
export * from "./services/datasets-ui-table-service";
@@ -78,3 +79,4 @@ export * from "./dataset-run-items/addToDeleteQueue";
// test utils
export * from "./test-utils";
export * from "./utils/headerUtils";
export * from "./traceDeletionProcessor";
@@ -316,22 +316,6 @@ 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;
+1 -4
View File
@@ -236,7 +236,6 @@ export type PromptVariable = { name: string; value: string; isUsed: boolean };
export enum LLMAdapter {
Anthropic = "anthropic",
OpenAI = "openai",
Atla = "atla",
Azure = "azure",
Bedrock = "bedrock",
VertexAI = "google-vertex-ai",
@@ -332,6 +331,7 @@ export type OpenAIModel = (typeof openAIModels)[number];
// WARNING: The first entry in the array is chosen as the default model to add LLM API keys
export const anthropicModels = [
"claude-sonnet-4-20250514",
"claude-opus-4-1-20250805",
"claude-opus-4-20250514",
"claude-3-7-sonnet-20250219",
"claude-3-5-sonnet-20241022",
@@ -378,8 +378,6 @@ 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 = {
@@ -389,7 +387,6 @@ export const supportedModels = {
[LLMAdapter.GoogleAIStudio]: googleAIStudioModels,
[LLMAdapter.Azure]: [],
[LLMAdapter.Bedrock]: [],
[LLMAdapter.Atla]: atlaModels,
} as const;
export type LLMFunctionCall = {
@@ -1,88 +0,0 @@
import { z } from "zod/v4";
import { Prisma } from "@prisma/client";
import { tableColumnsToSqlFilterAndPrefix } from "../filterToPrisma";
import { singleFilter } from "../../interfaces/filters";
import { orderBy } from "../../interfaces/orderBy";
import { orderByToPrismaSql } from "../orderByToPrisma";
import { sessionsViewCols } from "../../tableDefinitions";
const GetSessionTableSQLParamsSchema = z.object({
projectId: z.string(),
filter: z.array(singleFilter).nullable(),
orderBy: orderBy,
page: z.number(),
limit: z.number(),
});
type GetSessionTableSQLParams = z.infer<typeof GetSessionTableSQLParamsSchema>;
export const createSessionsAllQuery = (
select: Prisma.Sql,
params: GetSessionTableSQLParams,
options?: {
ignoreOrderBy?: boolean; // used by session.metrics and session.all.totalCount
sessionIdList?: string[]; // used by session.metrics
},
): Prisma.Sql => {
const { projectId, filter, orderBy, page, limit } =
GetSessionTableSQLParamsSchema.parse(params);
const filterCondition = tableColumnsToSqlFilterAndPrefix(
filter ?? [],
sessionsViewCols,
"sessions",
);
const orderByCondition = orderByToPrismaSql(orderBy, sessionsViewCols);
const sessionIdFilter = options?.sessionIdList
? Prisma.sql`AND s.id IN (${Prisma.join(options?.sessionIdList)})`
: Prisma.sql``;
const sql = Prisma.sql`
SELECT
${select}
FROM
trace_sessions AS s
LEFT JOIN LATERAL (
SELECT
t.session_id,
MAX(t. "timestamp") AS "max_timestamp",
MIN(t. "timestamp") AS "min_timestamp",
array_agg(t.id) AS "traceIds",
array_agg(DISTINCT t.user_id) AS "userIds",
count(t.id)::int AS "countTraces",
array_agg(DISTINCT u.tag) AS "tags"
FROM
traces t
LEFT JOIN LATERAL (
SELECT DISTINCT UNNEST(t.tags) AS tag) AS u ON TRUE
WHERE
t.project_id = ${projectId}
AND t.session_id = s.id
GROUP BY
t.session_id) AS t ON TRUE
LEFT JOIN LATERAL (
SELECT
EXTRACT(EPOCH FROM COALESCE(MAX(o. "end_time"), MAX(o. "start_time"), t. "max_timestamp")) - EXTRACT(EPOCH FROM COALESCE(MIN(o. "start_time"), t. "min_timestamp"))::double precision AS "sessionDuration",
SUM(COALESCE(o. "calculated_input_cost", 0)) AS "inputCost",
SUM(COALESCE(o. "calculated_output_cost", 0)) AS "outputCost",
SUM(COALESCE(o. "calculated_total_cost", 0)) AS "totalCost",
SUM(o.prompt_tokens) AS "promptTokens",
SUM(o.completion_tokens) AS "completionTokens",
SUM(o.total_tokens) AS "totalTokens"
FROM
observations_view o
WHERE
o.project_id = ${projectId}
AND o.trace_id = ANY (t. "traceIds")) AS o ON TRUE
WHERE
s. "project_id" = ${projectId}
${filterCondition}
${sessionIdFilter}
${options?.ignoreOrderBy ? Prisma.sql`` : orderByCondition}
LIMIT ${limit}
OFFSET ${page * limit}
`;
return sql;
};
@@ -1,4 +1,3 @@
export { createSessionsAllQuery } from "./createSessionsAllQuery";
export {
type FullObservations,
type FullObservationsWithScores,
+9
View File
@@ -125,6 +125,15 @@ export const BatchActionProcessingEventSchema = z.discriminatedUnion(
targetId: z.string().optional(),
type: z.enum(BatchActionType),
}),
z.object({
actionId: z.literal("observation-add-to-annotation-queue"),
projectId: z.string(),
query: BatchActionQuerySchema,
tableName: z.enum(BatchTableNames),
cutoffCreatedAt: z.date(),
targetId: z.string().optional(),
type: z.enum(BatchActionType),
}),
z.object({
actionId: z.literal("eval-create"),
targetObject: z.enum(["trace", "dataset"]),
@@ -77,6 +77,7 @@ const createRedisClusterInstance = (
callback(null, address);
},
redisOptions: {
username: env.REDIS_USERNAME || undefined,
password: env.REDIS_AUTH || undefined,
...defaultRedisOptions,
...additionalOptions,
@@ -129,6 +130,7 @@ export const createNewRedisInstance = (
? new Redis({
host: String(env.REDIS_HOST),
port: Number(env.REDIS_PORT),
username: env.REDIS_USERNAME || undefined,
password: String(env.REDIS_AUTH),
...defaultRedisOptions,
...additionalOptions,
@@ -36,7 +36,7 @@ const getS3StorageServiceClient = (bucketName: string): StorageService => {
export async function upsertClickhouse<
T extends Record<string, unknown>,
>(opts: {
table: "scores" | "traces" | "observations";
table: "scores" | "traces" | "observations" | "traces_null";
records: T[];
eventBodyMapper: (body: T) => Record<string, unknown>; // eslint-disable-line no-unused-vars
tags?: Record<string, string>;
@@ -8,21 +8,69 @@ import {
orderByToClickhouseSql,
StringFilter,
} from "../queries";
import { queryClickhouse } from "./clickhouse";
import {
parseClickhouseUTCDateTimeFormat,
queryClickhouse,
} from "./clickhouse";
import { convertDatasetRunItemClickhouseToDomain } from "./dataset-run-items-converters";
import { DatasetRunItemRecordReadType } from "./definitions";
import { env } from "../../env";
import { commandClickhouse } from "./clickhouse";
import Decimal from "decimal.js";
type DatasetRunItemsTableQuery = {
projectId: string;
datasetId: string;
filter: FilterState;
orderBy?: OrderByState | OrderByState[];
limit?: number;
offset?: number;
};
type DatasetRunsMetricsTableQuery = {
projectId: string;
datasetId: string;
orderBy?: OrderByState;
limit?: number;
offset?: number;
};
export type DatasetRunsMetrics = {
id: string;
projectId: string;
createdAt: Date;
datasetId: string;
countRunItems: number;
avgTotalCost: Decimal;
avgLatency: number;
};
type DatasetRunsMetricsRecordType = {
dataset_run_id: string;
project_id: string;
dataset_run_created_at: string;
dataset_id: string;
count_run_items: number;
avg_latency_seconds: number;
avg_total_cost: number;
};
const convertDatasetRunsMetricsRecord = (
record: DatasetRunsMetricsRecordType,
): DatasetRunsMetrics => {
return {
id: record.dataset_run_id,
projectId: record.project_id,
createdAt: parseClickhouseUTCDateTimeFormat(record.dataset_run_created_at),
datasetId: record.dataset_id,
countRunItems: record.count_run_items,
avgTotalCost: record.avg_total_cost
? new Decimal(record.avg_total_cost)
: new Decimal(0),
avgLatency: record.avg_latency_seconds ?? 0,
};
};
const getProjectDatasetIdDefaultFilter = (
projectId: string,
datasetId: string,
@@ -45,6 +93,148 @@ const getProjectDatasetIdDefaultFilter = (
};
};
const getDatasetRunsTableInternal = async <T>(
opts: DatasetRunsMetricsTableQuery & {
tags: Record<string, string>;
},
): Promise<Array<T>> => {
const { projectId, datasetId, orderBy, limit, offset } = opts;
const { datasetRunItemsFilter } = getProjectDatasetIdDefaultFilter(
projectId,
datasetId,
);
const appliedFilter = datasetRunItemsFilter.apply();
// Build ORDER BY array - conditionally add event_ts DESC for rows
const orderByArray: OrderByState[] = [];
// Add user ordering if provided
if (orderBy) {
orderByArray.push(orderBy);
}
const orderByClause = orderByToClickhouseSql(
orderByArray,
datasetRunItemsTableUiColumnDefinitions,
);
const query = `
WITH observations_filtered AS (
SELECT
o.id,
o.trace_id,
o.project_id,
o.start_time,
o.end_time,
o.total_cost
FROM observations o FINAL
WHERE o.project_id = {projectId: String}
AND o.start_time >= (
SELECT min(dri.dataset_run_created_at) - INTERVAL 1 DAY
FROM dataset_run_items dri
WHERE dri.project_id = {projectId: String}
AND dri.dataset_id = {datasetId: String}
)
AND o.start_time <= (
SELECT max(dri.dataset_run_created_at) + INTERVAL 1 DAY
FROM dataset_run_items dri
WHERE dri.project_id = {projectId: String}
AND dri.dataset_id = {datasetId: String}
)
),
traces_aggregated AS (
SELECT
of.trace_id,
of.project_id,
dateDiff('millisecond', min(of.start_time), max(of.end_time)) as latency_ms,
sum(of.total_cost) as total_cost
FROM observations_filtered of
JOIN dataset_run_items dri ON dri.trace_id = of.trace_id
AND dri.project_id = of.project_id
AND dri.observation_id IS NULL -- Only for trace-level dataset run items
WHERE dri.dataset_id = {datasetId: String}
GROUP BY of.trace_id, of.project_id
),
observations_direct AS (
SELECT
dri.observation_id,
dri.project_id,
dri.trace_id,
of.total_cost,
dateDiff('millisecond', of.start_time, of.end_time) as latency_ms
FROM dataset_run_items dri
JOIN observations_filtered of ON dri.observation_id = of.id
AND dri.project_id = of.project_id
AND dri.trace_id = of.trace_id
WHERE dri.dataset_id = {datasetId: String}
AND dri.observation_id IS NOT NULL -- Only for observation-level dataset run items
)
SELECT DISTINCT
dri.dataset_run_id as dataset_run_id,
dri.project_id as project_id,
dri.dataset_id as dataset_id,
dri.dataset_run_created_at as dataset_run_created_at,
count(DISTINCT dri.project_id, dri.dataset_id, dri.dataset_run_id, dri.dataset_item_id) as count_run_items,
-- Latency metrics (priority: observation > trace)
AVG(CASE
WHEN dri.observation_id IS NOT NULL AND od.latency_ms IS NOT NULL
THEN od.latency_ms / 1000.0
ELSE COALESCE(ta.latency_ms / 1000.0, 0)
END) as avg_latency_seconds,
-- Cost metrics (priority: observation > trace)
AVG(CASE
WHEN dri.observation_id IS NOT NULL AND od.total_cost IS NOT NULL
THEN od.total_cost
ELSE COALESCE(ta.total_cost, 0)
END) as avg_total_cost
FROM dataset_run_items dri
LEFT JOIN traces_aggregated ta
ON dri.trace_id = ta.trace_id
AND dri.project_id = ta.project_id
LEFT JOIN observations_direct od
ON dri.observation_id = od.observation_id
AND dri.project_id = od.project_id
AND dri.trace_id = od.trace_id
WHERE ${appliedFilter.query}
GROUP BY dri.project_id, dri.dataset_id, dri.dataset_run_id, dri.dataset_run_created_at
ORDER BY dri.dataset_run_created_at DESC
${orderByClause}
${limit !== undefined && offset !== undefined ? `LIMIT ${limit} OFFSET ${offset}` : ""};`;
const res = await queryClickhouse<T>({
query,
params: {
projectId,
datasetId,
...appliedFilter.params,
},
tags: {
...(opts.tags ?? {}),
feature: "datasets",
type: "dataset-run-items",
projectId,
datasetId,
},
});
return res;
};
export const getDatasetRunsTableMetricsCh = async (
opts: DatasetRunsMetricsTableQuery,
): Promise<DatasetRunsMetrics[]> => {
// First get the metrics (latency, cost, counts)
const rows = await getDatasetRunsTableInternal<DatasetRunsMetricsRecordType>({
...opts,
tags: { kind: "list" },
});
return rows.map(convertDatasetRunsMetricsRecord);
};
const getDatasetRunItemsTableInternal = async <T>(
opts: DatasetRunItemsTableQuery & {
select: "count" | "rows";
@@ -104,7 +294,11 @@ const getDatasetRunItemsTableInternal = async <T>(
// Add user ordering if provided
if (orderBy) {
orderByArray.push(orderBy);
if (Array.isArray(orderBy)) {
orderByArray.push(...orderBy);
} else {
orderByArray.push(orderBy);
}
}
// Add event_ts DESC for row queries (for deduplication)
@@ -170,6 +364,7 @@ export const getDatasetRunItemsCountByDatasetIdCh = async (
return Number(rows[0]?.count);
};
export const deleteDatasetRunItemsByProjectId = async ({
projectId,
}: {
@@ -122,7 +122,7 @@ export const traceRecordInsertSchema = traceRecordBaseSchema.extend({
});
export type TraceRecordInsertType = z.infer<typeof traceRecordInsertSchema>;
export const traceMtRecordInsertSchema = z.object({
export const traceNullRecordInsertSchema = z.object({
// Identifiers
project_id: z.string(),
id: z.string(),
@@ -157,7 +157,9 @@ export const traceMtRecordInsertSchema = z.object({
updated_at: z.number(),
event_ts: z.number(),
});
export type TraceMtRecordInsertType = z.infer<typeof traceMtRecordInsertSchema>;
export type TraceNullRecordInsertType = z.infer<
typeof traceNullRecordInsertSchema
>;
export const scoreRecordBaseSchema = z.object({
id: z.string(),
@@ -495,9 +497,9 @@ export const convertPostgresScoreToInsert = (
};
};
export const convertTraceToTraceMt = (
export const convertTraceToTraceNull = (
traceRecord: TraceRecordInsertType,
): TraceMtRecordInsertType => {
): TraceNullRecordInsertType => {
return {
// Identifiers
project_id: traceRecord.project_id,
@@ -535,13 +537,13 @@ export const convertTraceToTraceMt = (
};
};
export const convertObservationToTraceMt = (
export const convertObservationToTraceNull = (
observationRecord: ObservationRecordInsertType,
): TraceMtRecordInsertType => {
): TraceNullRecordInsertType => {
return {
// Identifiers
project_id: observationRecord.project_id,
// Use trace_id as the id in traces_mt. Always set given the conditions around calling the function
// Use trace_id as the id in traces_null. Always set given the conditions around calling the function
id: observationRecord.trace_id || "",
start_time: observationRecord.start_time,
end_time: observationRecord.end_time || null,
@@ -579,13 +581,13 @@ export const convertObservationToTraceMt = (
};
};
export const convertScoreToTraceMt = (
export const convertScoreToTraceNull = (
scoreRecord: ScoreRecordInsertType,
): TraceMtRecordInsertType => {
): TraceNullRecordInsertType => {
return {
// Identifiers
project_id: scoreRecord.project_id,
// Use trace_id as the id in traces_mt. Always set given the conditions around calling the function
// Use trace_id as the id in traces_null. Always set given the conditions around calling the function
id: scoreRecord.trace_id || "",
start_time: scoreRecord.timestamp,
end_time: null, // scores don't have end_time
@@ -23,7 +23,8 @@ import {
observationsTableUiColumnDefinitions,
} from "../../tableDefinitions";
import { OrderByState } from "../../interfaces/orderBy";
import { getTracesByIds } from "./traces";
import { getTimeframesTracesAMT, getTracesByIds } from "./traces";
import { measureAndReturn } from "../clickhouse/measureAndReturn";
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
import { convertObservation } from "./observations_converters";
import { clickhouseSearchCondition } from "../queries/clickhouse-sql/search";
@@ -36,6 +37,7 @@ import { TracingSearchType } from "../../interfaces/search";
import { ClickHouseClientConfigOptions } from "@clickhouse/client";
import { ObservationType } from "../../domain";
import { recordDistribution } from "../instrumentation";
import { DEFAULT_RENDERING_PROPS, RenderingProps } from "../utils/rendering";
/**
* Checks if observation exists in clickhouse.
@@ -288,7 +290,7 @@ export const getObservationForTraceIdByName = async (
},
});
return records.map(convertObservation);
return records.map((record) => convertObservation(record));
};
export const getObservationById = async ({
@@ -298,6 +300,7 @@ export const getObservationById = async ({
startTime,
type,
traceId,
renderingProps = DEFAULT_RENDERING_PROPS,
}: {
id: string;
projectId: string;
@@ -305,6 +308,7 @@ export const getObservationById = async ({
startTime?: Date;
type?: ObservationType;
traceId?: string;
renderingProps?: RenderingProps;
}) => {
const records = await getObservationByIdInternal({
id,
@@ -313,8 +317,11 @@ export const getObservationById = async ({
startTime,
type,
traceId,
renderingProps,
});
const mapped = records.map(convertObservation);
const mapped = records.map((record) =>
convertObservation(record, renderingProps),
);
mapped.forEach((observation) => {
recordDistribution(
@@ -384,7 +391,7 @@ export const getObservationsById = async (
query,
params: { ids, projectId },
});
return records.map(convertObservation);
return records.map((record) => convertObservation(record));
};
const getObservationByIdInternal = async ({
@@ -394,6 +401,7 @@ const getObservationByIdInternal = async ({
startTime,
type,
traceId,
renderingProps = DEFAULT_RENDERING_PROPS,
}: {
id: string;
projectId: string;
@@ -401,6 +409,7 @@ const getObservationByIdInternal = async ({
startTime?: Date;
type?: ObservationType;
traceId?: string;
renderingProps?: RenderingProps;
}) => {
const query = `
SELECT
@@ -417,7 +426,7 @@ const getObservationByIdInternal = async ({
level,
status_message,
version,
${fetchWithInputOutput ? "input, output," : ""}
${fetchWithInputOutput ? (renderingProps.truncated ? `left(input, ${env.LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT}) as input, left(output, ${env.LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT}) as output,` : "input, output,") : ""}
provided_model_name,
internal_model_id,
model_parameters,
@@ -612,12 +621,14 @@ const getObservationsTableInternal = async <T>(
} = opts;
const selectString = selectIOAndMetadata
? `
${select},
${selectIOAndMetadata ? `o.input, o.output, o.metadata` : ""}
`
? `${select}, o.input, o.output, o.metadata`
: select;
const timeFilter = filter.find(
(f) =>
f.column === "Start Time" && (f.operator === ">=" || f.operator === ">"),
);
const scoresFilter = new FilterList([
new StringFilter({
clickhouseTable: "scores",
@@ -627,33 +638,22 @@ const getObservationsTableInternal = async <T>(
}),
]);
const timeFilter = opts.filter.find(
(f) =>
f.column === "Start Time" && (f.operator === ">=" || f.operator === ">"),
);
// query optimisation: joining traces onto observations is expensive. Hence, only join if the UI table contains filters on traces.
const traceTableFilter = opts.filter.filter(
(f) =>
observationsTableTraceUiColumnDefinitions
.map((c) => c.uiTableId)
.includes(f.column) ||
observationsTableTraceUiColumnDefinitions
.map((c) => c.uiTableName)
.includes(f.column),
);
const hasScoresFilter = filter.some((f) =>
f.column.toLowerCase().includes("scores"),
);
const orderByTraces = opts.orderBy
? observationsTableTraceUiColumnDefinitions
.map((c) => c.uiTableId)
.includes(opts.orderBy.column) ||
observationsTableTraceUiColumnDefinitions
.map((c) => c.uiTableName)
.includes(opts.orderBy.column)
// query optimisation: joining traces onto observations is expensive. Hence, only join if the UI table contains filters on traces.
const traceTableFilter = filter.filter((f) =>
observationsTableTraceUiColumnDefinitions.some(
(c) => c.uiTableId === f.column || c.uiTableName === f.column,
),
);
const orderByTraces = orderBy
? observationsTableTraceUiColumnDefinitions.some(
(c) =>
c.uiTableId === orderBy.column || c.uiTableName === orderBy.column,
)
: undefined;
timeFilter
@@ -759,7 +759,7 @@ const getObservationsTableInternal = async <T>(
SELECT
${selectString}
FROM observations o
${traceTableFilter.length > 0 || orderByTraces || search.query ? "LEFT JOIN traces t FINAL ON t.id = o.trace_id AND t.project_id = o.project_id" : ""}
${traceTableFilter.length > 0 || orderByTraces || search.query ? "LEFT JOIN __TRACE_TABLE__ t FINAL ON t.id = o.trace_id AND t.project_id = o.project_id" : ""}
${hasScoresFilter ? `LEFT JOIN scores_agg AS s ON s.trace_id = o.trace_id and s.observation_id = o.id` : ""}
WHERE ${appliedObservationsFilter.query}
@@ -769,30 +769,52 @@ const getObservationsTableInternal = async <T>(
${opts.select === "rows" ? "LIMIT 1 BY o.id, o.project_id" : ""}
${limit !== undefined && offset !== undefined ? `LIMIT ${limit} OFFSET ${offset}` : ""};`;
const res = await queryClickhouse<T>({
query,
params: {
...appliedScoresFilter.params,
...appliedObservationsFilter.params,
...(timeFilter
? {
tracesTimestampFilter: convertDateToClickhouseDateTime(
timeFilter.value as Date,
),
}
: {}),
...search.params,
return measureAndReturn({
operationName: "getObservationsTableInternal",
projectId,
minStartTime: (timeFilter?.value as Date) || undefined,
input: {
params: {
...appliedScoresFilter.params,
...appliedObservationsFilter.params,
...(timeFilter
? {
tracesTimestampFilter: convertDateToClickhouseDateTime(
timeFilter.value as Date,
),
}
: {}),
...search.params,
},
tags: {
...(opts.tags ?? {}),
feature: "tracing",
type: "observation",
projectId,
kind: opts.select,
operation_name: "getObservationsTableInternal",
},
},
tags: {
...(opts.tags ?? {}),
feature: "tracing",
type: "observation",
projectId,
existingExecution: async (input) => {
return queryClickhouse<T>({
query: query.replace("__TRACE_TABLE__", "traces"),
params: input.params,
tags: { ...input.tags, experiment_amt: "original" },
clickhouseConfigs,
});
},
newExecution: async (input) => {
const traceAmt = getTimeframesTracesAMT(
(timeFilter?.value as Date) || undefined,
);
return queryClickhouse<T>({
query: query.replace("__TRACE_TABLE__", traceAmt),
params: input.params,
tags: { ...input.tags, experiment_amt: "new" },
clickhouseConfigs,
});
},
clickhouseConfigs,
});
return res;
};
export const getObservationsGroupedByModel = async (
@@ -1473,6 +1495,9 @@ export const getObservationsForBlobStorageExport = function (
kind: "analytic",
projectId,
},
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DATA_EXPORT_REQUEST_TIMEOUT_MS,
},
});
return records;
@@ -1531,7 +1556,7 @@ export const getGenerationsForPostHog = async function* (
projectId,
},
clickhouseConfigs: {
request_timeout: 300_000, // 5 minutes
request_timeout: env.LANGFUSE_CLICKHOUSE_DATA_EXPORT_REQUEST_TIMEOUT_MS,
clickhouse_settings: {
join_algorithm: "grace_hash",
grace_hash_join_initial_buckets: "32",
@@ -1545,6 +1570,7 @@ export const getGenerationsForPostHog = async function* (
timestamp: record.start_time,
langfuse_generation_name: record.name,
langfuse_trace_name: record.trace_name,
langfuse_trace_id: record.trace_id,
langfuse_url: `${baseUrl}/project/${projectId}/traces/${encodeURIComponent(record.trace_id as string)}?observation=${encodeURIComponent(record.id as string)}`,
langfuse_id: record.id,
langfuse_cost_usd: record.total_cost,
@@ -1,15 +1,20 @@
import { parseClickhouseUTCDateTimeFormat } from "./clickhouse";
import { ObservationRecordReadType } from "./definitions";
import { parseJsonPrioritised } from "../../utils/json";
import {
Observation,
ObservationLevelType,
ObservationType,
} from "../../domain";
import { parseMetadataCHRecordToDomain } from "../utils/metadata_conversion";
import {
RenderingProps,
DEFAULT_RENDERING_PROPS,
applyInputOutputRendering,
} from "../utils/rendering";
export const convertObservation = (
record: ObservationRecordReadType,
renderingProps: RenderingProps = DEFAULT_RENDERING_PROPS,
): Observation => {
const reducedCostDetails = reduceUsageOrCostDetails(record.cost_details);
const reducedUsageDetails = reduceUsageOrCostDetails(record.usage_details);
@@ -30,10 +35,8 @@ export const convertObservation = (
level: record.level as ObservationLevelType,
statusMessage: record.status_message ?? null,
version: record.version ?? null,
input: record.input ? (parseJsonPrioritised(record.input) ?? null) : null,
output: record.output
? (parseJsonPrioritised(record.output) ?? null)
: null,
input: applyInputOutputRendering(record.input, renderingProps),
output: applyInputOutputRendering(record.output, renderingProps),
modelParameters: record.model_parameters
? (JSON.parse(record.model_parameters) ?? null)
: null,
@@ -32,6 +32,7 @@ import { parseMetadataCHRecordToDomain } from "../utils/metadata_conversion";
import { ClickHouseClientConfigOptions } from "@clickhouse/client";
import { recordDistribution } from "../instrumentation";
import { prisma } from "../../db";
import { measureAndReturn } from "../clickhouse/measureAndReturn";
export const searchExistingAnnotationScore = async (
projectId: string,
@@ -284,6 +285,53 @@ export const getScoresForDatasetRuns = async <
return rows.map(convertToScore);
};
export const getTraceScoresForDatasetRuns = async (
projectId: string,
datasetRunIds: string[],
): Promise<Array<{ dataset_run_id: string } & any>> => {
if (datasetRunIds.length === 0) return [];
const query = `
SELECT
s.* EXCEPT (metadata),
length(mapKeys(s.metadata)) > 0 AS has_metadata,
dri.dataset_run_id as run_id
FROM dataset_run_items dri
JOIN scores s FINAL ON dri.trace_id = s.trace_id
AND dri.project_id = s.project_id
WHERE dri.project_id = {projectId: String}
AND dri.dataset_run_id IN {datasetRunIds: Array(String)}
AND s.project_id = {projectId: String}
ORDER BY s.event_ts DESC
LIMIT 1 BY s.id, s.project_id, dri.dataset_run_id
`;
const rows = await queryClickhouse<
Omit<ScoreRecordReadType, "metadata"> & {
has_metadata: 0 | 1;
run_id: string;
}
>({
query,
params: {
projectId,
datasetRunIds,
},
tags: {
feature: "dataset-run-items",
type: "trace-scores",
kind: "list",
projectId,
},
});
return rows.map((row) => ({
...convertToScore({ ...row, metadata: {} }),
datasetRunId: row.run_id,
hasMetadata: !!row.has_metadata,
}));
};
// Used in multiple places, including the public API, hence the non-default exclusion of metadata via excludeMetadata flag
export const getScoresForTraces = async <
ExcludeMetadata extends boolean,
@@ -895,31 +943,49 @@ const getScoresUiGeneric = async <T>(props: {
SELECT
${select}
FROM scores s final
${performTracesJoin ? "LEFT JOIN traces t ON s.trace_id = t.id AND t.project_id = s.project_id" : ""}
${performTracesJoin ? "LEFT JOIN __TRACE_TABLE__ t ON s.trace_id = t.id AND t.project_id = s.project_id" : ""}
WHERE s.project_id = {projectId: String}
${scoresFilterRes?.query ? `AND ${scoresFilterRes.query}` : ""}
${orderByToClickhouseSql(orderBy ?? null, scoresTableUiColumnDefinitions)}
${limit !== undefined && offset !== undefined ? `limit {limit: Int32} offset {offset: Int32}` : ""}
`;
const rows = await queryClickhouse<T>({
query: query,
params: {
projectId: projectId,
...(scoresFilterRes ? scoresFilterRes.params : {}),
limit: limit,
offset: offset,
return measureAndReturn({
operationName: "getScoresUiGeneric",
projectId,
input: {
params: {
projectId: projectId,
...(scoresFilterRes ? scoresFilterRes.params : {}),
limit: limit,
offset: offset,
},
tags: {
...(props.tags ?? {}),
feature: "tracing",
type: "score",
projectId,
select: props.select,
operation_name: "getScoresUiGeneric",
},
},
tags: {
...(props.tags ?? {}),
feature: "tracing",
type: "score",
projectId,
existingExecution: async (input) => {
return queryClickhouse<T>({
query: query.replace("__TRACE_TABLE__", "traces"),
params: input.params,
tags: { ...input.tags, experiment_amt: "original" },
clickhouseConfigs,
});
},
newExecution: async (input) => {
return queryClickhouse<T>({
query: query.replace("__TRACE_TABLE__", "traces_all_amt"),
params: input.params,
tags: { ...input.tags, experiment_amt: "new" },
clickhouseConfigs,
});
},
clickhouseConfigs,
});
return rows;
};
export const getScoreNames = async (
@@ -1320,6 +1386,9 @@ export const getScoresForBlobStorageExport = function (
kind: "analytic",
projectId,
},
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DATA_EXPORT_REQUEST_TIMEOUT_MS,
},
});
return records;
@@ -1339,6 +1408,7 @@ export const getScoresForPostHog = async function* (
s.data_type as data_type,
s.comment as comment,
s.environment as environment,
t.id as trace_id,
t.name as trace_name,
t.session_id as trace_session_id,
t.user_id as trace_user_id,
@@ -1370,7 +1440,7 @@ export const getScoresForPostHog = async function* (
projectId,
},
clickhouseConfigs: {
request_timeout: 300_000, // 5 minutes
request_timeout: env.LANGFUSE_CLICKHOUSE_DATA_EXPORT_REQUEST_TIMEOUT_MS,
clickhouse_settings: {
join_algorithm: "grace_hash",
grace_hash_join_initial_buckets: "32",
@@ -1389,6 +1459,7 @@ export const getScoresForPostHog = async function* (
langfuse_score_string_value: record.string_value,
langfuse_score_data_type: record.data_type,
langfuse_trace_name: record.trace_name,
langfuse_trace_id: record.trace_id,
langfuse_id: record.id,
langfuse_session_id: record.trace_session_id,
langfuse_project_id: projectId,
+417 -91
View File
@@ -15,10 +15,13 @@ import {
FilterList,
StringFilter,
} from "../queries/clickhouse-sql/clickhouse-filter";
import { TraceRecordReadType } from "./definitions";
import { TraceRecordReadType, convertTraceToTraceNull } from "./definitions";
import { tracesTableUiColumnDefinitions } from "../../tableDefinitions/mapTracesTable";
import { UiColumnMappings } from "../../tableDefinitions";
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
import {
clickhouseClient,
convertDateToClickhouseDateTime,
} from "../clickhouse/client";
import { convertClickhouseToDomain } from "./traces_converters";
import { clickhouseSearchCondition } from "../queries/clickhouse-sql/search";
import {
@@ -29,6 +32,7 @@ import { env } from "../../env";
import { ClickHouseClientConfigOptions } from "@clickhouse/client";
import { recordDistribution } from "../instrumentation";
import { measureAndReturn } from "../clickhouse/measureAndReturn";
import { DEFAULT_RENDERING_PROPS, RenderingProps } from "../utils/rendering";
// eslint-disable-next-line no-unused-vars
enum TracesAMTs {
@@ -143,6 +147,7 @@ export const checkTraceExists = async ({
return measureAndReturn({
operationName: "checkTraceExists",
projectId,
minStartTime: timestamp ?? exactTimestamp,
input: {
params: {
projectId,
@@ -163,6 +168,7 @@ export const checkTraceExists = async ({
type: "trace",
kind: "exists",
projectId,
operation_name: "checkTraceExists",
},
timestamp: timestamp ?? exactTimestamp,
},
@@ -186,7 +192,7 @@ export const checkTraceExists = async ({
const rows = await queryClickhouse<{ id: string; project_id: string }>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
});
return rows.length > 0;
@@ -200,7 +206,7 @@ export const checkTraceExists = async ({
t.project_id as project_id,
-- Add a timestamp alias to ensure we can filter on it
t.start_time as timestamp
FROM ${traceAmt} t
FROM ${traceAmt} t FINAL
${observationFilterRes ? `INNER JOIN observations_agg o ON t.id = o.trace_id AND t.project_id = o.project_id` : ""}
WHERE ${tracesFilterRes.query}
AND t.project_id = {projectId: String}
@@ -209,7 +215,7 @@ export const checkTraceExists = async ({
const rows = await queryClickhouse<{ id: string; project_id: string }>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
});
return rows.length > 0;
@@ -225,6 +231,7 @@ export const upsertTrace = async (trace: Partial<TraceRecordReadType>) => {
if (!["id", "project_id", "timestamp"].every((key) => key in trace)) {
throw new Error("Identifier fields must be provided to upsert Trace.");
}
await upsertClickhouse({
table: "traces",
records: [trace as TraceRecordReadType],
@@ -236,6 +243,38 @@ export const upsertTrace = async (trace: Partial<TraceRecordReadType>) => {
projectId: trace.project_id ?? "",
},
});
// Also insert into traces_null if experiment flag is enabled
if (env.LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES === "true") {
// Convert trace to insert format first (since we have read format)
const traceRecord = trace as TraceRecordReadType;
const traceInsert = {
...traceRecord,
timestamp: new Date(traceRecord.timestamp).getTime(),
created_at: new Date(traceRecord.created_at).getTime(),
updated_at: new Date(traceRecord.updated_at).getTime(),
event_ts: new Date(traceRecord.event_ts).getTime(),
is_deleted: 0,
};
// Convert to traces_null format
const traceNull = convertTraceToTraceNull(traceInsert);
// Insert directly into traces_null using clickhouse client
await clickhouseClient().insert({
table: "traces_null",
format: "JSONEachRow",
values: [traceNull],
clickhouse_settings: {
log_comment: JSON.stringify({
feature: "tracing",
type: "traces_null",
kind: "upsert",
experiment: "insert_into_aggregating_merge_trees",
}),
},
});
}
};
export const getTracesByIds = async (
@@ -260,6 +299,7 @@ export const getTracesByIds = async (
type: "trace",
kind: "byId",
projectId,
operation_name: "getTracesByIds",
},
clickhouseConfigs,
},
@@ -276,7 +316,7 @@ export const getTracesByIds = async (
return queryClickhouse<TraceRecordReadType>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
clickhouseConfigs: input.clickhouseConfigs,
});
},
@@ -311,13 +351,15 @@ export const getTracesByIds = async (
return queryClickhouse<TraceRecordReadType>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
clickhouseConfigs: input.clickhouseConfigs,
});
},
});
return records.map(convertClickhouseToDomain);
return records.map((record) =>
convertClickhouseToDomain(record, DEFAULT_RENDERING_PROPS),
);
};
export const getTracesBySessionId = async (
@@ -328,6 +370,7 @@ export const getTracesBySessionId = async (
const records = await measureAndReturn({
operationName: "getTracesBySessionId",
projectId,
minStartTime: timestamp,
input: {
params: {
sessionIds,
@@ -341,6 +384,7 @@ export const getTracesBySessionId = async (
type: "trace",
kind: "list",
projectId,
operation_name: "getTracesBySessionId",
},
timestamp,
},
@@ -357,7 +401,7 @@ export const getTracesBySessionId = async (
return queryClickhouse<TraceRecordReadType>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
});
},
newExecution: (input) => {
@@ -388,12 +432,14 @@ export const getTracesBySessionId = async (
return queryClickhouse<TraceRecordReadType>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
});
},
});
const traces = records.map(convertClickhouseToDomain);
const traces = records.map((record) =>
convertClickhouseToDomain(record, DEFAULT_RENDERING_PROPS),
);
traces.forEach((trace) => {
recordDistribution(
@@ -416,6 +462,7 @@ export const hasAnyTrace = async (projectId: string) => {
type: "trace",
kind: "hasAny",
projectId,
operation_name: "hasAnyTrace",
},
},
existingExecution: async (input) => {
@@ -431,7 +478,7 @@ export const hasAnyTrace = async (projectId: string) => {
params: {
projectId: input.projectId,
},
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
});
return rows.length > 0;
@@ -449,7 +496,7 @@ export const hasAnyTrace = async (projectId: string) => {
params: {
projectId: input.projectId,
},
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
});
return rows.length > 0;
@@ -467,6 +514,7 @@ export const getTraceCountsByProjectInCreationInterval = async ({
return measureAndReturn({
operationName: "getTraceCountsByProjectInCreationInterval",
projectId: "__CROSS_PROJECT__",
minStartTime: start,
input: {
params: {
start: convertDateToClickhouseDateTime(start),
@@ -476,6 +524,7 @@ export const getTraceCountsByProjectInCreationInterval = async ({
feature: "tracing",
type: "trace",
kind: "analytic",
operation_name: "getTraceCountsByProjectInCreationInterval",
},
timestamp: start,
},
@@ -494,7 +543,7 @@ export const getTraceCountsByProjectInCreationInterval = async ({
{
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
},
);
@@ -519,7 +568,7 @@ export const getTraceCountsByProjectInCreationInterval = async ({
{
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
},
);
@@ -541,6 +590,7 @@ export const getTraceCountOfProjectsSinceCreationDate = async ({
return measureAndReturn({
operationName: "getTraceCountOfProjectsSinceCreationDate",
projectId: "__CROSS_PROJECT__",
minStartTime: start,
input: {
params: {
projectIds,
@@ -550,6 +600,7 @@ export const getTraceCountOfProjectsSinceCreationDate = async ({
feature: "tracing",
type: "trace",
kind: "analytic",
operation_name: "getTraceCountOfProjectsSinceCreationDate",
},
timestamp: start,
},
@@ -565,7 +616,7 @@ export const getTraceCountOfProjectsSinceCreationDate = async ({
const rows = await queryClickhouse<{ count: string }>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
});
return Number(rows[0]?.count ?? 0);
@@ -583,7 +634,7 @@ export const getTraceCountOfProjectsSinceCreationDate = async ({
const rows = await queryClickhouse<{ count: string }>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
});
return Number(rows[0]?.count ?? 0);
@@ -603,15 +654,18 @@ export const getTraceById = async ({
projectId,
timestamp,
fromTimestamp,
renderingProps = DEFAULT_RENDERING_PROPS,
}: {
traceId: string;
projectId: string;
timestamp?: Date;
fromTimestamp?: Date;
renderingProps?: RenderingProps;
}) => {
const records = await measureAndReturn({
operationName: "getTraceById",
projectId,
minStartTime: fromTimestamp ?? timestamp,
input: {
params: {
traceId,
@@ -628,11 +682,30 @@ export const getTraceById = async ({
type: "trace",
kind: "byId",
projectId,
operation_name: "getTraceById",
},
},
existingExecution: (input) => {
const query = `
SELECT *
SELECT
id,
name as name,
user_id as user_id,
metadata as metadata,
release as release,
version as version,
project_id,
environment,
public as public,
bookmarked as bookmarked,
tags,
${renderingProps.truncated ? `left(input, ${env.LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT})` : "input"} as input,
${renderingProps.truncated ? `left(output, ${env.LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT})` : "output"} as output,
session_id as session_id,
0 as is_deleted,
timestamp,
created_at,
updated_at
FROM traces
WHERE id = {traceId: String}
AND project_id = {projectId: String}
@@ -645,7 +718,7 @@ export const getTraceById = async ({
return queryClickhouse<TraceRecordReadType>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
});
},
newExecution: (input) => {
@@ -662,8 +735,8 @@ export const getTraceById = async ({
finalizeAggregation(public) as public,
finalizeAggregation(bookmarked) as bookmarked,
tags,
finalizeAggregation(input) as input,
finalizeAggregation(output) as output,
${renderingProps.truncated ? `left(finalizeAggregation(input), ${env.LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT})` : "finalizeAggregation(input)"} as input,
${renderingProps.truncated ? `left(finalizeAggregation(output), ${env.LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT})` : "finalizeAggregation(output)"} as output,
session_id as session_id,
0 as is_deleted,
start_time as timestamp,
@@ -679,12 +752,14 @@ export const getTraceById = async ({
return queryClickhouse<TraceRecordReadType>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
});
},
});
const res = records.map(convertClickhouseToDomain);
const res = records.map((record) =>
convertClickhouseToDomain(record, renderingProps),
);
res.forEach((trace) => {
recordDistribution(
@@ -715,6 +790,10 @@ export const getTracesGroupedByName = async (
return measureAndReturn({
operationName: "getTracesGroupedByName",
projectId,
minStartTime: timestampFilter?.find(
(f) =>
f.column === "timestamp" && (f.operator === ">=" || f.operator === ">"),
)?.value as Date | undefined,
input: {
params: {
projectId,
@@ -725,6 +804,7 @@ export const getTracesGroupedByName = async (
type: "trace",
kind: "analytic",
projectId,
operation_name: "getTracesGroupedByName",
},
},
existingExecution: async (input) => {
@@ -749,7 +829,7 @@ export const getTracesGroupedByName = async (
}>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
});
},
newExecution: async (input) => {
@@ -778,7 +858,7 @@ export const getTracesGroupedByName = async (
}>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
});
},
});
@@ -809,6 +889,10 @@ export const getTracesGroupedByUsers = async (
return measureAndReturn({
operationName: "getTracesGroupedByUsers",
projectId,
minStartTime: filter?.find(
(f) =>
f.column === "timestamp" && (f.operator === ">=" || f.operator === ">"),
)?.value as Date | undefined,
input: {
params: {
limit,
@@ -822,6 +906,7 @@ export const getTracesGroupedByUsers = async (
type: "trace",
kind: "analytic",
projectId,
operation_name: "getTracesGroupedByUsers",
},
},
existingExecution: async (input) => {
@@ -848,7 +933,7 @@ export const getTracesGroupedByUsers = async (
}>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
});
},
newExecution: async (input) => {
@@ -880,7 +965,7 @@ export const getTracesGroupedByUsers = async (
}>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
});
},
});
@@ -905,6 +990,10 @@ export const getTracesGroupedByTags = async (props: GroupedTracesQueryProp) => {
return measureAndReturn({
operationName: "getTracesGroupedByTags",
projectId,
minStartTime: filter?.find(
(f) =>
f.column === "timestamp" && (f.operator === ">=" || f.operator === ">"),
)?.value as Date | undefined,
input: {
params: {
projectId,
@@ -915,6 +1004,7 @@ export const getTracesGroupedByTags = async (props: GroupedTracesQueryProp) => {
type: "trace",
kind: "analytic",
projectId,
operation_name: "getTracesGroupedByTags",
},
},
existingExecution: async (input) => {
@@ -931,7 +1021,7 @@ export const getTracesGroupedByTags = async (props: GroupedTracesQueryProp) => {
}>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
});
},
newExecution: async (input) => {
@@ -955,7 +1045,7 @@ export const getTracesGroupedByTags = async (props: GroupedTracesQueryProp) => {
}>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
});
},
});
@@ -978,6 +1068,7 @@ export const getTracesIdentifierForSession = async (
type: "trace",
kind: "list",
projectId,
operation_name: "getTracesIdentifierForSession",
},
},
existingExecution: (input) => {
@@ -1005,7 +1096,7 @@ export const getTracesIdentifierForSession = async (
}>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
});
},
newExecution: (input) => {
@@ -1017,10 +1108,11 @@ export const getTracesIdentifierForSession = async (
start_time as timestamp,
project_id,
environment
FROM traces_all_amt FINAL
FROM traces_all_amt
WHERE (project_id = {projectId: String})
AND (session_id = {sessionId: String})
ORDER BY start_time ASC;
ORDER BY start_time ASC
LIMIT 1 BY id, project_id;
`;
return queryClickhouse<{
@@ -1032,7 +1124,7 @@ export const getTracesIdentifierForSession = async (
}>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
});
},
});
@@ -1047,25 +1139,104 @@ export const getTracesIdentifierForSession = async (
};
export const deleteTraces = async (projectId: string, traceIds: string[]) => {
const query = `
DELETE FROM traces
WHERE project_id = {projectId: String}
AND id IN ({traceIds: Array(String)});
`;
await commandClickhouse({
query: query,
params: {
projectId,
traceIds,
await measureAndReturn({
operationName: "deleteTraces",
projectId,
input: {
params: {
projectId,
traceIds,
},
tags: {
feature: "tracing",
type: "trace",
kind: "delete",
projectId,
},
},
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
existingExecution: async (input) => {
const query = `
DELETE FROM traces
WHERE project_id = {projectId: String}
AND id IN ({traceIds: Array(String)});
`;
await commandClickhouse({
query: query,
params: input.params,
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: input.tags,
});
},
tags: {
feature: "tracing",
type: "trace",
kind: "delete",
projectId,
newExecution: async (input) => {
await Promise.all([
// Delete from traces
await commandClickhouse({
query: `
DELETE FROM traces
WHERE project_id = {projectId: String}
AND id IN ({traceIds: Array(String)});
`,
params: input.params,
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: input.tags,
}),
// Delete from traces_null
commandClickhouse({
query: `
DELETE FROM traces_null
WHERE project_id = {projectId: String}
AND id IN ({traceIds: Array(String)});
`,
params: input.params,
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: input.tags,
}),
// Delete from traces_all_amt
commandClickhouse({
query: `
DELETE FROM traces_all_amt
WHERE project_id = {projectId: String}
AND id IN ({traceIds: Array(String)});
`,
params: input.params,
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: input.tags,
}),
// Delete from traces_7d_amt
commandClickhouse({
query: `
DELETE FROM traces_7d_amt
WHERE project_id = {projectId: String}
AND id IN ({traceIds: Array(String)});
`,
params: input.params,
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: input.tags,
}),
// Delete from traces_30d_amt
commandClickhouse({
query: `
DELETE FROM traces_30d_amt
WHERE project_id = {projectId: String}
AND id IN ({traceIds: Array(String)});
`,
params: input.params,
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: input.tags,
}),
]);
},
});
};
@@ -1074,47 +1245,187 @@ export const deleteTracesOlderThanDays = async (
projectId: string,
beforeDate: Date,
) => {
const query = `
DELETE FROM traces
WHERE project_id = {projectId: String}
AND timestamp < {cutoffDate: DateTime64(3)};
`;
await commandClickhouse({
query: query,
params: {
projectId,
cutoffDate: convertDateToClickhouseDateTime(beforeDate),
await measureAndReturn({
operationName: "deleteTracesOlderThanDays",
projectId,
input: {
params: {
projectId,
cutoffDate: convertDateToClickhouseDateTime(beforeDate),
},
tags: {
feature: "tracing",
type: "trace",
kind: "delete",
projectId,
},
},
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
existingExecution: async (input) => {
const query = `
DELETE FROM traces
WHERE project_id = {projectId: String}
AND timestamp < {cutoffDate: DateTime64(3)};
`;
await commandClickhouse({
query: query,
params: input.params,
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: input.tags,
});
},
tags: {
feature: "tracing",
type: "trace",
kind: "delete",
projectId,
newExecution: async (input) => {
await Promise.all([
// Delete from traces
await commandClickhouse({
query: `
DELETE FROM traces
WHERE project_id = {projectId: String}
AND timestamp < {cutoffDate: DateTime64(3)};
`,
params: input.params,
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: input.tags,
}),
// Delete from traces_all_amt
commandClickhouse({
query: `
DELETE FROM traces_all_amt
WHERE project_id = {projectId: String}
AND start_time < {cutoffDate: DateTime64(3)};
`,
params: input.params,
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: input.tags,
}),
// Delete from traces_7d_amt
commandClickhouse({
query: `
DELETE FROM traces_7d_amt
WHERE project_id = {projectId: String}
AND start_time < {cutoffDate: DateTime64(3)};
`,
params: input.params,
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: input.tags,
}),
// Delete from traces_30d_amt
commandClickhouse({
query: `
DELETE FROM traces_30d_amt
WHERE project_id = {projectId: String}
AND start_time < {cutoffDate: DateTime64(3)};
`,
params: input.params,
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: input.tags,
}),
]);
},
});
};
export const deleteTracesByProjectId = async (projectId: string) => {
const query = `
DELETE FROM traces
WHERE project_id = {projectId: String};
`;
await commandClickhouse({
query: query,
params: {
projectId,
await measureAndReturn({
operationName: "deleteTracesByProjectId",
projectId,
input: {
params: {
projectId,
},
tags: {
feature: "tracing",
type: "trace",
kind: "delete",
projectId,
},
},
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
existingExecution: async (input) => {
const query = `
DELETE FROM traces
WHERE project_id = {projectId: String};
`;
await commandClickhouse({
query: query,
params: input.params,
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: input.tags,
});
},
tags: {
feature: "tracing",
type: "trace",
kind: "delete",
projectId,
newExecution: async (input) => {
await Promise.all([
// Delete from traces
await commandClickhouse({
query: `
DELETE FROM traces
WHERE project_id = {projectId: String};
`,
params: input.params,
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: input.tags,
}),
// Delete from traces_null
commandClickhouse({
query: `
DELETE FROM traces_null
WHERE project_id = {projectId: String};
`,
params: input.params,
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: input.tags,
}),
// Delete from traces_all_amt
commandClickhouse({
query: `
DELETE FROM traces_all_amt
WHERE project_id = {projectId: String};
`,
params: input.params,
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: input.tags,
}),
// Delete from traces_7d_amt
commandClickhouse({
query: `
DELETE FROM traces_7d_amt
WHERE project_id = {projectId: String};
`,
params: input.params,
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: input.tags,
}),
// Delete from traces_30d_amt
commandClickhouse({
query: `
DELETE FROM traces_30d_amt
WHERE project_id = {projectId: String};
`,
params: input.params,
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: input.tags,
}),
]);
},
});
};
@@ -1130,6 +1441,7 @@ export const hasAnyUser = async (projectId: string) => {
type: "user",
kind: "hasAny",
projectId,
operation_name: "hasAnyUser",
},
},
existingExecution: async (input) => {
@@ -1147,7 +1459,7 @@ export const hasAnyUser = async (projectId: string) => {
params: {
projectId: input.projectId,
},
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
});
return rows.length > 0;
@@ -1167,7 +1479,7 @@ export const hasAnyUser = async (projectId: string) => {
params: {
projectId: input.projectId,
},
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
});
return rows.length > 0;
@@ -1194,6 +1506,10 @@ export const getTotalUserCount = async (
return measureAndReturn({
operationName: "getTotalUserCount",
projectId,
minStartTime: filter?.find(
(f) =>
f.column === "timestamp" && (f.operator === ">=" || f.operator === ">"),
)?.value as Date | undefined,
input: {
params: {
...tracesFilterRes.params,
@@ -1204,6 +1520,7 @@ export const getTotalUserCount = async (
type: "trace",
kind: "analytic",
projectId,
operation_name: "getTotalUserCount",
},
},
existingExecution: async (input) => {
@@ -1219,7 +1536,7 @@ export const getTotalUserCount = async (
return queryClickhouse({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
});
},
newExecution: async (input) => {
@@ -1242,7 +1559,7 @@ export const getTotalUserCount = async (
return queryClickhouse({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
});
},
});
@@ -1342,6 +1659,10 @@ export const getUserMetrics = async (
return measureAndReturn({
operationName: "getUserMetrics",
projectId,
minStartTime: filter?.find(
(f) =>
f.column === "timestamp" && (f.operator === ">=" || f.operator === ">"),
)?.value as Date | undefined,
input: {
params: {
projectId,
@@ -1360,6 +1681,7 @@ export const getUserMetrics = async (
type: "trace",
kind: "analytic",
projectId,
operation_name: "getUserMetrics",
},
},
existingExecution: async (input) => {
@@ -1377,7 +1699,7 @@ export const getUserMetrics = async (
}>({
query: query.replaceAll("__TRACE_TABLE__", "traces"),
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
});
return rows.map((row) => ({
@@ -1415,7 +1737,7 @@ export const getUserMetrics = async (
}>({
query: query.replaceAll("__TRACE_TABLE__", traceAmt),
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
});
return rows.map((row) => ({
@@ -1475,6 +1797,9 @@ export const getTracesForBlobStorageExport = function (
kind: "analytic",
projectId,
},
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DATA_EXPORT_REQUEST_TIMEOUT_MS,
},
});
};
@@ -1531,7 +1856,7 @@ export const getTracesForPostHog = async function* (
projectId,
},
clickhouseConfigs: {
request_timeout: 300_000, // 5 minutes
request_timeout: env.LANGFUSE_CLICKHOUSE_DATA_EXPORT_REQUEST_TIMEOUT_MS,
clickhouse_settings: {
join_algorithm: "grace_hash",
grace_hash_join_initial_buckets: "32",
@@ -1585,6 +1910,7 @@ export const getTracesByIdsForAnyProject = async (traceIds: string[]) => {
feature: "tracing",
type: "trace",
kind: "list",
operation_name: "getTracesByIdsForAnyProject",
},
},
existingExecution: async (input) => {
@@ -1600,7 +1926,7 @@ export const getTracesByIdsForAnyProject = async (traceIds: string[]) => {
}>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
});
return records.map((record) => ({
@@ -1621,7 +1947,7 @@ export const getTracesByIdsForAnyProject = async (traceIds: string[]) => {
}>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
});
return records.map((record) => ({
@@ -1,9 +1,13 @@
import { parseClickhouseUTCDateTimeFormat } from "./clickhouse";
import { TraceRecordReadType } from "./definitions";
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
import { parseJsonPrioritised } from "../../utils/json";
import { TraceDomain } from "../../domain";
import { parseMetadataCHRecordToDomain } from "../utils/metadata_conversion";
import {
RenderingProps,
DEFAULT_RENDERING_PROPS,
applyInputOutputRendering,
} from "../utils/rendering";
export const convertTraceDomainToClickhouse = (
trace: TraceDomain,
@@ -33,6 +37,7 @@ export const convertTraceDomainToClickhouse = (
export const convertClickhouseToDomain = (
record: TraceRecordReadType,
renderingProps: RenderingProps = DEFAULT_RENDERING_PROPS,
): TraceDomain => {
return {
id: record.id,
@@ -47,10 +52,8 @@ export const convertClickhouseToDomain = (
userId: record.user_id ?? null,
sessionId: record.session_id ?? null,
public: record.public,
input: record.input ? (parseJsonPrioritised(record.input) ?? null) : null,
output: record.output
? (parseJsonPrioritised(record.output) ?? null)
: null,
input: applyInputOutputRendering(record.input, renderingProps),
output: applyInputOutputRendering(record.output, renderingProps),
metadata: parseMetadataCHRecordToDomain(record.metadata),
createdAt: parseClickhouseUTCDateTimeFormat(record.created_at),
updatedAt: parseClickhouseUTCDateTimeFormat(record.updated_at),
@@ -104,6 +104,7 @@ export class StorageServiceFactory {
}
}
let azureContainersExists: Record<string, boolean> = {};
class AzureBlobStorageService implements StorageService {
private client: ContainerClient;
private container: string;
@@ -139,8 +140,18 @@ class AzureBlobStorageService implements StorageService {
}
private async createContainerIfNotExists(): Promise<void> {
// Skip container existence check if environment variable is set
if (env.LANGFUSE_AZURE_SKIP_CONTAINER_CHECK === "true") {
return;
}
try {
if (azureContainersExists[this.container]) {
return; // Container already exists, no need to create it again
}
await this.client.createIfNotExists();
azureContainersExists[this.container] = true; // Mark container as created
logger.info(`Azure Blob Storage container ${this.container} created`);
} catch (err) {
logger.error(
`Failed to create Azure Blob Storage container ${this.container}`,
@@ -24,6 +24,8 @@ export type SessionDataReturnType = {
trace_count: number;
trace_tags: string[];
trace_environment?: string;
scores_avg?: Array<Array<[string, number]>>;
score_categories?: Array<Array<string>>;
};
export type SessionWithMetricsReturnType = SessionDataReturnType & {
@@ -159,7 +161,9 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
session_total_cost,
session_input_usage,
session_output_usage,
session_total_usage`;
session_total_usage,
scores_avg,
score_categories`;
break;
default: {
const exhaustiveCheckDefault: never = select;
@@ -167,7 +171,7 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
}
}
const { tracesFilter } = getProjectIdDefaultFilter(projectId, {
const { tracesFilter, scoresFilter } = getProjectIdDefaultFilter(projectId, {
tracesPrefix: "s",
});
@@ -176,6 +180,7 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
const tracesFilterRes = tracesFilter
.filter((f) => f.field !== "environment")
.apply();
const scoresFilterRes = scoresFilter.apply();
const traceTimestampFilter: DateTimeFilter | undefined = tracesFilter.find(
(f) =>
@@ -207,6 +212,13 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
const singleTraceFilter =
filters.length > 0 ? new FilterList(filters).apply() : undefined;
const requiresScoresJoin =
tracesFilter.find((f) => f.clickhouseTable === "scores") !== undefined ||
sessionCols.find(
(c) =>
c.uiTableName === orderBy?.column || c.uiTableId === orderBy?.column,
)?.clickhouseTableName === "scores";
const hasMetricsFilter =
tracesFilter.find((f) =>
[
@@ -217,6 +229,8 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
"session_total_usage",
"session_output_usage",
"session_input_usage",
"scores_avg",
"score_categories",
].includes(f.field),
) ||
(orderBy &&
@@ -233,14 +247,53 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
const selectMetrics = select === "metrics" || hasMetricsFilter;
const scoresCte = `scores_agg AS (
SELECT
project_id,
session_id AS score_session_id,
-- For numeric scores, use tuples of (name, avg_value)
groupArrayIf(
tuple(name, avg_value),
data_type IN ('NUMERIC', 'BOOLEAN')
) AS scores_avg,
-- For categorical scores, use name:value format for improved query performance
groupArrayIf(
concat(name, ':', string_value),
data_type = 'CATEGORICAL' AND notEmpty(string_value)
) AS score_categories
FROM (
SELECT
project_id,
session_id,
name,
data_type,
string_value,
avg(value) avg_value
FROM scores s FINAL
WHERE
project_id = {projectId: String}
${scoresFilterRes ? `AND ${scoresFilterRes.query}` : ""}
GROUP BY
project_id,
session_id,
name,
data_type,
string_value
) tmp
GROUP BY
project_id, session_id
)`;
// We use deduplicated traces and observations CTEs instead of final to be able to use Skip indices in Clickhouse.
const query = `
WITH deduplicated_traces AS (
WITH ${select === "metrics" || requiresScoresJoin ? `${scoresCte},` : ""}
deduplicated_traces AS (
SELECT * EXCEPT input, output, metadata
FROM __TRACE_TABLE__ t FINAL
FROM __TRACE_TABLE__ t
WHERE t.session_id IS NOT NULL
AND t.project_id = {projectId: String}
${singleTraceFilter?.query ? ` AND ${singleTraceFilter.query}` : ""}
LIMIT 1 BY id, project_id
),
deduplicated_observations AS (
SELECT *
@@ -288,6 +341,12 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
date_diff('second', minIf(min_start_time, min_start_time > '1970-01-01'), max(max_end_time)) as duration,
sumMap(o.sum_usage_details) as session_usage_details,
sumMap(o.sum_cost_details) as session_cost_details,
${
select === "metrics" || requiresScoresJoin
? `groupUniqArrayArray(s.scores_avg) as scores_avg,
groupUniqArrayArray(s.score_categories) as score_categories,`
: ""
}
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sumMap(o.sum_cost_details)))) as session_input_cost,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, sumMap(o.sum_cost_details)))) as session_output_cost,
sumMap(o.sum_cost_details)['total'] as session_total_cost,
@@ -303,6 +362,7 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
ON t.id = o.trace_id AND t.project_id = o.project_id`
: ""
}
${select === "metrics" || requiresScoresJoin ? `LEFT JOIN scores_agg s on s.project_id = t.project_id and t.session_id = s.score_session_id` : ""}
WHERE t.session_id IS NOT NULL
AND t.project_id = {projectId: String}
${singleTraceFilter?.query ? ` AND ${singleTraceFilter.query}` : ""}
@@ -318,6 +378,11 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
return measureAndReturn({
operationName: "getSessionsTableGeneric",
projectId,
minStartTime: filter?.find(
(f) =>
f.column === "min_timestamp" &&
(f.operator === ">=" || f.operator === ">"),
)?.value as Date | undefined,
input: {
params: {
projectId,
@@ -325,6 +390,7 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
offset: limit && page ? limit * page : 0,
...tracesFilterRes.params,
...singleTraceFilter?.params,
...scoresFilterRes.params,
...(traceTimestampFilter
? {
observationsStartTime: convertDateToClickhouseDateTime(
@@ -338,13 +404,14 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
feature: "tracing",
type: "sessions-table",
projectId,
operation_name: "getSessionsTableGeneric",
},
},
existingExecution: async (input) => {
return queryClickhouse<T>({
query: query.replace("__TRACE_TABLE__", "traces"),
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
clickhouseConfigs,
});
},
@@ -359,7 +426,7 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
return queryClickhouse<T>({
query: query.replace("__TRACE_TABLE__", traceAmt),
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
clickhouseConfigs,
});
},
@@ -350,6 +350,7 @@ async function getTracesTableGeneric(props: FetchTracesTableProps) {
return measureAndReturn({
operationName: "getTracesTableGeneric",
projectId: props.projectId,
minStartTime: select !== "metrics" ? timeStampFilter?.value : undefined,
input: props,
existingExecution: async (props) => {
let sqlSelect: string;
@@ -479,6 +480,8 @@ async function getTracesTableGeneric(props: FetchTracesTableProps) {
feature: "tracing",
type: "traces-table",
projectId,
experiment_amt: "original",
operation_name: "getTracesTableGeneric",
},
clickhouseConfigs,
});
@@ -541,12 +544,8 @@ async function getTracesTableGeneric(props: FetchTracesTableProps) {
true,
);
const defaultOrder = orderBy?.order && orderBy?.column === "timestamp";
const chOrderBy = orderByToClickhouseSql(
[
defaultOrder ? [{ column: "timestamp", order: orderBy.order }] : null,
orderBy ?? null,
].flat(),
[orderBy ?? null].flat(),
tracesTableUiColumnDefinitions,
);
@@ -556,7 +555,7 @@ async function getTracesTableGeneric(props: FetchTracesTableProps) {
: getTimeframesTracesAMT(timeStampFilter?.value);
const query = `
${observationsAndScoresCTE}
${observationsAndScoresCTE}
SELECT ${sqlSelect}
FROM ${tracesAmt} t FINAL
@@ -588,6 +587,8 @@ async function getTracesTableGeneric(props: FetchTracesTableProps) {
feature: "tracing",
type: "traces-table",
projectId,
experiment_amt: "new",
operation_name: "getTracesTableGeneric",
},
clickhouseConfigs,
});
@@ -4,18 +4,16 @@ import {
ObservationRecordInsertType,
ScoreRecordInsertType,
DatasetRunItemRecordInsertType,
convertTraceToTraceMt,
convertTraceToTraceNull,
} from "../repositories/definitions";
import { env } from "../../env";
export const createTracesCh = async (trace: TraceRecordInsertType[]) => {
if (
env.LANGFUSE_EXPERIMENT_COMPARE_READ_FROM_AGGREGATING_MERGE_TREES === "true"
) {
if (env.LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES === "true") {
await clickhouseClient().insert({
table: "traces_mt",
table: "traces_null",
format: "JSONEachRow",
values: trace.map(convertTraceToTraceMt),
values: trace.map(convertTraceToTraceNull),
});
}
return await clickhouseClient().insert({
@@ -0,0 +1,90 @@
import { randomUUID } from "crypto";
import { prisma } from "../db";
import { TraceDeleteQueue } from "./redis/traceDelete";
import { QueueJobs } from "./queues";
import { logger } from "./logger";
import { env } from "../env";
export interface TraceDeletionProcessorOptions {
delayMs?: number; // Default from LANGFUSE_TRACE_DELETE_DELAY_MS env var
}
/**
* Efficient trace deletion processor that batches deletions for better performance.
*
* This function:
* 1. Creates a record in the pending_deletions table for each trace
* 2. Sends a deletion event to the queue with a configurable delay
* 3. The worker will batch delete all pending traces from ClickHouse
* 4. Sets the is_deleted flag to true after successful deletion
*
* @param projectId - The project ID
* @param traceIds - Array of trace IDs to delete
* @param options - Configuration options including delay
*/
export async function traceDeletionProcessor(
projectId: string,
traceIds: string[],
options: TraceDeletionProcessorOptions = {},
): Promise<void> {
const { delayMs = env.LANGFUSE_TRACE_DELETE_DELAY_MS } = options;
if (traceIds.length === 0) {
logger.warn("traceDeletionProcessor called with empty traceIds array", {
projectId,
});
return;
}
logger.info(
`Processing trace deletion for ${traceIds.length} traces in project ${projectId}`,
{
projectId,
traceIds,
delayMs,
},
);
try {
// Create pending deletion records for all traces
await prisma.pendingDeletion.createMany({
data: traceIds.map((traceId) => ({
projectId,
object: "trace",
objectId: traceId,
isDeleted: false,
})),
skipDuplicates: true, // Avoid conflicts if trace is already pending deletion
});
// Get the trace delete queue
const traceDeleteQueue = TraceDeleteQueue.getInstance();
if (!traceDeleteQueue) {
throw new Error("TraceDeleteQueue not available");
}
// Send deletion event with delay
await traceDeleteQueue.add(
QueueJobs.TraceDelete,
{
timestamp: new Date(),
id: randomUUID(),
name: QueueJobs.TraceDelete,
payload: {
projectId,
traceIds,
},
},
{
delay: delayMs,
},
);
} catch (error) {
logger.error(`Failed to process trace deletion for project ${projectId}`, {
projectId,
traceIds,
error,
});
throw error;
}
}
@@ -0,0 +1,58 @@
import { JsonNested } from "../../utils/zod";
import { parseJsonPrioritised } from "../../utils/json";
import { env } from "../../env";
/**
* Rendering properties used to control how data is processed and returned
* in tRPC routes and repository functions.
*/
export interface RenderingProps {
/**
* Whether to truncate input/output fields to a specific character limit
*/
truncated: boolean;
/**
* Whether to skip JSON parsing of input/output fields and return them as raw strings.
* This is useful when the client will handle JSON parsing to avoid double parsing.
*/
shouldJsonParse: boolean;
}
/**
* Default rendering properties
*/
export const DEFAULT_RENDERING_PROPS: RenderingProps = {
truncated: false,
shouldJsonParse: true,
};
/**
* Transform input/output fields based on rendering properties.
*/
export const applyInputOutputRendering = (
io: string | null | undefined,
renderingProps: RenderingProps,
): JsonNested | string | null => {
if (!io) return null;
let result: JsonNested | string = io;
if (
renderingProps.truncated &&
io.length > env.LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT
) {
result =
io.slice(0, env.LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT) + "\n...[truncated]";
}
if (
renderingProps.truncated &&
io.length === env.LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT
) {
result = io + "\n...[truncated]";
}
return renderingProps.shouldJsonParse
? (parseJsonPrioritised(result) ?? null)
: result;
};
@@ -19,4 +19,10 @@ export const datasetRunItemsTableUiColumnDefinitions: UiColumnMappings = [
clickhouseTableName: "dataset_run_items",
clickhouseSelect: 'dri."event_ts"',
},
{
uiTableName: "Dataset Item ID",
uiTableId: "datasetItemId",
clickhouseTableName: "dataset_run_items",
clickhouseSelect: 'dri."dataset_item_id"',
},
];
@@ -138,4 +138,16 @@ export const sessionCols: UiColumnMappings = [
clickhouseTableName: "traces",
clickhouseSelect: "session_id",
},
{
uiTableName: "Scores (numeric)",
uiTableId: "scores",
clickhouseTableName: "scores",
clickhouseSelect: "scores_avg",
},
{
uiTableName: "Scores (categorical)",
uiTableId: "scores",
clickhouseTableName: "scores",
clickhouseSelect: "score_categories",
},
];
@@ -1,6 +1,7 @@
import {
type ColumnDefinition,
type SingleValueOption,
type MultiValueOption,
} from "../tableDefinitions/types";
import { formatColumnOptions } from "./typeHelpers";
@@ -87,11 +88,27 @@ export const sessionsViewCols: ColumnDefinition[] = [
internal: 't."tags"',
options: [], // to be filled in at runtime
},
{
name: "Scores (numeric)",
id: "scores_avg",
type: "numberObject",
internal: "scores",
},
{
name: "Scores (categorical)",
id: "score_categories",
type: "categoryOptions",
internal: "score_categories",
options: [], // to be added at runtime
nullable: true,
},
];
export type SessionOptions = {
userIds: Array<SingleValueOption>;
tags: Array<SingleValueOption>;
scores_avg?: Array<string>;
score_categories?: Array<MultiValueOption>;
};
export function sessionsTableColsWithOptions(
@@ -104,6 +121,12 @@ export function sessionsTableColsWithOptions(
if (col.id === "tags") {
return formatColumnOptions(col, options?.tags ?? []);
}
if (col.id === "scores_avg") {
return formatColumnOptions(col, options?.scores_avg ?? []);
}
if (col.id === "score_categories") {
return formatColumnOptions(col, options?.score_categories ?? []);
}
return col;
});
}
@@ -77,6 +77,7 @@ export const tableNames = [
"job_configurations",
"job_executions",
"dataset_items",
"annotation_queue_assignments",
] as const;
export type TableNames = (typeof tableNames)[number];
+2 -2
View File
@@ -41,11 +41,11 @@ export const jsonSchema: z.ZodType<Json> = z.lazy(() =>
export const paginationZod = {
page: z.preprocess(
(x) => (x === "" ? undefined : x),
z.coerce.number().default(1),
z.coerce.number().nonnegative().default(1),
),
limit: z.preprocess(
(x) => (x === "" ? undefined : x),
z.coerce.number().lte(100).default(50),
z.coerce.number().nonnegative().lte(100).default(50),
),
};
+2 -2
View File
@@ -15,7 +15,7 @@ const cspHeader = `
default-src 'self' https://*.langfuse.com https://*.langfuse.dev https://*.posthog.com https://*.sentry.io;
script-src 'self' 'unsafe-eval' 'unsafe-inline' https://*.langfuse.com https://*.langfuse.dev https://challenges.cloudflare.com https://*.sentry.io https://static.cloudflareinsights.com https://*.stripe.com https://uptime.betterstack.com https://chat.cdn-plain.com;
style-src 'self' 'unsafe-inline' https://uptime.betterstack.com https://fonts.googleapis.com;
img-src 'self' https: blob: data: http://localhost:*;
img-src 'self' https: blob: data: http://localhost:* https://prod-uk-services-workspac-workspacefilespublicbuck-vs4gjqpqjkh6.s3.amazonaws.com https://prod-uk-services-attachm-attachmentsbucket28b3ccf-uwfssb4vt2us.s3.eu-west-2.amazonaws.com https://i0.wp.com;
font-src 'self';
frame-src 'self' https://challenges.cloudflare.com https://*.stripe.com;
worker-src 'self' blob:;
@@ -23,7 +23,7 @@ const cspHeader = `
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
connect-src 'self' https://*.langfuse.com https://*.langfuse.dev https://*.ingest.us.sentry.io https://*.sentry.io https://uptime.betterstack.com https://chat.uk.plain.com https://*.s3.amazonaws.com;
connect-src 'self' https://*.langfuse.com https://*.langfuse.dev https://*.ingest.us.sentry.io https://*.sentry.io https://uptime.betterstack.com https://chat.uk.plain.com https://*.s3.amazonaws.com https://prod-uk-services-attachm-attachmentsuploadbucket2-1l2e4906o2asm.s3.eu-west-2.amazonaws.com;
media-src 'self' https: http://localhost:*;
${env.LANGFUSE_CSP_ENFORCE_HTTPS === "true" ? "upgrade-insecure-requests; block-all-mixed-content;" : ""}
${env.SENTRY_CSP_REPORT_URI ? `report-uri ${env.SENTRY_CSP_REPORT_URI}; report-to csp-endpoint;` : ""}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.89.0",
"version": "3.95.2",
"private": true,
"license": "MIT",
"engines": {
+355 -1
View File
@@ -409,6 +409,111 @@ paths:
schema: {}
security:
- BasicAuth: []
/api/public/annotation-queues/{queueId}/assignments:
post:
description: Create an assignment for a user to an annotation queue
operationId: annotationQueues_createQueueAssignment
tags:
- AnnotationQueues
parameters:
- name: queueId
in: path
description: The unique identifier of the annotation queue
required: true
schema:
type: string
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/CreateAnnotationQueueAssignmentResponse'
'400':
description: ''
content:
application/json:
schema: {}
'401':
description: ''
content:
application/json:
schema: {}
'403':
description: ''
content:
application/json:
schema: {}
'404':
description: ''
content:
application/json:
schema: {}
'405':
description: ''
content:
application/json:
schema: {}
security:
- BasicAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AnnotationQueueAssignmentRequest'
delete:
description: Delete an assignment for a user to an annotation queue
operationId: annotationQueues_deleteQueueAssignment
tags:
- AnnotationQueues
parameters:
- name: queueId
in: path
description: The unique identifier of the annotation queue
required: true
schema:
type: string
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/DeleteAnnotationQueueAssignmentResponse'
'400':
description: ''
content:
application/json:
schema: {}
'401':
description: ''
content:
application/json:
schema: {}
'403':
description: ''
content:
application/json:
schema: {}
'404':
description: ''
content:
application/json:
schema: {}
'405':
description: ''
content:
application/json:
schema: {}
security:
- BasicAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AnnotationQueueAssignmentRequest'
/api/public/comments:
post:
description: >-
@@ -1421,6 +1526,109 @@ paths:
name: My Score
value: 0.9
environment: default
/api/public/llm-connections:
get:
description: Get all LLM connections in a project
operationId: llmConnections_list
tags:
- LlmConnections
parameters:
- name: page
in: query
description: page number, starts at 1
required: false
schema:
type: integer
nullable: true
- name: limit
in: query
description: limit of items per page
required: false
schema:
type: integer
nullable: true
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedLlmConnections'
'400':
description: ''
content:
application/json:
schema: {}
'401':
description: ''
content:
application/json:
schema: {}
'403':
description: ''
content:
application/json:
schema: {}
'404':
description: ''
content:
application/json:
schema: {}
'405':
description: ''
content:
application/json:
schema: {}
security:
- BasicAuth: []
put:
description: >-
Create or update an LLM connection. The connection is upserted on
provider.
operationId: llmConnections_upsert
tags:
- LlmConnections
parameters: []
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/LlmConnection'
'400':
description: ''
content:
application/json:
schema: {}
'401':
description: ''
content:
application/json:
schema: {}
'403':
description: ''
content:
application/json:
schema: {}
'404':
description: ''
content:
application/json:
schema: {}
'405':
description: ''
content:
application/json:
schema: {}
security:
- BasicAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpsertLlmConnectionRequest'
/api/public/media/{mediaId}:
get:
description: Get a media record
@@ -3046,7 +3254,7 @@ paths:
parameters:
- name: filter
in: query
description: Filter expression (e.g. userName eq "value")
description: Filter expression (e.g. userName eq 'value')
required: false
schema:
type: string
@@ -4284,6 +4492,36 @@ components:
required:
- success
- message
AnnotationQueueAssignmentRequest:
title: AnnotationQueueAssignmentRequest
type: object
properties:
userId:
type: string
required:
- userId
DeleteAnnotationQueueAssignmentResponse:
title: DeleteAnnotationQueueAssignmentResponse
type: object
properties:
success:
type: boolean
required:
- success
CreateAnnotationQueueAssignmentResponse:
title: CreateAnnotationQueueAssignmentResponse
type: object
properties:
userId:
type: string
queueId:
type: string
projectId:
type: string
required:
- userId
- queueId
- projectId
CreateCommentRequest:
title: CreateCommentRequest
type: object
@@ -6225,6 +6463,122 @@ components:
type: integer
- $ref: '#/components/schemas/OpenAICompletionUsageSchema'
- $ref: '#/components/schemas/OpenAIResponseUsageSchema'
LlmConnection:
title: LlmConnection
type: object
description: LLM API connection configuration (secrets excluded)
properties:
id:
type: string
provider:
type: string
description: >-
Provider name (e.g., 'openai', 'my-gateway'). Must be unique in
project, used for upserting.
adapter:
type: string
description: The adapter used to interface with the LLM
displaySecretKey:
type: string
description: Masked version of the secret key for display purposes
baseURL:
type: string
nullable: true
description: Custom base URL for the LLM API
customModels:
type: array
items:
type: string
description: List of custom model names available for this connection
withDefaultModels:
type: boolean
description: Whether to include default models for this adapter
extraHeaderKeys:
type: array
items:
type: string
description: >-
Keys of extra headers sent with requests (values excluded for
security)
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
required:
- id
- provider
- adapter
- displaySecretKey
- customModels
- withDefaultModels
- extraHeaderKeys
- createdAt
- updatedAt
PaginatedLlmConnections:
title: PaginatedLlmConnections
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/LlmConnection'
meta:
$ref: '#/components/schemas/utilsMetaResponse'
required:
- data
- meta
UpsertLlmConnectionRequest:
title: UpsertLlmConnectionRequest
type: object
description: Request to create or update an LLM connection (upsert)
properties:
provider:
type: string
description: >-
Provider name (e.g., 'openai', 'my-gateway'). Must be unique in
project, used for upserting.
adapter:
$ref: '#/components/schemas/LlmAdapter'
description: The adapter used to interface with the LLM
secretKey:
type: string
description: Secret key for the LLM API.
baseURL:
type: string
nullable: true
description: Custom base URL for the LLM API
customModels:
type: array
items:
type: string
nullable: true
description: List of custom model names
withDefaultModels:
type: boolean
nullable: true
description: Whether to include default models. Default is true.
extraHeaders:
type: object
additionalProperties:
type: string
nullable: true
description: Extra headers to send with requests
required:
- provider
- adapter
- secretKey
LlmAdapter:
title: LlmAdapter
type: string
enum:
- anthropic
- openai
- azure
- bedrock
- google-vertex-ai
- google-ai-studio
GetMediaResponse:
title: GetMediaResponse
type: object
@@ -324,6 +324,88 @@
"body": null
},
"response": []
},
{
"_type": "endpoint",
"name": "Create Queue Assignment",
"request": {
"description": "Create an assignment for a user to an annotation queue",
"url": {
"raw": "{{baseUrl}}/api/public/annotation-queues/:queueId/assignments",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"annotation-queues",
":queueId",
"assignments"
],
"query": [],
"variable": [
{
"key": "queueId",
"value": "",
"description": "The unique identifier of the annotation queue"
}
]
},
"header": [],
"method": "POST",
"auth": null,
"body": {
"mode": "raw",
"raw": "{\n \"userId\": \"example\"\n}",
"options": {
"raw": {
"language": "json"
}
}
}
},
"response": []
},
{
"_type": "endpoint",
"name": "Delete Queue Assignment",
"request": {
"description": "Delete an assignment for a user to an annotation queue",
"url": {
"raw": "{{baseUrl}}/api/public/annotation-queues/:queueId/assignments",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"annotation-queues",
":queueId",
"assignments"
],
"query": [],
"variable": [
{
"key": "queueId",
"value": "",
"description": "The unique identifier of the annotation queue"
}
]
},
"header": [],
"method": "DELETE",
"auth": null,
"body": {
"mode": "raw",
"raw": "{\n \"userId\": \"example\"\n}",
"options": {
"raw": {
"language": "json"
}
}
}
},
"response": []
}
]
},
@@ -1129,6 +1211,82 @@
}
]
},
{
"_type": "container",
"description": null,
"name": "Llm Connections",
"item": [
{
"_type": "endpoint",
"name": "List",
"request": {
"description": "Get all LLM connections in a project",
"url": {
"raw": "{{baseUrl}}/api/public/llm-connections?page=&limit=",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"llm-connections"
],
"query": [
{
"key": "page",
"value": "",
"description": "page number, starts at 1"
},
{
"key": "limit",
"value": "",
"description": "limit of items per page"
}
],
"variable": []
},
"header": [],
"method": "GET",
"auth": null,
"body": null
},
"response": []
},
{
"_type": "endpoint",
"name": "Upsert",
"request": {
"description": "Create or update an LLM connection. The connection is upserted on provider.",
"url": {
"raw": "{{baseUrl}}/api/public/llm-connections",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"llm-connections"
],
"query": [],
"variable": []
},
"header": [],
"method": "PUT",
"auth": null,
"body": {
"mode": "raw",
"raw": "{\n \"provider\": \"example\",\n \"adapter\": \"anthropic\",\n \"secretKey\": \"example\",\n \"baseURL\": \"example\",\n \"customModels\": [\n \"example\"\n ],\n \"withDefaultModels\": true,\n \"extraHeaders\": {\n \"example\": \"example\"\n }\n}",
"options": {
"raw": {
"language": "json"
}
}
}
},
"response": []
}
]
},
{
"_type": "container",
"description": null,
@@ -0,0 +1,342 @@
/** @jest-environment node */
import {
makeZodVerifiedAPICall,
makeAPICall,
pruneDatabase,
} from "@/src/__tests__/test-utils";
import { prisma } from "@langfuse/shared/src/db";
import { createOrgProjectAndApiKey } from "@langfuse/shared/src/server";
import { v4 as uuidv4 } from "uuid";
import {
CreateAnnotationQueueAssignmentResponse,
DeleteAnnotationQueueAssignmentResponse,
} from "@/src/features/public-api/types/annotation-queues";
describe("/api/public/annotation-queues/:queueId/assignments API", () => {
let auth: string;
let projectId: string;
let orgId: string;
let queueId: string;
let testUserId: string;
let secondTestUserId: string;
beforeAll(async () => {
const {
auth: newAuth,
projectId: newProjectId,
orgId: newOrgId,
} = await createOrgProjectAndApiKey();
auth = newAuth;
projectId = newProjectId;
orgId = newOrgId;
// Create a test annotation queue
const queue = await prisma.annotationQueue.create({
data: {
id: uuidv4(),
name: "Test Queue for Assignments",
description: "Test queue for assignment testing",
projectId,
scoreConfigIds: [],
},
});
queueId = queue.id;
// Create test users with project access
const testUser = await prisma.user.create({
data: {
id: uuidv4(),
email: `testuser1-${uuidv4()}@example.com`,
name: "Test User 1",
},
});
testUserId = testUser.id;
const secondTestUser = await prisma.user.create({
data: {
id: uuidv4(),
email: `testuser2-${uuidv4()}@example.com`,
name: "Test User 2",
},
});
secondTestUserId = secondTestUser.id;
// Give users project access - need to create org memberships first
await prisma.organizationMembership.createMany({
data: [
{
orgId,
userId: testUserId,
role: "MEMBER",
},
{
orgId,
userId: secondTestUserId,
role: "MEMBER",
},
],
});
// Get the org membership IDs and create project memberships
const orgMembership1 = await prisma.organizationMembership.findFirst({
where: { orgId, userId: testUserId },
});
const orgMembership2 = await prisma.organizationMembership.findFirst({
where: { orgId, userId: secondTestUserId },
});
await prisma.projectMembership.createMany({
data: [
{
projectId,
userId: testUserId,
role: "MEMBER",
orgMembershipId: orgMembership1!.id,
},
{
projectId,
userId: secondTestUserId,
role: "MEMBER",
orgMembershipId: orgMembership2!.id,
},
],
});
});
afterAll(async () => {
await pruneDatabase();
});
describe("POST /api/public/annotation-queues/:queueId/assignments", () => {
it("should create annotation queue assignment successfully", async () => {
const response = await makeZodVerifiedAPICall(
CreateAnnotationQueueAssignmentResponse,
"POST",
`/api/public/annotation-queues/${queueId}/assignments`,
{
userId: testUserId,
},
auth,
);
expect(response.status).toBe(200);
expect(response.body.userId).toBe(testUserId);
expect(response.body.projectId).toBe(projectId);
expect(response.body.queueId).toBe(queueId);
// Verify the membership was created in the database
const assignment = await prisma.annotationQueueAssignment.findUnique({
where: {
projectId_queueId_userId: {
projectId,
queueId,
userId: testUserId,
},
},
});
expect(assignment).toBeTruthy();
expect(assignment?.userId).toBe(testUserId);
expect(assignment?.projectId).toBe(projectId);
expect(assignment?.queueId).toBe(queueId);
});
it("should handle duplicate assignment creation gracefully", async () => {
// Create assignment first time
await makeZodVerifiedAPICall(
CreateAnnotationQueueAssignmentResponse,
"POST",
`/api/public/annotation-queues/${queueId}/assignments`,
{
userId: secondTestUserId,
},
auth,
);
// Create same assignment again - should succeed (upsert behavior)
const response = await makeZodVerifiedAPICall(
CreateAnnotationQueueAssignmentResponse,
"POST",
`/api/public/annotation-queues/${queueId}/assignments`,
{
userId: secondTestUserId,
},
auth,
);
expect(response.status).toBe(200);
expect(response.body.userId).toBe(secondTestUserId);
// Verify only one assignment exists
const assignments = await prisma.annotationQueueAssignment.findMany({
where: {
projectId,
queueId,
userId: secondTestUserId,
},
});
expect(assignments).toHaveLength(1);
});
it("should return 404 for non-existent annotation queue", async () => {
const nonExistentQueueId = uuidv4();
const response = await makeAPICall(
"POST",
`/api/public/annotation-queues/${nonExistentQueueId}/assignments`,
{
userId: testUserId,
},
auth,
);
expect(response.status).toBe(404);
});
it("should return 404 for non-existent user", async () => {
const nonExistentUserId = uuidv4();
const response = await makeAPICall(
"POST",
`/api/public/annotation-queues/${queueId}/assignments`,
{
userId: nonExistentUserId,
},
auth,
);
expect(response.status).toBe(404);
});
it("should return 404 for user without project access", async () => {
// Create a user without project access
const userWithoutAccess = await prisma.user.create({
data: {
id: uuidv4(),
email: `noaccess-${uuidv4()}@example.com`,
name: "No Access User",
},
});
const response = await makeAPICall(
"POST",
`/api/public/annotation-queues/${queueId}/assignments`,
{
userId: userWithoutAccess.id,
},
auth,
);
expect(response.status).toBe(404);
});
it("should validate request body", async () => {
// Missing userId
const response = await makeAPICall(
"POST",
`/api/public/annotation-queues/${queueId}/assignments`,
{},
auth,
);
expect(response.status).toBe(400);
});
it("should require valid queueId format", async () => {
const response = await makeAPICall(
"POST",
`/api/public/annotation-queues/invalid-queue-id/assignments`,
{
userId: testUserId,
},
auth,
);
expect(response.status).toBe(404);
});
});
describe("DELETE /api/public/annotation-queues/:queueId/assignments", () => {
beforeEach(async () => {
// Ensure assignment exists for delete tests
await prisma.annotationQueueAssignment.upsert({
where: {
projectId_queueId_userId: {
projectId,
queueId,
userId: testUserId,
},
},
create: {
projectId,
queueId,
userId: testUserId,
},
update: {},
});
});
it("should delete annotation queue membership successfully", async () => {
const response = await makeZodVerifiedAPICall(
DeleteAnnotationQueueAssignmentResponse,
"DELETE",
`/api/public/annotation-queues/${queueId}/assignments`,
{
userId: testUserId,
},
auth,
);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
// Verify the membership was deleted from the database
const assignment = await prisma.annotationQueueAssignment.findUnique({
where: {
projectId_queueId_userId: {
projectId,
queueId,
userId: testUserId,
},
},
});
expect(assignment).toBeNull();
});
it("should handle deletion of non-existent assignment gracefully", async () => {
// Delete a assignment that doesn't exist
const nonExistentUserId = uuidv4();
const response = await makeZodVerifiedAPICall(
DeleteAnnotationQueueAssignmentResponse,
"DELETE",
`/api/public/annotation-queues/${queueId}/assignments`,
{
userId: nonExistentUserId,
},
auth,
);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
});
it("should return 404 for non-existent annotation queue", async () => {
const nonExistentQueueId = uuidv4();
const response = await makeAPICall(
"DELETE",
`/api/public/annotation-queues/${nonExistentQueueId}/assignments`,
{
userId: testUserId,
},
auth,
);
expect(response.status).toBe(404);
});
});
});
@@ -0,0 +1,443 @@
/** @jest-environment node */
import { prisma } from "@langfuse/shared/src/db";
import { Prisma, type Role } from "@langfuse/shared";
import { v4 } from "uuid";
import { getUserProjectRoles } from "@/src/features/rbac/utils/userProjectRole";
export const createOrgAndProject = async () => {
const org = await prisma.organization.create({
data: {
id: v4(),
name: v4(),
},
});
const project = await prisma.project.create({
data: {
id: v4(),
name: v4(),
orgId: org.id,
},
});
return { org, project };
};
describe("find user project roles", () => {
it("should find users with org role", async () => {
const { org, project } = await createOrgAndProject();
const user = await prisma.user.create({
data: {
id: v4(),
email: v4(),
name: v4(),
},
});
await prisma.organizationMembership.create({
data: {
userId: user.id,
orgId: org.id,
role: "MEMBER",
},
});
const users = await getUserProjectRoles({
projectId: project.id,
orgId: org.id,
filterCondition: [],
searchFilter: Prisma.empty,
orderBy: Prisma.empty,
});
expect(users).toEqual([
expect.objectContaining({
id: user.id,
name: user.name,
email: user.email,
}),
]);
});
it("should exclude users with NONE role", async () => {
const { org, project } = await createOrgAndProject();
const user = await prisma.user.create({
data: {
id: v4(),
email: v4(),
name: v4(),
},
});
await prisma.organizationMembership.create({
data: {
userId: user.id,
orgId: org.id,
role: "NONE",
},
});
const users = await getUserProjectRoles({
projectId: project.id,
orgId: org.id,
filterCondition: [],
searchFilter: Prisma.empty,
orderBy: Prisma.empty,
});
expect(users).toEqual([]);
});
it("should find users with org and project role", async () => {
const { org, project } = await createOrgAndProject();
const user = await prisma.user.create({
data: {
id: v4(),
email: v4(),
name: v4(),
},
});
await prisma.organizationMembership.create({
data: {
userId: user.id,
orgId: org.id,
role: "MEMBER",
},
});
const user2 = await prisma.user.create({
data: {
id: v4(),
email: v4(),
name: v4(),
},
});
const orgMembershipOfUser2 = await prisma.organizationMembership.create({
data: {
userId: user2.id,
orgId: org.id,
role: "MEMBER",
},
});
// overwrite org role to ADMIN
await prisma.projectMembership.create({
data: {
userId: user2.id,
projectId: project.id,
role: "ADMIN",
orgMembershipId: orgMembershipOfUser2.id,
},
});
const user3 = await prisma.user.create({
data: {
id: v4(),
email: v4(),
name: v4(),
},
});
const orgMembershipOfUser3 = await prisma.organizationMembership.create({
data: {
userId: user3.id,
orgId: org.id,
role: "ADMIN",
},
});
// Downgrade org role to VIEWER
await prisma.projectMembership.create({
data: {
userId: user3.id,
projectId: project.id,
role: "VIEWER",
orgMembershipId: orgMembershipOfUser3.id,
},
});
const users = await getUserProjectRoles({
projectId: project.id,
orgId: org.id,
filterCondition: [],
searchFilter: Prisma.empty,
orderBy: Prisma.empty,
});
expect(users).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: user.id,
name: user.name,
email: user.email,
role: "MEMBER",
}),
expect.objectContaining({
id: user2.id,
name: user2.name,
email: user2.email,
role: "ADMIN",
}),
expect.objectContaining({
id: user3.id,
name: user3.name,
email: user3.email,
role: "VIEWER",
}),
]),
);
});
it("should not select users from different projects", async () => {
const { org, project } = await createOrgAndProject();
// valid user
const user = await prisma.user.create({
data: {
id: v4(),
email: v4(),
name: v4(),
},
});
await prisma.organizationMembership.create({
data: {
userId: user.id,
orgId: org.id,
role: "MEMBER",
},
});
const { org: org2, project: project2 } = await createOrgAndProject();
const user2 = await prisma.user.create({
data: {
id: v4(),
email: v4(),
name: v4(),
},
});
await prisma.organizationMembership.create({
data: {
userId: user2.id,
orgId: org2.id,
role: "MEMBER",
},
});
const user3 = await prisma.user.create({
data: {
id: v4(),
email: v4(),
name: v4(),
},
});
const orgMembershipOfUser3 = await prisma.organizationMembership.create({
data: {
userId: user3.id,
orgId: org2.id,
role: "MEMBER",
},
});
await prisma.projectMembership.create({
data: {
userId: user3.id,
projectId: project2.id,
role: "MEMBER",
orgMembershipId: orgMembershipOfUser3.id,
},
});
const users = await getUserProjectRoles({
projectId: project.id,
orgId: org.id,
filterCondition: [],
searchFilter: Prisma.empty,
orderBy: Prisma.empty,
});
expect(users).toEqual([
expect.objectContaining({
id: user.id,
name: user.name,
email: user.email,
}),
]);
});
it("should return empty array for empty organization", async () => {
const { org, project } = await createOrgAndProject();
const users = await getUserProjectRoles({
projectId: project.id,
orgId: org.id,
filterCondition: [],
searchFilter: Prisma.empty,
orderBy: Prisma.empty,
});
expect(users).toEqual([]);
});
it("should exclude users with project role NONE even if they have org membership", async () => {
const { org, project } = await createOrgAndProject();
const user = await prisma.user.create({
data: {
id: v4(),
email: v4(),
name: v4(),
},
});
const orgMembership = await prisma.organizationMembership.create({
data: {
userId: user.id,
orgId: org.id,
role: "ADMIN",
},
});
// Project role NONE should exclude user despite ADMIN org role
await prisma.projectMembership.create({
data: {
userId: user.id,
projectId: project.id,
role: "NONE",
orgMembershipId: orgMembership.id,
},
});
const users = await getUserProjectRoles({
projectId: project.id,
orgId: org.id,
filterCondition: [],
searchFilter: Prisma.empty,
orderBy: Prisma.empty,
});
expect(users).toEqual([]);
});
it("should test all role combinations inheritance vs override", async () => {
const { org, project } = await createOrgAndProject();
// Test inheritance: users with only org roles
const inheritanceTests = [
{ orgRole: "OWNER", expectedRole: "OWNER" },
{ orgRole: "ADMIN", expectedRole: "ADMIN" },
{ orgRole: "MEMBER", expectedRole: "MEMBER" },
{ orgRole: "VIEWER", expectedRole: "VIEWER" },
];
const inheritanceUsers = [];
for (const test of inheritanceTests) {
const id = v4();
const user = await prisma.user.create({
data: {
id,
email: `${id}-${test.orgRole}@test.com`,
name: `${id}-${test.orgRole} User`,
},
});
await prisma.organizationMembership.create({
data: {
userId: user.id,
orgId: org.id,
role: test.orgRole as Role,
},
});
inheritanceUsers.push({
id,
role: test.expectedRole,
});
}
// Test overrides: users with project roles that override org roles
const overrideTests = [
{ orgRole: "OWNER", projectRole: "VIEWER", expectedRole: "VIEWER" },
{ orgRole: "MEMBER", projectRole: "ADMIN", expectedRole: "ADMIN" },
{ orgRole: "VIEWER", projectRole: "MEMBER", expectedRole: "MEMBER" },
{ orgRole: "ADMIN", projectRole: "VIEWER", expectedRole: "VIEWER" },
];
const overrideUsers = [];
for (const test of overrideTests) {
const id = v4();
const user = await prisma.user.create({
data: {
id,
email: `${id}-${test.orgRole}-${test.projectRole}@test.com`,
name: `${id}-${test.orgRole}-${test.projectRole} User`,
},
});
const orgMembership = await prisma.organizationMembership.create({
data: {
userId: user.id,
orgId: org.id,
role: test.orgRole as Role,
},
});
await prisma.projectMembership.create({
data: {
userId: user.id,
projectId: project.id,
role: test.projectRole as Role,
orgMembershipId: orgMembership.id,
},
});
overrideUsers.push({
id: user.id,
role: test.expectedRole,
});
}
const users = await getUserProjectRoles({
projectId: project.id,
orgId: org.id,
filterCondition: [],
searchFilter: Prisma.empty,
orderBy: Prisma.empty,
});
// Verify all inheritance cases
for (const expectedUser of inheritanceUsers) {
expect(users).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expectedUser.id,
role: expectedUser.role,
}),
]),
);
}
// Verify all override cases
for (const expectedUser of overrideUsers) {
expect(users).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expectedUser.id,
role: expectedUser.role,
}),
]),
);
}
// Verify total count
expect(users).toHaveLength(inheritanceUsers.length + overrideUsers.length);
});
});
@@ -316,36 +316,40 @@ describe("/api/public/ingestion API Endpoint", () => {
"<",
"#",
"|",
])("should test special S3 characters in IDs (%s)", async (char: string) => {
const traceId = randomUUID();
])(
"should test special S3 characters in IDs (%s)",
async (char: string) => {
const traceId = randomUUID();
const response = await makeAPICall("POST", "/api/public/ingestion", {
batch: [
{
id: randomUUID(),
type: "trace-create",
timestamp: new Date().toISOString(),
body: {
id: `${traceId}-${char}-test`,
const response = await makeAPICall("POST", "/api/public/ingestion", {
batch: [
{
id: randomUUID(),
type: "trace-create",
timestamp: new Date().toISOString(),
body: {
id: `${traceId}-${char}-test`,
timestamp: new Date().toISOString(),
},
},
},
],
});
expect(response.status).toBe(207);
await waitForExpect(async () => {
const trace = await getTraceById({
traceId: `${traceId}-${char}-test`,
projectId,
],
});
expect(trace).toBeDefined();
expect(trace!.id).toBe(`${traceId}-${char}-test`);
expect(trace!.projectId).toBe(projectId);
expect(trace!.environment).toEqual("default");
});
});
expect(response.status).toBe(207);
await waitForExpect(async () => {
const trace = await getTraceById({
traceId: `${traceId}-${char}-test`,
projectId,
});
expect(trace).toBeDefined();
expect(trace!.id).toBe(`${traceId}-${char}-test`);
expect(trace!.projectId).toBe(projectId);
expect(trace!.environment).toEqual("default");
});
},
10000,
);
it("should fail for \\r in id", async () => {
const traceId = v4();
@@ -543,6 +547,7 @@ describe("/api/public/ingestion API Endpoint", () => {
);
});
},
10000,
);
it.each([
@@ -630,6 +635,65 @@ describe("/api/public/ingestion API Endpoint", () => {
},
);
it("should merge metadata correctly across multiple trace updates", async () => {
const traceId = randomUUID();
// First update with initial metadata: {"step": 1, "status": "started"}
const traceUpdate1 = {
id: randomUUID(),
type: "trace-create",
timestamp: new Date().toISOString(),
body: {
id: traceId,
name: "operation",
timestamp: new Date().toISOString(),
metadata: { step: 1, status: "started" },
},
};
const response1 = await makeAPICall("POST", "/api/public/ingestion", {
batch: [traceUpdate1],
});
expect(response1.status).toBe(207);
// Second update with additional metadata: {"step": 2, "error": ""}
// This should merge with the first update
const traceUpdate2 = {
id: randomUUID(),
type: "trace-create",
timestamp: new Date(Date.now() + 1000).toISOString(), // Later timestamp
body: {
id: traceId,
name: "operation",
timestamp: new Date(Date.now() + 1000).toISOString(),
metadata: { step: 2, error: "" },
},
};
const response2 = await makeAPICall("POST", "/api/public/ingestion", {
batch: [traceUpdate2],
});
expect(response2.status).toBe(207);
await waitForExpect(async () => {
const trace = await getTraceById({ traceId, projectId });
expect(trace).toBeDefined();
expect(trace!.id).toBe(traceId);
expect(trace!.projectId).toBe(projectId);
// Expected final metadata: {"step": 2, "status": "started", "error": ""}
// This verifies that:
// - "step" is updated to the latest value (2)
// - "status" is preserved from the first update ("started")
// - "error" is added from the second update ("")
expect(trace!.metadata).toEqual({
step: 2,
status: "started",
error: "",
});
});
}, 20000);
it("#4900: should clear score comment on update with `null`", async () => {
const scoreId = randomUUID();
const score1 = {
@@ -674,5 +738,5 @@ describe("/api/public/ingestion API Endpoint", () => {
expect(score!.value).toEqual(100.5);
expect(score!.comment).toBe(null);
});
});
}, 10000);
});
File diff suppressed because it is too large Load Diff
@@ -118,7 +118,7 @@ describe("/api/public/observations API Endpoint", () => {
eventType: "click",
target: "submit-button",
});
}, 15_000);
}, 20_000);
it("should filter observations by level parameter", async () => {
const traceId = randomUUID();
+194 -10
View File
@@ -18,16 +18,20 @@ const ScimUserSchema = z.object({
schemas: z.array(z.string()),
id: z.string(),
userName: z.string(),
name: z.object({
formatted: z.string().nullable(),
}),
emails: z.array(
z.object({
primary: z.boolean(),
value: z.string(),
type: z.string(),
}),
),
name: z
.object({
formatted: z.string().nullable(),
})
.optional(),
emails: z
.array(
z.object({
primary: z.boolean(),
value: z.string(),
type: z.string(),
}),
)
.optional(),
meta: z.object({
resourceType: z.string(),
created: z.string().optional(),
@@ -533,6 +537,45 @@ describe("SCIM API", () => {
expect(result.body.detail).toContain("userName is required");
});
it("should create a new user with specified role", async () => {
const uniqueEmail = `test.user.${randomUUID().substring(0, 8)}@example.com`;
const response = await makeZodVerifiedAPICall(
ScimUserSchema,
"POST",
"/api/public/scim/Users",
{
userName: uniqueEmail,
name: {
formatted: "Test User With Role",
},
emails: [
{
primary: true,
value: uniqueEmail,
type: "work",
},
],
active: true,
roles: ["ADMIN"],
},
createBasicAuthHeader(orgApiKey, orgSecretKey),
201,
);
expect(response.status).toBe(201);
expect(response.body.userName).toBe(uniqueEmail);
expect(response.body.name.formatted).toBe("Test User With Role");
testUserId = response.body.id;
// Verify the user was created with the specified role
const orgMemberships = await prisma.organizationMembership.findMany({
where: { userId: testUserId, orgId: orgId },
});
expect(orgMemberships.length).toBe(1);
expect(orgMemberships[0].role).toBe("ADMIN");
});
it("should return 409 when user with the same userName already exists", async () => {
const uniqueEmail = `test.user.${randomUUID().substring(0, 8)}@example.com`;
@@ -617,6 +660,147 @@ describe("SCIM API", () => {
});
});
describe("PUT /api/public/scim/Users/{id}", () => {
beforeEach(async () => {
// Create a test user
const uniqueEmail = `test.user.${randomUUID().substring(0, 8)}@example.com`;
const user = await prisma.user.create({
data: {
email: uniqueEmail,
name: "Test User",
},
});
await prisma.organizationMembership.create({
data: {
userId: user.id,
orgId: orgId,
role: "NONE",
},
});
testUserId = user.id;
});
it("should deactivate a user when active is false", async () => {
const response = await makeZodVerifiedAPICall(
ScimUserSchema,
"PUT",
`/api/public/scim/Users/${testUserId}`,
{
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
id: testUserId,
userName: "test.user@example.com",
active: false,
},
createBasicAuthHeader(orgApiKey, orgSecretKey),
200,
);
expect(response.status).toBe(200);
expect(response.body.id).toBe(testUserId);
// Verify the user was removed from the organization
const orgMemberships = await prisma.organizationMembership.findMany({
where: { userId: testUserId, orgId: orgId },
});
expect(orgMemberships.length).toBe(0);
});
it("should reactivate a user when active is true", async () => {
// First deactivate the user
await prisma.organizationMembership.deleteMany({
where: { userId: testUserId, orgId: orgId },
});
const response = await makeZodVerifiedAPICall(
ScimUserSchema,
"PUT",
`/api/public/scim/Users/${testUserId}`,
{
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
id: testUserId,
userName: "test.user@example.com",
active: true,
},
createBasicAuthHeader(orgApiKey, orgSecretKey),
200,
);
expect(response.status).toBe(200);
expect(response.body.id).toBe(testUserId);
// Verify the user was re-added to the organization with default role
const orgMemberships = await prisma.organizationMembership.findMany({
where: { userId: testUserId, orgId: orgId },
});
expect(orgMemberships.length).toBe(1);
expect(orgMemberships[0].role).toBe("NONE");
});
it("should reactivate a user with specified role", async () => {
// First deactivate the user
await prisma.organizationMembership.deleteMany({
where: { userId: testUserId, orgId: orgId },
});
const response = await makeZodVerifiedAPICall(
ScimUserSchema,
"PUT",
`/api/public/scim/Users/${testUserId}`,
{
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
id: testUserId,
userName: "test.user@example.com",
active: true,
roles: ["MEMBER"],
},
createBasicAuthHeader(orgApiKey, orgSecretKey),
200,
);
expect(response.status).toBe(200);
expect(response.body.id).toBe(testUserId);
// Verify the user was re-added to the organization with specified role
const orgMemberships = await prisma.organizationMembership.findMany({
where: { userId: testUserId, orgId: orgId },
});
expect(orgMemberships.length).toBe(1);
expect(orgMemberships[0].role).toBe("MEMBER");
});
it("should return 400 when SCIM schema is missing", async () => {
const result = await makeAPICall(
"PUT",
`/api/public/scim/Users/${testUserId}`,
{
id: testUserId,
userName: "test.user@example.com",
active: false,
},
createBasicAuthHeader(orgApiKey, orgSecretKey),
);
expect(result.status).toBe(400);
expect(result.body.detail).toContain("schemas");
});
it("should return 404 when user does not exist", async () => {
const nonExistentUserId = randomUUID();
const result = await makeAPICall(
"PUT",
`/api/public/scim/Users/${nonExistentUserId}`,
{
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
id: nonExistentUserId,
userName: "test.user@example.com",
active: false,
},
createBasicAuthHeader(orgApiKey, orgSecretKey),
);
expect(result.status).toBe(404);
expect(result.body.detail).toContain("User not found");
});
});
describe("DELETE /api/public/scim/Users/{id}", () => {
beforeEach(async () => {
// Create a test user
@@ -641,10 +641,9 @@ describe("/api/public/scores API Endpoint", () => {
totalPages: 1,
});
for (const val of getAllScore.body.data) {
expect(val).toMatchObject({
traceId: traceId,
trace: { tags: ["prod", "test"], userId: "user-name" },
});
expect(val.traceId).toBe(traceId);
expect(val.trace?.tags?.sort()).toEqual(["prod", "test"].sort());
expect(val.trace?.userId).toBe("user-name");
}
});
@@ -475,10 +475,9 @@ describe("/api/public/v2/scores API Endpoint", () => {
totalPages: 1,
});
for (const val of getAllScore.body.data) {
expect(val).toMatchObject({
traceId: traceId,
trace: { tags: ["prod", "test"], userId: "user-name" },
});
expect(val.traceId).toBe(traceId);
expect(val.trace?.tags?.sort()).toEqual(["prod", "test"].sort());
expect(val.trace?.userId).toBe("user-name");
}
});
@@ -4,10 +4,13 @@ import {
createObservation,
createObservationsCh,
createOrgProjectAndApiKey,
createScoresCh,
createSessionScore,
createTracesCh,
getSessionsWithMetrics,
} from "@langfuse/shared/src/server";
import { createTrace, getSessionsTable } from "@langfuse/shared/src/server";
import { type FilterState } from "@langfuse/shared";
describe("trpc.sessions", () => {
describe("GET sessions.all", () => {
@@ -385,4 +388,52 @@ describe("trpc.sessions", () => {
expect(sessions[0]?.trace_count).toBe(2);
expect(parseInt(sessions[0]?.duration as any)).toBe(1);
});
it("should GET correct session data with filters", async () => {
const project_id = v4();
const trace_id_with_score = v4();
const session_id_with_score = v4();
const trace_id_without_score = v4();
const session_id_without_score = v4();
const filterState: FilterState = [
{
type: "numberObject",
column: "Scores (numeric)",
key: "test",
operator: ">",
value: 0,
},
];
const trace_with_score = createTrace({
id: trace_id_with_score,
project_id,
session_id: session_id_with_score,
});
const trace_without_score = createTrace({
id: trace_id_without_score,
project_id,
session_id: session_id_without_score,
});
await createTracesCh([trace_with_score, trace_without_score]);
const score = createSessionScore({
project_id,
session_id: session_id_with_score,
name: "test",
value: 1,
data_type: "NUMERIC",
});
await createScoresCh([score]);
const tableRows = await getSessionsTable({
projectId: project_id,
filter: filterState,
limit: 10,
page: 0,
});
expect(tableRows).toHaveLength(1);
expect(tableRows[0].session_id).toEqual(session_id_with_score);
});
});
@@ -69,9 +69,9 @@ describe("traces trpc", () => {
expect(traceRes?.projectId).toEqual(projectId);
expect(traceRes?.name).toEqual(trace.name);
expect(traceRes?.timestamp).toEqual(new Date(trace.timestamp));
expect(traceRes?.tags).toEqual(trace.tags);
expect(traceRes?.input).toEqual(trace.input);
expect(traceRes?.output).toEqual(trace.output);
expect(traceRes?.tags?.sort()).toEqual(trace.tags?.sort());
expect(traceRes?.input).toBeNull();
expect(traceRes?.output).toBeNull();
expect(traceRes?.userId).toEqual(trace.user_id);
expect(traceRes?.sessionId).toEqual(trace.session_id);
});
@@ -32,7 +32,7 @@ describe("Traces table API test", () => {
expect(tableRows).toHaveLength(1);
expect(tableRows[0].id).toEqual(trace_id);
expect(tableRows[0].projectId).toEqual(project_id);
expect(tableRows[0].tags).toEqual(trace.tags);
expect(tableRows[0].tags.sort()).toEqual(trace.tags.sort());
expect(tableRows[0].name).toEqual(trace.name);
expect(tableRows[0].bookmarked).toEqual(trace.bookmarked);
expect(tableRows[0].release).toEqual(trace.release);
@@ -98,7 +98,7 @@ describe("Traces table API test", () => {
expect(tableRows).toHaveLength(1);
expect(tableRows[0].id).toEqual(trace_id);
expect(tableRows[0].projectId).toEqual(project_id);
expect(tableRows[0].tags).toEqual(trace.tags);
expect(tableRows[0].tags.sort()).toEqual(trace.tags.sort());
expect(tableRows[0].name).toEqual(trace.name);
expect(tableRows[0].bookmarked).toEqual(trace.bookmarked);
expect(tableRows[0].release).toEqual(trace.release);
@@ -24,12 +24,12 @@ import {
createObservationsCh,
} from "@langfuse/shared/src/server";
import { type QueryType } from "@/src/features/query/types";
import { executeQuery } from "@/src/features/dashboard/server/dashboard-router";
import {
transformToPivotTable,
type DatabaseRow,
} from "@/src/features/widgets/utils/pivot-table-utils";
import { QueryBuilder } from "@/src/features/query/server/queryBuilder";
import { executeQuery } from "@/src/features/query/server/queryExecutor";
describe("Dashboard Router - Pivot Table Integration", () => {
// Single project ID for all tests
+104 -429
View File
@@ -1,6 +1,6 @@
import { clickhouseClient } from "@langfuse/shared/src/server";
import { QueryBuilder } from "@/src/features/query/server/queryBuilder";
import { type QueryType } from "@/src/features/query/types";
import { executeQuery } from "@/src/features/query/server/queryExecutor";
import {
createTrace,
createObservation,
@@ -238,20 +238,7 @@ describe("queryBuilder", () => {
async (_name, query: QueryType) => {
const projectId = randomUUID();
// When
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
// Then
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
const result = await executeQuery(projectId, query);
expect(result).toBeDefined();
},
);
@@ -373,17 +360,8 @@ describe("queryBuilder", () => {
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Assert
expect(result.data).toHaveLength(1);
@@ -420,17 +398,8 @@ describe("queryBuilder", () => {
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Assert
expect(result.data).toHaveLength(2);
@@ -483,17 +452,8 @@ describe("queryBuilder", () => {
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Assert - should only return traces with name "qa-bot"
expect(result.data).toHaveLength(1);
@@ -529,17 +489,8 @@ describe("queryBuilder", () => {
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Assert
expect(result.data).toHaveLength(2);
@@ -597,17 +548,8 @@ describe("queryBuilder", () => {
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
expect(result.data).toHaveLength(4);
// Expect one entry for all so index order does not matter
@@ -662,17 +604,8 @@ describe("queryBuilder", () => {
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Assert - should only return traces with tag-a or tag-b
expect(result.data).toHaveLength(2);
@@ -742,17 +675,8 @@ describe("queryBuilder", () => {
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Assert - should only return traces with both tag-a and tag-b
expect(result.data).toHaveLength(1);
@@ -813,17 +737,8 @@ describe("queryBuilder", () => {
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Assert - should only return traces without tag-a or tag-b
expect(result.data).toHaveLength(2);
@@ -890,17 +805,8 @@ describe("queryBuilder", () => {
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Assert
expect(result.data).toHaveLength(2);
@@ -957,17 +863,8 @@ describe("queryBuilder", () => {
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Assert
expect(result.data).toHaveLength(4); // 2 names × 2 environments = 4 combinations
@@ -1030,17 +927,8 @@ describe("queryBuilder", () => {
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Assert
expect(result.data).toHaveLength(1);
@@ -1083,20 +971,14 @@ describe("queryBuilder", () => {
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const { query: compiledQuery } = queryBuilder.build(query, projectId);
// Verify ORDER BY clause is present in the query
expect(compiledQuery).toContain("ORDER BY name asc");
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
// Execute query
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Assert - results should be ordered by name alphabetically
expect(result.data).toHaveLength(3);
@@ -1134,20 +1016,14 @@ describe("queryBuilder", () => {
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const { query: compiledQuery } = queryBuilder.build(query, projectId);
// Verify ORDER BY clause is present in the query
expect(compiledQuery).toContain("ORDER BY sum_observationsCount desc");
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
// Execute query
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Assert - results should be ordered by observation count descending
expect(result.data).toHaveLength(3);
@@ -1187,25 +1063,17 @@ describe("queryBuilder", () => {
],
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const { query: compiledQuery } = queryBuilder.build(query, projectId);
// Verify ORDER BY clause is present in the query with both fields
expect(compiledQuery).toContain(
"ORDER BY environment asc, sum_observationsCount desc",
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
// Execute query
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Assert - results should be ordered by environment (asc) and then by observation count (desc)
expect(result.data).toHaveLength(4);
@@ -1263,22 +1131,15 @@ describe("queryBuilder", () => {
orderBy: null,
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const { query: compiledQuery } = queryBuilder.build(query, projectId);
// Verify ORDER BY clause includes default time dimension ordering
expect(compiledQuery).toContain("ORDER BY time_dimension asc");
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
// Execute query
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Results should be ordered by time dimension (ascending)
expect(result.data.length).toBeGreaterThan(0);
@@ -1323,22 +1184,15 @@ describe("queryBuilder", () => {
orderBy: null,
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const { query: compiledQuery } = queryBuilder.build(query, projectId);
// Verify ORDER BY clause includes default metric ordering (descending)
expect(compiledQuery).toContain("ORDER BY sum_observationsCount desc");
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
// Execute query
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Results should be ordered by observation count descending (default for metrics)
expect(result.data).toHaveLength(3);
@@ -1374,22 +1228,15 @@ describe("queryBuilder", () => {
orderBy: null,
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const { query: compiledQuery } = queryBuilder.build(query, projectId);
// Verify ORDER BY clause includes default dimension ordering (ascending)
expect(compiledQuery).toContain("ORDER BY name asc");
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
// Execute query
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Results should be ordered by name ascending (default for dimensions)
expect(result.data).toHaveLength(3);
@@ -1433,17 +1280,8 @@ describe("queryBuilder", () => {
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Assert
expect(result.data).toHaveLength(2);
@@ -1480,17 +1318,8 @@ describe("queryBuilder", () => {
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Assert
expect(result.data).toHaveLength(1);
@@ -1560,23 +1389,16 @@ describe("queryBuilder", () => {
orderBy: null,
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const { query: compiledQuery } = queryBuilder.build(query, projectId);
// Verify WITH FILL clause is present in the query
expect(compiledQuery).toContain("WITH FILL");
expect(compiledQuery).toContain("STEP INTERVAL 1 DAY");
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
// Execute query
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Expected to have 3 days in the result (including the filled gap)
expect(result.data.length).toBeGreaterThanOrEqual(3);
@@ -1713,17 +1535,8 @@ describe("queryBuilder", () => {
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Assert - should have 5 combinations (chat-today, chat-yesterday, embeddings-today, embeddings-yesterday, embeddings-dayBefore)
expect(result.data).toHaveLength(5);
@@ -1831,22 +1644,8 @@ describe("queryBuilder", () => {
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient({
clickhouse_settings: {
date_time_output_format: "iso",
},
}).query({
query: compiledQuery,
query_params: parameters,
})
).json();
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Verify we have results
expect(result.data.length).toBeGreaterThan(0);
@@ -1926,17 +1725,8 @@ describe("queryBuilder", () => {
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
expect(result.data).toHaveLength(2);
expect(result.data[0].name).toBe("trace-with-metadata-1");
@@ -2074,7 +1864,6 @@ describe("queryBuilder", () => {
orderBy: null,
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
@@ -2087,12 +1876,9 @@ describe("queryBuilder", () => {
expect(compiledQuery).toContain(": String}) = 0");
expect(Object.values(parameters)).toContain("CATEGORICAL");
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
// Execute query
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Assert
expect(result.data).toHaveLength(3); // accuracy, relevance, coherence
@@ -2199,17 +1985,8 @@ describe("queryBuilder", () => {
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Assert - should only return human scores
expect(result.data).toHaveLength(1);
@@ -2307,23 +2084,16 @@ describe("queryBuilder", () => {
orderBy: null,
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const { query: compiledQuery } = queryBuilder.build(query, projectId);
// Verify joins included
expect(compiledQuery).toContain("LEFT JOIN traces");
expect(compiledQuery).toContain("LEFT JOIN observations");
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
// Execute query
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Assert - should have 2 rows (1 for each trace/model combination)
expect(result.data).toHaveLength(2);
@@ -2441,17 +2211,8 @@ describe("queryBuilder", () => {
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Assert - should only return true scores
expect(result.data).toHaveLength(2);
@@ -2536,17 +2297,8 @@ describe("queryBuilder", () => {
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
expect(result.data).toHaveLength(2);
expect(result.data[0].name).toBe("score-premium");
@@ -2607,22 +2359,15 @@ describe("queryBuilder", () => {
orderBy: null,
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const { query: compiledQuery } = queryBuilder.build(query, projectId);
// Verify the compiled query contains filtering on name
expect(compiledQuery).toContain("scores_numeric.name");
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
// Execute query
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Assert - should only return scores with name "accuracy"
expect(result.data).toHaveLength(1);
@@ -2744,7 +2489,6 @@ describe("queryBuilder", () => {
orderBy: null,
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
@@ -2755,12 +2499,9 @@ describe("queryBuilder", () => {
expect(compiledQuery).toContain("data_type = {");
expect(Object.values(parameters)).toContain("CATEGORICAL");
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
// Execute query
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Assert - should have 4 rows for different name+value combinations
expect(result.data).toHaveLength(4);
@@ -2865,17 +2606,8 @@ describe("queryBuilder", () => {
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Assert - should only return auto-source scores
expect(result.data).toHaveLength(2);
@@ -3026,18 +2758,8 @@ describe("queryBuilder", () => {
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Assert
expect(result.data).toHaveLength(3);
@@ -3133,18 +2855,9 @@ describe("queryBuilder", () => {
orderBy: null,
};
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
// Execute query
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
expect(result.data).toHaveLength(1);
const row = result.data[0];
@@ -3206,18 +2919,9 @@ describe("queryBuilder", () => {
orderBy: null,
};
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
// Execute query
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
expect(result.data).toHaveLength(1);
const row = result.data[0];
@@ -3281,18 +2985,9 @@ describe("queryBuilder", () => {
orderBy: null,
};
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
// Execute query
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
expect(result.data).toHaveLength(1);
const row = result.data[0];
@@ -3366,17 +3061,8 @@ describe("queryBuilder", () => {
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
expect(result.data).toHaveLength(2);
expect(result.data[0].name).toBe("observation-basic");
@@ -3463,7 +3149,7 @@ describe("queryBuilder", () => {
const queryBuilder = new QueryBuilder(
customBinHistogramQuery.chartConfig,
);
const { query: compiledQuery, parameters } = queryBuilder.build(
const { query: compiledQuery } = queryBuilder.build(
customBinHistogramQuery,
projectId,
);
@@ -3472,12 +3158,9 @@ describe("queryBuilder", () => {
expect(compiledQuery).toContain("histogram(20)");
expect(compiledQuery).toContain("total_cost");
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
// Execute query
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, customBinHistogramQuery);
// Assert histogram results with custom bins
expect(result.data).toHaveLength(1);
@@ -3545,17 +3228,9 @@ describe("queryBuilder", () => {
orderBy: null,
};
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
// Execute query
const result: { data: Array<any> } = { data: [] };
result.data = await executeQuery(projectId, query);
// Verify the month is formatted as YYYY-MM
expect(result.data).toHaveLength(1);
@@ -6,7 +6,7 @@ import {
createObservationsCh,
} from "@langfuse/shared/src/server";
import { type QueryType } from "@/src/features/query/types";
import { executeQuery } from "@/src/features/dashboard/server/dashboard-router";
import { executeQuery } from "@/src/features/query/server/queryExecutor";
describe("selfServeDashboards", () => {
// Single project ID for all tests
@@ -1,8 +1,8 @@
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";
import { executeQuery } from "@/src/features/query/server/queryExecutor";
import { InvalidRequestError } from "@langfuse/shared";
/**
* Test suite for testing SQL injection vulnerabilities in the QueryBuilder
@@ -596,7 +596,7 @@ describe("QueryBuilder SQL Injection Tests", () => {
// Expect the executeQuery function to throw a TRPC error
// rather than allowing the injection
await expect(executeQuery(projectId, maliciousQuery)).rejects.toThrow(
TRPCError,
InvalidRequestError,
);
});
});
+14 -1
View File
@@ -119,6 +119,19 @@ export default function Layout(props: PropsWithChildren) {
// project info based on projectId in the URL
const { project, organization } = useQueryProjectOrOrganization();
// Helper function for precise path matching
const isPathActive = (routePath: string, currentPath: string): boolean => {
// Exact match
if (currentPath === routePath) return true;
// Only allow prefix matching if the route ends with a specific page (not just project root)
// This prevents /project/123 from matching /project/123/datasets
const isRoot = routePath.split("/").length <= 3;
if (isRoot) return false;
return currentPath.startsWith(routePath + "/");
};
const mapNavigation = (route: Route): NavigationItem | null => {
// Project-level routes
if (!routerProjectId && route.pathname.includes("[projectId]")) return null;
@@ -196,7 +209,7 @@ export default function Layout(props: PropsWithChildren) {
return {
...route,
url: url,
isActive: router.pathname === route.pathname,
isActive: isPathActive(route.pathname, router.pathname),
items:
items.length > 0
? (items as NavigationItem[]) // does not include null due to filter
+271
View File
@@ -0,0 +1,271 @@
import { memo } from "react";
import { type Row } from "@tanstack/react-table";
import { urlRegex } from "@langfuse/shared";
import { type JsonTableRow } from "@/src/components/table/utils/jsonExpansionUtils";
const MAX_STRING_LENGTH_FOR_LINK_DETECTION = 1500;
const MAX_CELL_DISPLAY_CHARS = 2000;
const SMALL_ARRAY_THRESHOLD = 5;
const ARRAY_PREVIEW_ITEMS = 3;
const OBJECT_PREVIEW_KEYS = 2;
const MONO_TEXT_CLASSES = "font-mono text-xs break-words";
const PREVIEW_TEXT_CLASSES = "italic text-gray-500 dark:text-gray-400";
function renderStringWithLinks(text: string): React.ReactNode {
if (text.length >= MAX_STRING_LENGTH_FOR_LINK_DETECTION) {
return text;
}
const localUrlRegex = new RegExp(urlRegex.source, "gi");
const parts = text.split(localUrlRegex);
const matches = text.match(localUrlRegex) || [];
const result: React.ReactNode[] = [];
let matchIndex = 0;
for (let i = 0; i < parts.length; i++) {
if (parts[i]) {
result.push(parts[i]);
}
if (matchIndex < matches.length) {
const url = matches[matchIndex];
result.push(
<a
key={`link-${matchIndex}`}
href={url}
target="_blank"
rel="noopener noreferrer"
className="hover:opacity-80"
onClick={(e) => e.stopPropagation()} // no row expansion when clicking links
>
{url}
</a>,
);
matchIndex++;
}
}
return result;
}
function getValueType(value: unknown): JsonTableRow["type"] {
if (value === null) return "null";
if (value === undefined) return "undefined";
if (Array.isArray(value)) return "array";
return typeof value as JsonTableRow["type"];
}
function renderArrayValue(arr: unknown[]): JSX.Element {
if (arr.length === 0) {
return <span className={PREVIEW_TEXT_CLASSES}>empty list</span>;
}
if (arr.length <= SMALL_ARRAY_THRESHOLD) {
// Show inline values for small arrays
const displayItems = arr
.map((item) => {
const itemType = getValueType(item);
if (itemType === "string") return `"${String(item)}"`;
if (itemType === "object" && item !== null) {
const obj = item as Record<string, unknown>;
const keys = Object.keys(obj);
if (keys.length === 0) return "{}";
if (keys.length <= OBJECT_PREVIEW_KEYS) {
const keyPreview = keys.map((k) => `"${k}": ...`).join(", ");
return `{${keyPreview}}`;
} else {
return `{"${keys[0]}": ...}`;
}
}
if (itemType === "array") return "...";
return String(item);
})
.join(", ");
return <span className={PREVIEW_TEXT_CLASSES}>[{displayItems}]</span>;
} else {
// Show truncated values for large arrays
const preview = arr
.slice(0, ARRAY_PREVIEW_ITEMS)
.map((item) => {
const itemType = getValueType(item);
if (itemType === "string") return `"${String(item)}"`;
if (itemType === "object" || itemType === "array") return "...";
return String(item);
})
.join(", ");
return (
<span className={PREVIEW_TEXT_CLASSES}>
[{preview}, ...{arr.length - ARRAY_PREVIEW_ITEMS} more]
</span>
);
}
}
function renderObjectValue(obj: Record<string, unknown>): JSX.Element {
const keys = Object.keys(obj);
if (keys.length === 0) {
return <span className={PREVIEW_TEXT_CLASSES}>empty object</span>;
}
return <span className={PREVIEW_TEXT_CLASSES}>{keys.length} items</span>;
}
function getValueStringLength(value: unknown): number {
if (typeof value === "string") {
return value.length;
}
try {
return JSON.stringify(value).length;
} catch {
return String(value).length;
}
}
function getTruncatedValue(value: string, maxChars: number): string {
if (value.length <= maxChars) {
return value;
}
const truncated = value.substring(0, maxChars);
const lastSpaceIndex = truncated.lastIndexOf(" ");
// Try to truncate at word boundary if possible
if (lastSpaceIndex > maxChars * 0.8) {
return truncated.substring(0, lastSpaceIndex) + "...";
}
return truncated + "...";
}
export const ValueCell = memo(
({
row,
expandedCells,
toggleCellExpansion,
}: {
row: Row<JsonTableRow>;
expandedCells: Set<string>;
toggleCellExpansion: (cellId: string) => void;
}) => {
const { value, type } = row.original;
const cellId = `${row.id}-value`;
const isCellExpanded = expandedCells.has(cellId);
const getDisplayValue = () => {
switch (type) {
case "string": {
const stringValue = String(value);
const needsTruncation = stringValue.length > MAX_CELL_DISPLAY_CHARS;
const displayValue =
needsTruncation && !isCellExpanded
? getTruncatedValue(stringValue, MAX_CELL_DISPLAY_CHARS)
: stringValue;
return {
content: (
<span className="whitespace-pre-line text-green-600 dark:text-green-400">
&quot;{renderStringWithLinks(displayValue)}&quot;
</span>
),
needsTruncation,
};
}
case "number":
return {
content: (
<span className="text-blue-600 dark:text-blue-400">
{String(value)}
</span>
),
needsTruncation: false,
};
case "boolean":
return {
content: (
<span className="text-orange-600 dark:text-orange-400">
{String(value)}
</span>
),
needsTruncation: false,
};
case "null":
return {
content: (
<span className="italic text-gray-500 dark:text-gray-400">
null
</span>
),
needsTruncation: false,
};
case "undefined":
return {
content: (
<span className="text-gray-500 dark:text-gray-400">
undefined
</span>
),
needsTruncation: false,
};
case "array": {
const arrayValue = value as unknown[];
// Arrays always show previews, never truncate
return {
content: renderArrayValue(arrayValue),
needsTruncation: false,
};
}
case "object": {
const objectValue = value as Record<string, unknown>;
// Objects always show previews, never truncate
return {
content: renderObjectValue(objectValue),
needsTruncation: false,
};
}
default: {
const stringValue = String(value);
const needsTruncation = stringValue.length > MAX_CELL_DISPLAY_CHARS;
const displayValue =
needsTruncation && !isCellExpanded
? getTruncatedValue(stringValue, MAX_CELL_DISPLAY_CHARS)
: stringValue;
return {
content: (
<span className="text-gray-600 dark:text-gray-400">
{displayValue}
</span>
),
needsTruncation,
};
}
}
};
const { content, needsTruncation } = getDisplayValue();
return (
<div className={`${MONO_TEXT_CLASSES} max-w-full`}>
{content}
{needsTruncation && !row.original.hasChildren && (
<div
className="inline cursor-pointer opacity-50"
onClick={(e) => {
e.stopPropagation();
toggleCellExpansion(cellId);
}}
>
{isCellExpanded
? "\n...collapse"
: `\n...expand (${getValueStringLength(value) - MAX_CELL_DISPLAY_CHARS} more characters)`}
</div>
)}
</div>
);
},
);
ValueCell.displayName = "ValueCell";
// Export utilities that might be needed elsewhere
export { getValueStringLength };
+14 -1
View File
@@ -67,6 +67,7 @@ interface DataTableProps<TData, TValue> {
pinFirstColumn?: boolean;
hidePagination?: boolean;
tableName: string;
getRowClassName?: (row: TData) => string;
}
export interface AsyncTableData<T> {
@@ -123,6 +124,7 @@ export function DataTable<TData extends object, TValue>({
pinFirstColumn = false,
hidePagination = false,
tableName,
getRowClassName,
}: DataTableProps<TData, TValue>) {
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const rowheighttw = getRowHeightTailwindClass(rowHeight, customRowHeights);
@@ -381,6 +383,7 @@ export function DataTable<TData extends object, TValue>({
help={help}
onRowClick={hasRowClickAction ? handleOnRowClick : undefined}
pinFirstColumn={pinFirstColumn}
getRowClassName={getRowClassName}
/>
)}
</Table>
@@ -430,6 +433,7 @@ interface TableBodyComponentProps<TData> {
help?: { description: string; href: string };
onRowClick?: (row: TData) => void;
pinFirstColumn?: boolean;
getRowClassName?: (row: TData) => string;
tableSnapshot?: {
tableDataUpdatedAt?: number;
columnVisibility?: VisibilityState;
@@ -441,10 +445,12 @@ interface TableBodyComponentProps<TData> {
function TableRowComponent<TData>({
row,
onRowClick,
getRowClassName,
children,
}: {
row: Row<TData>;
onRowClick?: (row: TData) => void;
getRowClassName?: (row: TData) => string;
children: React.ReactNode;
}) {
const router = useRouter();
@@ -462,6 +468,7 @@ function TableRowComponent<TData>({
"hover:bg-accent",
!!onRowClick ? "cursor-pointer" : "cursor-default",
selectedRowId && selectedRowId === row.id ? "bg-accent" : undefined,
getRowClassName?.(row.original),
)}
>
{children}
@@ -477,6 +484,7 @@ function TableBodyComponent<TData>({
help,
onRowClick,
pinFirstColumn = false,
getRowClassName,
}: TableBodyComponentProps<TData>) {
return (
<TableBody>
@@ -491,7 +499,12 @@ function TableBodyComponent<TData>({
</TableRow>
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRowComponent key={row.id} row={row} onRowClick={onRowClick}>
<TableRowComponent
key={row.id}
row={row}
onRowClick={onRowClick}
getRowClassName={getRowClassName}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
@@ -14,6 +14,12 @@ import { LangfuseIcon } from "@/src/components/LangfuseLogo";
import { UserCircle2Icon } from "lucide-react";
import { StatusBadge } from "@/src/components/layouts/status-badge";
import { DeactivateEvalConfig } from "@/src/features/evals/components/deactivate-config";
import { Switch } from "@/src/components/ui/switch";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { useState } from "react";
import { cn } from "@/src/utils/tailwind";
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
import { api } from "@/src/utils/api";
export const PeekViewEvaluatorConfigDetail = ({
projectId,
@@ -23,12 +29,16 @@ export const PeekViewEvaluatorConfigDetail = ({
row?: EvaluatorDataRow;
}) => {
const { peekId } = usePeekState();
const [isEditMode, setIsEditMode] = useState(false);
const utils = api.useUtils();
const { data: evalConfig } = usePeekEvalConfigData({
jobConfigurationId: peekId,
projectId,
});
const hasAccess = useHasProjectAccess({ projectId, scope: "evalJob:CUD" });
if (!evalConfig) {
return <Skeleton className="h-full w-full" />;
}
@@ -36,8 +46,8 @@ export const PeekViewEvaluatorConfigDetail = ({
return (
<div className="grid h-full flex-1 grid-rows-[auto,auto,1fr] gap-2 overflow-hidden p-3 contain-layout">
<div className="flex items-center justify-between">
<span className="max-h-fit text-lg font-medium">Configuration</span>
<div className="flex items-center gap-2">
<div className="flex flex-row items-center gap-2">
<span className="max-h-fit text-lg font-medium">Configuration</span>
<div className="flex items-center gap-2">
<StatusBadge
type={evalConfig.status.toLowerCase()}
@@ -49,7 +59,22 @@ export const PeekViewEvaluatorConfigDetail = ({
evalConfig={evalConfig}
/>
</div>
<span className="text-sm text-muted-foreground">View Only</span>
</div>
<div className="flex items-center gap-2">
<span
className={cn("text-sm", isEditMode ? "" : "text-muted-foreground")}
>
Edit Mode
</span>
<Switch
disabled={
!hasAccess ||
(evalConfig?.timeScope?.length === 1 &&
evalConfig.timeScope[0] === "EXISTING")
}
checked={isEditMode}
onCheckedChange={setIsEditMode}
/>
</div>
</div>
<CardDescription className="flex items-center text-sm">
@@ -76,7 +101,7 @@ export const PeekViewEvaluatorConfigDetail = ({
</CardDescription>
<div className="flex max-h-full w-full flex-col items-start justify-between space-y-2 overflow-y-auto pb-4">
<EvaluatorForm
key={evalConfig?.id}
key={`${evalConfig?.id}-${evalConfig?.updatedAt}-${isEditMode}`}
projectId={projectId}
evalTemplates={
evalConfig?.evalTemplate ? [evalConfig.evalTemplate] : []
@@ -89,9 +114,18 @@ export const PeekViewEvaluatorConfigDetail = ({
}
: undefined
}
disabled={true}
mode="edit"
disabled={!isEditMode}
shouldWrapVariables={true}
useDialog={false}
onFormSuccess={() => {
setIsEditMode(false);
utils.evals.invalidate();
showSuccessToast({
title: "Running Evaluator updated",
description: "The evaluator configuration has been updated.",
});
}}
/>
</div>
</div>
@@ -4,7 +4,7 @@ import useColumnVisibility from "@/src/features/column-visibility/hooks/useColum
import { api } from "@/src/utils/api";
import { type Prisma } from "@langfuse/shared/src/db";
import { useQueryParams, withDefault, NumberParam } from "use-query-params";
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
import { IOTableCell } from "../../ui/IOTableCell";
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
import useColumnOrder from "@/src/features/column-visibility/hooks/useColumnOrder";
@@ -1,7 +1,7 @@
import { api } from "@/src/utils/api";
import { DataTable } from "@/src/components/table/data-table";
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { TokenUsageBadge } from "@/src/components/token-usage-badge";
import { NumberParam, useQueryParams, withDefault } from "use-query-params";
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
@@ -15,6 +15,8 @@ import {
BatchExportTableName,
type ObservationType,
TableViewPresetTableName,
AnnotationQueueObjectType,
BatchActionType,
} from "@langfuse/shared";
import { cn } from "@/src/utils/tailwind";
import { LevelColors } from "@/src/components/level-colors";
@@ -22,7 +24,7 @@ import { numberFormatter, usdFormatter } from "@/src/utils/numbers";
import { observationsTableColsWithOptions } from "@langfuse/shared";
import { useOrderByState } from "@/src/features/orderBy/hooks/useOrderByState";
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
import { MemoizedIOTableCell } from "@/src/components/ui/CodeJsonViewer";
import { MemoizedIOTableCell } from "../../ui/IOTableCell";
import {
getScoreGroupColumnProps,
verifyAndPrefixScoreDataAgainstKeys,
@@ -43,7 +45,7 @@ import {
convertSelectedEnvironmentsToFilter,
} from "@/src/hooks/use-environment-filter";
import { Badge } from "@/src/components/ui/badge";
import { type Row } from "@tanstack/react-table";
import { type RowSelectionState, type Row } from "@tanstack/react-table";
import TableIdOrName from "@/src/components/table/table-id";
import { ItemBadge } from "@/src/components/ItemBadge";
import { Skeleton } from "@/src/components/ui/skeleton";
@@ -55,6 +57,11 @@ import { useTableViewManager } from "@/src/components/table/table-view-presets/h
import { useRouter } from "next/router";
import { useFullTextSearch } from "@/src/components/table/use-cases/useFullTextSearch";
import { type PeekViewProps } from "@/src/components/table/peek/hooks/usePeekView";
import { TableSelectionManager } from "@/src/features/table/components/TableSelectionManager";
import { useSelectAll } from "@/src/features/table/hooks/useSelectAll";
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
import { TableActionMenu } from "@/src/features/table/components/TableActionMenu";
import { type TableAction } from "@/src/features/table/types";
export type ObservationsTableRow = {
// Shown by default
@@ -117,10 +124,12 @@ export default function ObservationsTable({
const { viewId } = router.query;
const { setDetailPageList } = useDetailPageLists();
const [selectedRows, setSelectedRows] = useState<RowSelectionState>({});
const { searchQuery, searchType, setSearchQuery, setSearchType } =
useFullTextSearch();
const { selectAll, setSelectAll } = useSelectAll(projectId, "observations");
const [paginationState, setPaginationState] = useQueryParams({
pageIndex: withDefault(NumberParam, 0),
pageSize: withDefault(NumberParam, 50),
@@ -272,6 +281,19 @@ export default function ObservationsTable({
},
);
const addToQueueMutation = api.annotationQueueItems.createMany.useMutation({
onSuccess: (data) => {
showSuccessToast({
title: "Observations added to queue",
description: `Selected observations will be added to queue "${data.queueName}". This may take a minute.`,
link: {
href: `/project/${projectId}/annotation-queues/${data.queueId}`,
text: `View queue "${data.queueName}"`,
},
});
},
});
useEffect(() => {
if (generations.isSuccess) {
setDetailPageList(
@@ -302,7 +324,54 @@ export default function ObservationsTable({
);
};
const { selectActionColumn } = TableSelectionManager<ObservationsTableRow>({
projectId,
tableName: "observations",
setSelectedRows,
});
const handleAddToAnnotationQueue = async ({
projectId,
targetId,
}: {
projectId: string;
targetId: string;
}) => {
const selectedGenerationIds = Object.keys(selectedRows).filter(
(generationId) =>
generations.data?.generations.map((g) => g.id).includes(generationId),
);
await addToQueueMutation.mutateAsync({
projectId,
objectIds: selectedGenerationIds,
objectType: AnnotationQueueObjectType.OBSERVATION,
queueId: targetId,
isBatchAction: selectAll,
query: {
filter: filterState,
orderBy: orderByState,
},
});
setSelectedRows({});
};
const tableActions: TableAction[] = [
{
id: "observation-add-to-annotation-queue",
type: BatchActionType.Create,
label: "Add to Annotation Queue",
description: "Add selected observations to an annotation queue.",
targetLabel: "Annotation Queue",
execute: handleAddToAnnotationQueue,
accessCheck: {
scope: "annotationQueues:CUD",
},
},
];
const columns: LangfuseColumnDef<ObservationsTableRow>[] = [
selectActionColumn,
{
accessorKey: "startTime",
id: "startTime",
@@ -975,7 +1044,7 @@ export default function ObservationsTable({
setRowHeight={setRowHeight}
selectedOption={selectedOption}
setDateRangeAndOption={setDateRangeAndOption}
actionButtons={
actionButtons={[
<BatchExportTableButton
{...{
projectId,
@@ -986,13 +1055,37 @@ export default function ObservationsTable({
}}
tableName={BatchExportTableName.Observations}
key="batchExport"
/>
}
/>,
Object.keys(selectedRows).filter((generationId) =>
generations.data?.generations
.map((g) => g.id)
.includes(generationId),
).length > 0 ? (
<TableActionMenu
key="observations-multi-select-actions"
projectId={projectId}
actions={tableActions}
tableName={BatchExportTableName.Observations}
/>
) : null,
]}
environmentFilter={{
values: selectedEnvironments,
onValueChange: setSelectedEnvironments,
options: environmentOptions.map((env) => ({ value: env })),
}}
multiSelect={{
selectAll,
setSelectAll,
selectedRowIds: Object.keys(selectedRows).filter((generationId) =>
generations.data?.generations
.map((g) => g.id)
.includes(generationId),
),
setRowSelection: setSelectedRows,
totalCount,
...paginationState,
}}
/>
<DataTable
tableName={"observations"}
@@ -1018,6 +1111,8 @@ export default function ObservationsTable({
onChange: setPaginationState,
state: paginationState,
}}
rowSelection={selectedRows}
setRowSelection={setSelectedRows}
setOrderBy={setOrderByState}
orderBy={orderByState}
columnOrder={columnOrder}
@@ -1051,6 +1146,7 @@ const GenerationsDynamicCell = ({
traceId,
projectId,
startTime,
truncated: true,
},
{
enabled: typeof traceId === "string" && typeof observationId === "string",
@@ -10,7 +10,7 @@ import {
type Prisma,
type ConfigCategory,
} from "@langfuse/shared";
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
import { IOTableCell } from "../../ui/IOTableCell";
import { NumberParam, useQueryParams, withDefault } from "use-query-params";
import {
isBooleanDataType,
@@ -3,7 +3,7 @@ import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
import TableLink from "@/src/components/table/table-link";
import { type LangfuseColumnDef } from "@/src/components/table/types";
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
import { IOTableCell } from "../../ui/IOTableCell";
import { Avatar, AvatarImage } from "@/src/components/ui/avatar";
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
+18 -12
View File
@@ -38,7 +38,7 @@ import {
TableViewPresetTableName,
} from "@langfuse/shared";
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
import { MemoizedIOTableCell } from "@/src/components/ui/CodeJsonViewer";
import { MemoizedIOTableCell } from "../../ui/IOTableCell";
import {
getScoreGroupColumnProps,
verifyAndPrefixScoreDataAgainstKeys,
@@ -134,6 +134,7 @@ export type TracesTableProps = {
hideControls?: boolean;
externalFilterState?: FilterState;
externalDateRange?: TableDateRange;
limitRows?: number;
};
export default function TracesTable({
@@ -143,6 +144,7 @@ export default function TracesTable({
hideControls = false,
externalFilterState,
externalDateRange,
limitRows,
}: TracesTableProps) {
const utils = api.useUtils();
const [selectedRows, setSelectedRows] = useState<RowSelectionState>({});
@@ -246,8 +248,8 @@ export default function TracesTable({
...tracesAllCountFilter,
searchQuery: searchQuery,
searchType: searchType,
page: paginationState.pageIndex,
limit: paginationState.pageSize,
page: limitRows ? 0 : paginationState.pageIndex,
limit: limitRows ?? paginationState.pageSize,
orderBy: orderByState,
};
@@ -451,10 +453,10 @@ export default function TracesTable({
const enableSorting = !hideControls;
const columns: LangfuseColumnDef<TracesTableRow>[] = [
selectActionColumn,
...(hideControls
? []
: [
selectActionColumn,
{
accessorKey: "bookmarked",
header: undefined,
@@ -996,12 +998,12 @@ export default function TracesTable({
const [columnVisibility, setColumnVisibility] =
useColumnVisibility<TracesTableRow>(
`traceColumnVisibility-${projectId}${hideControls ? "-hideControls" : "-showControls"}`,
`traceColumnVisibility-${projectId}${hideControls ? "-hideControl" : "-showControls"}`,
columns,
);
const [columnOrder, setColumnOrder] = useColumnOrder<TracesTableRow>(
`traceColumnOrder-${projectId}${hideControls ? "-hideControls" : "-showControls"}`,
`traceColumnOrder-${projectId}${hideControls ? "-hideControl" : "-showControls"}`,
columns,
);
@@ -1193,11 +1195,15 @@ export default function TracesTable({
data: rows,
}
}
pagination={{
totalCount,
onChange: setPaginationState,
state: paginationState,
}}
pagination={
limitRows
? undefined
: {
totalCount,
onChange: setPaginationState,
state: paginationState,
}
}
setOrderBy={setOrderByState}
orderBy={orderByState}
rowSelection={selectedRows}
@@ -1229,7 +1235,7 @@ const TracesDynamicCell = ({
singleLine?: boolean;
}) => {
const trace = api.traces.byId.useQuery(
{ traceId, projectId, timestamp },
{ traceId, projectId, timestamp, truncated: true },
{
refetchOnMount: false, // prevents refetching loops
staleTime: 60 * 1000, // 1 minute
@@ -13,6 +13,10 @@ export function getRowChildren(row: JsonTableRow): JsonTableRow[] {
return row.subRows;
}
if (row.rawChildData) {
// Prevent infinite recursion by limiting depth; 25 levels of nesting should make a reasonable assumption
if (row.level > 25) {
return [];
}
return transformJsonToTableData(
row.rawChildData,
row.key,
@@ -171,6 +171,12 @@ const OtherSection = ({ details, isCost, formatValue }: OtherSectionProps) => {
if (otherEntries.length === 0) return null;
const otherTotal = otherEntries.reduce((acc, val) => {
if (typeof val[1] !== "number") return acc;
return acc + (val[1] ?? 0);
}, 0);
return (
<div className="flex flex-col gap-2">
<div className="flex justify-between border-b pb-2">
@@ -178,7 +184,7 @@ const OtherSection = ({ details, isCost, formatValue }: OtherSectionProps) => {
{isCost ? "Other cost" : "Other usage"}
</span>
<span className="text-right font-mono text-xs font-medium">
{formatValue(details.total ?? 0)}
{formatValue(otherTotal)}
</span>
</div>
{otherEntries.map(([key, value]) => (
+4 -3
View File
@@ -15,6 +15,7 @@ import { MarkdownJsonView } from "@/src/components/ui/MarkdownJsonView";
import { SubHeaderLabel } from "@/src/components/layouts/header";
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import useLocalStorage from "@/src/components/useLocalStorage";
export const IOPreview: React.FC<{
input?: Prisma.JsonValue;
@@ -47,9 +48,9 @@ export const IOPreview: React.FC<{
onOutputExpansionChange,
...props
}) => {
const [localCurrentView, setLocalCurrentView] = useState<"pretty" | "json">(
"pretty",
);
const [localCurrentView, setLocalCurrentView] = useLocalStorage<
"pretty" | "json"
>("jsonViewPreference", "pretty");
const selectedView = currentView ?? localCurrentView;
const capture = usePostHogClientCapture();
const input = deepParseJson(props.input);
@@ -59,7 +59,10 @@ export const ObservationPreview = ({
"view",
withDefault(StringParam, "preview"),
);
const [currentView, setCurrentView] = useState<"pretty" | "json">("pretty");
const [currentView, setCurrentView] = useLocalStorage<"pretty" | "json">(
"jsonViewPreference",
"pretty",
);
const capture = usePostHogClientCapture();
const [isPrettyViewAvailable, setIsPrettyViewAvailable] = useState(false);
const [emptySelectedConfigIds, setEmptySelectedConfigIds] = useLocalStorage<
+4 -1
View File
@@ -60,7 +60,10 @@ export const TracePreview = ({
"view",
withDefault(StringParam, "preview"),
);
const [currentView, setCurrentView] = useState<"pretty" | "json">("pretty");
const [currentView, setCurrentView] = useLocalStorage<"pretty" | "json">(
"jsonViewPreference",
"pretty",
);
const [isPrettyViewAvailable, setIsPrettyViewAvailable] = useState(false);
const [emptySelectedConfigIds, setEmptySelectedConfigIds] = useLocalStorage<
string[]
+3 -68
View File
@@ -1,4 +1,4 @@
import { memo, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { Button } from "@/src/components/ui/button";
import {
Check,
@@ -22,7 +22,7 @@ import { MarkdownJsonViewHeader } from "@/src/components/ui/MarkdownJsonView";
import { renderContentWithPromptButtons } from "@/src/features/prompts/components/renderContentWithPromptButtons";
import { copyTextToClipboard } from "@/src/utils/clipboard";
const IO_TABLE_CHAR_LIMIT = 10000;
export const IO_TABLE_CHAR_LIMIT = 10000;
export function JSONView(props: {
canEnableMarkdown?: boolean;
@@ -292,71 +292,6 @@ export function CodeView(props: {
);
}
export const IOTableCell = ({
data,
isLoading = false,
className,
singleLine = false,
}: {
data: unknown;
isLoading?: boolean;
className?: string;
singleLine?: boolean;
}) => {
if (isLoading) {
return <JsonSkeleton className="h-full w-full overflow-hidden px-2 py-1" />;
}
const stringifiedJson =
data !== null && data !== undefined ? stringifyJsonNode(data) : undefined;
// perf: truncate to IO_TABLE_CHAR_LIMIT characters as table becomes unresponsive attempting to render large JSONs with high levels of nesting
const shouldTruncate =
stringifiedJson && stringifiedJson.length > IO_TABLE_CHAR_LIMIT;
return (
<>
{singleLine ? (
<div
className={cn(
"ph-no-capture h-full w-full self-stretch overflow-hidden overflow-y-auto truncate rounded-sm border px-2 py-0.5",
className,
)}
>
{stringifiedJson}
</div>
) : shouldTruncate ? (
<div className="ph-no-capture grid h-full grid-cols-1">
<JSONView
json={
stringifiedJson.slice(0, IO_TABLE_CHAR_LIMIT) +
`...[truncated ${stringifiedJson.length - IO_TABLE_CHAR_LIMIT} characters]`
}
className={cn("h-full w-full self-stretch rounded-sm", className)}
codeClassName="py-1 px-2 min-h-0 h-full overflow-y-auto"
collapseStringsAfterLength={null} // in table, show full strings as row height is fixed
/>
<div className="text-xs text-muted-foreground">
Content was truncated.
</div>
</div>
) : (
<JSONView
json={data}
className={cn(
"ph-no-capture h-full w-full self-stretch rounded-sm",
className,
)}
codeClassName="py-1 px-2 min-h-0 h-full overflow-y-auto"
collapseStringsAfterLength={null} // in table, show full strings as row height is fixed
/>
)}
</>
);
};
export const MemoizedIOTableCell = memo(IOTableCell);
export const JsonSkeleton = ({
className,
numRows = 10,
@@ -382,7 +317,7 @@ export const JsonSkeleton = ({
};
// TODO: deduplicate with PrettyJsonView.tsx
function stringifyJsonNode(node: unknown) {
export function stringifyJsonNode(node: unknown) {
// return single string nodes without quotes
if (typeof node === "string") {
return node;
+72
View File
@@ -0,0 +1,72 @@
import {
JsonSkeleton,
stringifyJsonNode,
IO_TABLE_CHAR_LIMIT,
JSONView,
} from "@/src/components/ui/CodeJsonViewer";
import { cn } from "@/src/utils/tailwind";
import { memo } from "react";
export const IOTableCell = ({
data,
isLoading = false,
className,
singleLine = false,
}: {
data: unknown;
isLoading?: boolean;
className?: string;
singleLine?: boolean;
}) => {
if (isLoading) {
return <JsonSkeleton className="h-full w-full overflow-hidden px-2 py-1" />;
}
const stringifiedJson =
data !== null && data !== undefined ? stringifyJsonNode(data) : undefined;
// perf: truncate to IO_TABLE_CHAR_LIMIT characters as table becomes unresponsive attempting to render large JSONs with high levels of nesting
const shouldTruncate =
stringifiedJson && stringifiedJson.length > IO_TABLE_CHAR_LIMIT;
return (
<>
{singleLine ? (
<div
className={cn(
"ph-no-capture h-full w-full self-stretch overflow-hidden overflow-y-auto truncate rounded-sm border px-2 py-0.5",
className,
)}
>
{stringifiedJson}
</div>
) : shouldTruncate ? (
<div className="ph-no-capture grid h-full grid-cols-1">
<JSONView
json={
stringifiedJson.slice(0, IO_TABLE_CHAR_LIMIT) +
`...[truncated ${stringifiedJson.length - IO_TABLE_CHAR_LIMIT} characters]`
}
className={cn("h-full w-full self-stretch rounded-sm", className)}
codeClassName="py-1 px-2 min-h-0 h-full overflow-y-auto"
collapseStringsAfterLength={null} // in table, show full strings as row height is fixed
/>
<div className="text-xs text-muted-foreground">
Content was truncated.
</div>
</div>
) : (
<JSONView
json={data}
className={cn(
"ph-no-capture h-full w-full self-stretch rounded-sm",
className,
)}
codeClassName="py-1 px-2 min-h-0 h-full overflow-y-auto"
collapseStringsAfterLength={null} // in table, show full strings as row height is fixed
/>
)}
</>
);
};
export const MemoizedIOTableCell = memo(IOTableCell);
+97 -159
View File
@@ -1,6 +1,6 @@
import { useMemo, useState, useEffect, memo, useRef, useCallback } from "react";
import { useMemo, useState, useEffect, useRef, useCallback } from "react";
import { cn } from "@/src/utils/tailwind";
import { deepParseJson, urlRegex } from "@langfuse/shared";
import { deepParseJson } from "@langfuse/shared";
import { Skeleton } from "@/src/components/ui/skeleton";
import { type MediaReturnType } from "@/src/features/media/validation";
import { LangfuseMediaView } from "@/src/components/ui/LangfuseMediaView";
@@ -46,11 +46,10 @@ import {
type JsonTableRow,
transformJsonToTableData,
} from "@/src/components/table/utils/jsonExpansionUtils";
// Constants for array/object preview logic
const SMALL_ARRAY_THRESHOLD = 5;
const ARRAY_PREVIEW_ITEMS = 3;
const OBJECT_PREVIEW_KEYS = 2;
import {
ValueCell,
getValueStringLength,
} from "@/src/components/table/ValueCell";
// Constants for table layout
const INDENTATION_PER_LEVEL = 16;
@@ -63,7 +62,7 @@ const CELL_PADDING_X = 8; // px-2
const DEFAULT_MAX_ROWS = 20;
const DEEPEST_DEFAULT_EXPANSION_LEVEL = 10;
const MAX_STRING_LENGTH_FOR_LINK_DETECTION = 1500;
const MAX_CELL_DISPLAY_CHARS = 2000;
const ASSISTANT_TITLES = ["assistant", "Output", "model"];
const SYSTEM_TITLES = ["system", "Input"];
@@ -71,44 +70,6 @@ const SYSTEM_TITLES = ["system", "Input"];
const MONO_TEXT_CLASSES = "font-mono text-xs break-words";
const PREVIEW_TEXT_CLASSES = "italic text-gray-500 dark:text-gray-400";
function renderStringWithLinks(text: string): React.ReactNode {
if (text.length >= MAX_STRING_LENGTH_FOR_LINK_DETECTION) {
return text;
}
const localUrlRegex = new RegExp(urlRegex.source, "gi");
const parts = text.split(localUrlRegex);
const matches = text.match(localUrlRegex) || [];
const result: React.ReactNode[] = [];
let matchIndex = 0;
for (let i = 0; i < parts.length; i++) {
if (parts[i]) {
result.push(parts[i]);
}
if (matchIndex < matches.length) {
const url = matches[matchIndex];
result.push(
<a
key={`link-${matchIndex}`}
href={url}
target="_blank"
rel="noopener noreferrer"
className="hover:opacity-80"
onClick={(e) => e.stopPropagation()} // no row expansion when clicking links
>
{url}
</a>,
);
matchIndex++;
}
}
return result;
}
function getEmptyValueDisplay(value: unknown): string | null {
if (value === null) return "null";
if (value === undefined) return "undefined";
@@ -146,7 +107,16 @@ function isChatMLFormat(json: unknown): boolean {
if (Array.isArray(json)) {
const directArray = ChatMlArraySchema.safeParse(json);
if (directArray.success) return true;
if (directArray.success) {
// had some false positives, so we really check for role/content to validate ChatML
const hasRoleOrContent = json.some(
(item) =>
typeof item === "object" &&
item !== null &&
("role" in item || "content" in item),
);
return hasRoleOrContent;
}
}
if ("messages" in json && Array.isArray((json as any).messages)) {
@@ -255,6 +225,7 @@ function findOptimalExpansionLevel(
rows: JsonTableRow[],
currentLevel: number,
cumulativeCount: number,
visitedData = new WeakSet(),
): number {
const rowsAtThisLevel = rows.length;
const newCumulativeCount = cumulativeCount + rowsAtThisLevel;
@@ -270,8 +241,21 @@ function findOptimalExpansionLevel(
// Get all children for next level
const childRows: JsonTableRow[] = [];
for (const row of rows) {
if (row.hasChildren) {
if (row.hasChildren && row.rawChildData) {
if (typeof row.rawChildData !== "object" || row.rawChildData === null) {
continue; // Skip non-objects
}
// Skip if we've already processed this exact data to prevent cycles
if (visitedData.has(row.rawChildData)) {
continue;
}
// Mark data as visited
visitedData.add(row.rawChildData);
const children = getRowChildren(row);
childRows.push(...children);
}
@@ -285,129 +269,37 @@ function findOptimalExpansionLevel(
childRows,
currentLevel + 1,
newCumulativeCount,
visitedData,
);
}
return Math.max(0, findOptimalRecursively(data, 0, 0));
}
function renderArrayValue(arr: unknown[]): JSX.Element {
if (arr.length === 0) {
return <span className={PREVIEW_TEXT_CLASSES}>empty list</span>;
}
if (arr.length <= SMALL_ARRAY_THRESHOLD) {
// Show inline values for small arrays
const displayItems = arr
.map((item) => {
const itemType = getValueType(item);
if (itemType === "string") return `"${String(item)}"`;
if (itemType === "object" && item !== null) {
const obj = item as Record<string, unknown>;
const keys = Object.keys(obj);
if (keys.length === 0) return "{}";
if (keys.length <= OBJECT_PREVIEW_KEYS) {
const keyPreview = keys.map((k) => `"${k}": ...`).join(", ");
return `{${keyPreview}}`;
} else {
return `{"${keys[0]}": ...}`;
}
}
if (itemType === "array") return "...";
return String(item);
})
.join(", ");
return <span className={PREVIEW_TEXT_CLASSES}>[{displayItems}]</span>;
} else {
// Show truncated values for large arrays
const preview = arr
.slice(0, ARRAY_PREVIEW_ITEMS)
.map((item) => {
const itemType = getValueType(item);
if (itemType === "string") return `"${String(item)}"`;
if (itemType === "object" || itemType === "array") return "...";
return String(item);
})
.join(", ");
return (
<span className={PREVIEW_TEXT_CLASSES}>
[{preview}, ...{arr.length - ARRAY_PREVIEW_ITEMS} more]
</span>
);
}
}
function renderObjectValue(obj: Record<string, unknown>): JSX.Element {
const keys = Object.keys(obj);
if (keys.length === 0) {
return <span className={PREVIEW_TEXT_CLASSES}>empty object</span>;
}
return <span className={PREVIEW_TEXT_CLASSES}>{keys.length} items</span>;
}
const ValueCell = memo(({ row }: { row: Row<JsonTableRow> }) => {
const { value, type } = row.original;
const renderValue = () => {
switch (type) {
case "string": {
const stringValue = String(value);
return (
<span className="whitespace-pre-line text-green-600 dark:text-green-400">
&quot;{renderStringWithLinks(stringValue)}&quot;
</span>
);
}
case "number":
return (
<span className="text-blue-600 dark:text-blue-400">
{String(value)}
</span>
);
case "boolean":
return (
<span className="text-orange-600 dark:text-orange-400">
{String(value)}
</span>
);
case "null":
return (
<span className="italic text-gray-500 dark:text-gray-400">null</span>
);
case "undefined":
return (
<span className="text-gray-500 dark:text-gray-400">undefined</span>
);
case "array":
return renderArrayValue(value as unknown[]);
case "object":
return renderObjectValue(value as Record<string, unknown>);
default:
return (
<span className="text-gray-600 dark:text-gray-400">
{String(value)}
</span>
);
}
};
return (
<div className={`${MONO_TEXT_CLASSES} max-w-full`}>{renderValue()}</div>
);
});
ValueCell.displayName = "ValueCell";
function handleRowExpansion(
row: Row<JsonTableRow>,
onLazyLoadChildren?: (rowId: string) => void,
expandedCells?: Set<string>,
toggleCellExpansion?: (cellId: string) => void,
) {
// row expansion takes precedence over cell expansion
if (row.original.hasChildren) {
const originalRow = row.original;
if (originalRow.rawChildData && !originalRow.childrenGenerated) {
onLazyLoadChildren?.(originalRow.id);
}
row.toggleExpanded();
return;
}
// does the row have children, then expand row
const cellId = `${row.id}-value`;
const { value } = row.original;
const valueStringLength = getValueStringLength(value);
const needsCellExpansion = valueStringLength > MAX_CELL_DISPLAY_CHARS;
if (needsCellExpansion && expandedCells && toggleCellExpansion) {
toggleCellExpansion(cellId);
}
}
@@ -421,6 +313,8 @@ function JsonPrettyTable({
onLazyLoadChildren,
onForceUpdate,
smartDefaultsLevel,
expandedCells,
toggleCellExpansion,
}: {
data: JsonTableRow[];
expandAllRef?: React.MutableRefObject<(() => void) | null>;
@@ -433,6 +327,8 @@ function JsonPrettyTable({
onLazyLoadChildren?: (rowId: string) => void;
onForceUpdate?: () => void;
smartDefaultsLevel?: number | null;
expandedCells: Set<string>;
toggleCellExpansion: (cellId: string) => void;
}) {
const columns: LangfuseColumnDef<JsonTableRow, unknown>[] = [
{
@@ -459,7 +355,12 @@ function JsonPrettyTable({
size="sm"
onClick={(e) => {
e.stopPropagation();
handleRowExpansion(row, onLazyLoadChildren);
handleRowExpansion(
row,
onLazyLoadChildren,
expandedCells,
toggleCellExpansion,
);
}}
className="h-4 w-4 p-0"
>
@@ -485,7 +386,13 @@ function JsonPrettyTable({
accessorKey: "value",
header: "Value",
size: 65,
cell: ({ row }) => <ValueCell row={row} />,
cell: ({ row }) => (
<ValueCell
row={row}
expandedCells={expandedCells}
toggleCellExpansion={toggleCellExpansion}
/>
),
},
];
@@ -627,8 +534,24 @@ function JsonPrettyTable({
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
onClick={() => handleRowExpansion(row, onLazyLoadChildren)}
className={row.original.hasChildren ? "cursor-pointer" : ""}
onClick={() =>
handleRowExpansion(
row,
onLazyLoadChildren,
expandedCells,
toggleCellExpansion,
)
}
className={
row.original.hasChildren ||
(!row.original.hasChildren &&
row.original.type !== "array" &&
row.original.type !== "object" &&
getValueStringLength(row.original.value) >
MAX_CELL_DISPLAY_CHARS)
? "cursor-pointer"
: ""
}
>
{row.getVisibleCells().map((cell) => (
<TableCell
@@ -682,6 +605,7 @@ export function PrettyJsonView(props: {
const [expandedRowsWithChildren, setExpandedRowsWithChildren] = useState<
Set<string>
>(new Set());
const [expandedCells, setExpandedCells] = useState<Set<string>>(new Set());
const [, setForceUpdate] = useState(0);
// View's own state, lower precedence than optionally supplied external expansion state
@@ -888,6 +812,18 @@ export function PrettyJsonView(props: {
setForceUpdate((prev) => prev + 1);
}, []);
const toggleCellExpansion = useCallback((cellId: string) => {
setExpandedCells((prev) => {
const newSet = new Set(prev);
if (newSet.has(cellId)) {
newSet.delete(cellId);
} else {
newSet.add(cellId);
}
return newSet;
});
}, []);
const { onExternalExpansionChange } = props;
const handleTableExpandedChange = useCallback(
(
@@ -1030,6 +966,8 @@ export function PrettyJsonView(props: {
onLazyLoadChildren={handleLazyLoadChildren}
onForceUpdate={handleForceUpdate}
smartDefaultsLevel={null}
expandedCells={expandedCells}
toggleCellExpansion={toggleCellExpansion}
/>
)}
</div>
@@ -0,0 +1,214 @@
import { useState, useEffect, useRef } from "react";
import { Input } from "@/src/components/ui/input";
import { Button } from "@/src/components/ui/button";
import { Search, X, MoreHorizontal } from "lucide-react";
interface MultiSelectComboboxProps<T> {
selectedItems: T[];
onItemsChange: (items: T[]) => void;
searchQuery: string;
onSearchChange: (query: string) => void;
searchResults: T[];
isLoading?: boolean;
placeholder?: string;
hasMoreResults?: boolean;
renderItem: (
item: T,
isSelected: boolean,
onToggle: () => void,
) => React.ReactNode;
renderSelectedItem: (item: T, onRemove: () => void) => React.ReactNode;
getItemKey: (item: T) => string;
disabled?: boolean;
}
export function MultiSelectCombobox<T>({
selectedItems,
onItemsChange,
searchQuery,
onSearchChange,
searchResults,
isLoading = false,
placeholder = "Search...",
hasMoreResults = false,
renderItem,
renderSelectedItem,
getItemKey,
disabled = false,
}: MultiSelectComboboxProps<T>) {
const [isInputFocused, setIsInputFocused] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
const [previousResults, setPreviousResults] = useState<T[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// Handle focus/blur for dropdown visibility
const handleInputFocus = () => {
setIsInputFocused(true);
setShowDropdown(true);
};
const handleInputBlur = () => {
setIsInputFocused(false);
// Delay hiding dropdown to allow clicking on dropdown items
setTimeout(() => {
if (!isInputFocused) {
setShowDropdown(false);
}
}, 200);
};
// Auto-scroll to input when items are added/removed
useEffect(() => {
if (inputRef.current && containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [selectedItems.length]);
// Update previous results when new data arrives (not loading)
useEffect(() => {
if (!isLoading && searchResults.length > 0) {
setPreviousResults(searchResults);
}
}, [isLoading, searchResults]);
// Handle click outside to close dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
containerRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
!containerRef.current.contains(event.target as Node)
) {
setShowDropdown(false);
setIsInputFocused(false);
}
};
if (showDropdown) {
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}
}, [showDropdown]);
const handleItemToggle = (item: T) => {
const itemKey = getItemKey(item);
const isSelected = selectedItems.some(
(selected) => getItemKey(selected) === itemKey,
);
if (isSelected) {
onItemsChange(
selectedItems.filter((selected) => getItemKey(selected) !== itemKey),
);
} else {
onItemsChange([...selectedItems, item]);
}
// Keep dropdown open after item toggle
setShowDropdown(true);
setIsInputFocused(true);
};
const handleItemRemove = (item: T) => {
const itemKey = getItemKey(item);
onItemsChange(
selectedItems.filter((selected) => getItemKey(selected) !== itemKey),
);
};
return (
<div className="space-y-2">
{/* Custom Input with Embedded Pills */}
<div className="relative">
<div
ref={containerRef}
className="flex max-h-14 min-h-9 w-full overflow-y-auto rounded-md border border-input bg-background text-xs"
>
<Search className="absolute left-2 top-2.5 z-10 h-4 w-4 text-muted-foreground" />
<div className="flex max-h-full flex-1 flex-wrap items-center gap-1 pl-8">
{/* Selected Items Pills */}
{selectedItems.map((item) => (
<div key={getItemKey(item)}>
{renderSelectedItem(item, () => handleItemRemove(item))}
</div>
))}
{/* Search Input */}
<Input
ref={inputRef}
type="text"
placeholder={selectedItems.length === 0 ? placeholder : ""}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
disabled={disabled}
className="min-w-24 flex-1 border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground"
/>
</div>
{searchQuery && (
<Button
variant="ghost"
size="sm"
className="absolute right-2 top-1 h-7 w-7 p-0"
onClick={() => onSearchChange("")}
>
<X className="h-3 w-3" />
</Button>
)}
</div>
</div>
{/* Search Results Dropdown */}
{showDropdown && (
<div ref={dropdownRef} className="relative">
{searchResults.length > 0 ||
(isLoading && previousResults.length > 0) ? (
<div
className="absolute top-0 z-10 max-h-48 w-full overflow-y-auto rounded-md border bg-background shadow-md"
onMouseDown={(e) => e.preventDefault()}
>
{(isLoading && previousResults.length > 0
? previousResults
: searchResults
).map((item, index, array) => (
<div key={getItemKey(item)}>
{renderItem(
item,
selectedItems.some(
(selected) => getItemKey(selected) === getItemKey(item),
),
() => handleItemToggle(item),
)}
{(index < array.length - 1 || hasMoreResults) && (
<div className="border-b border-border/50" />
)}
</div>
))}
{hasMoreResults && (
<div className="flex items-center gap-3 px-3 py-2 text-muted-foreground">
<MoreHorizontal className="h-4 w-4" />
<div className="min-w-0 flex-1">
<p className="text-xs italic">
More results available, refine your search
</p>
</div>
</div>
)}
</div>
) : (
<div className="absolute top-0 z-10 w-full rounded-md border bg-background py-6 text-center text-xs text-muted-foreground shadow-md">
{searchQuery
? `No results found for "${searchQuery}"`
: "No results available"}
</div>
)}
</div>
)}
</div>
);
}
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.89.0";
export const VERSION = "v3.95.2";
@@ -2,7 +2,7 @@ import { DataTable } from "@/src/components/table/data-table";
import { type LangfuseColumnDef } from "@/src/components/table/types";
import { api } from "@/src/utils/api";
import { useQueryParams, withDefault, NumberParam } from "use-query-params";
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
import { IOTableCell } from "@/src/components/ui/IOTableCell";
import {
Avatar,
AvatarFallback,
@@ -72,7 +72,9 @@ export const OktaProviderSchema = base.extend({
.object({
clientId: z.string(),
clientSecret: z.string(),
issuer: z.string(),
issuer: z.string().startsWith("https://", {
message: "Okta issuer must start with https://",
}),
allowDangerousEmailAccountLinking: z.boolean().optional().default(false),
})
.nullish(),
+2 -2
View File
@@ -249,7 +249,7 @@ export const env = createEnv({
LANGFUSE_INIT_ORG_CLOUD_PLAN: z.string().optional(), // for use in CI
LANGFUSE_INIT_PROJECT_ID: z.string().optional(),
LANGFUSE_INIT_PROJECT_NAME: z.string().optional(),
LANGFUSE_INIT_PROJECT_RETENTION: z.number().int().gte(3).optional(),
LANGFUSE_INIT_PROJECT_RETENTION: z.coerce.number().int().gte(3).optional(),
LANGFUSE_INIT_PROJECT_PUBLIC_KEY: z.string().optional(),
LANGFUSE_INIT_PROJECT_SECRET_KEY: z.string().optional(),
LANGFUSE_INIT_USER_EMAIL: z
@@ -257,7 +257,7 @@ export const env = createEnv({
.optional(),
LANGFUSE_INIT_USER_NAME: z.string().optional(),
LANGFUSE_INIT_USER_PASSWORD: z.string().optional(),
LANGFUSE_MAX_HISTORIC_EVAL_CREATION_LIMIT: z
LANGFUSE_MAX_HISTORIC_EVAL_CREATION_LIMIT: z.coerce
.number()
.positive()
.default(50_000),
@@ -34,6 +34,7 @@ type RowData = {
countPendingItems: number;
scoreConfigs: { id: string; name: string; dataType: ScoreDataType }[];
createdAt: string;
isAssigned: boolean;
};
export function AnnotationQueuesTable({ projectId }: { projectId: string }) {
@@ -213,6 +214,7 @@ export function AnnotationQueuesTable({ projectId }: { projectId: string }) {
createdAt: item.createdAt.toLocaleString(),
countCompletedItems: item.countCompletedItems,
countPendingItems: item.countPendingItems,
isAssigned: item.isCurrentUserAssigned,
};
};
@@ -265,6 +267,9 @@ export function AnnotationQueuesTable({ projectId }: { projectId: string }) {
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
rowHeight={rowHeight}
getRowClassName={(row) =>
row.isAssigned ? "bg-primary/5 border-l-4 border-l-primary/40" : ""
}
/>
</>
);
@@ -26,8 +26,8 @@ import { useForm } from "react-hook-form";
import { Form } from "@/src/components/ui/form";
import { Textarea } from "@/src/components/ui/textarea";
import {
type CreateQueue,
CreateQueueData,
type CreateQueueWithAssignments,
CreateQueueWithAssignmentsData,
type ValidatedScoreConfig,
} from "@langfuse/shared";
import { api } from "@/src/utils/api";
@@ -39,6 +39,14 @@ import { useEntitlementLimit } from "@/src/features/entitlements/hooks";
import { ActionButton } from "@/src/components/ActionButton";
import { DropdownMenuItem } from "@/src/components/ui/dropdown-menu";
import { useUniqueNameValidation } from "@/src/hooks/useUniqueNameValidation";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/src/components/ui/collapsible";
import { ChevronDown, ChevronRight } from "lucide-react";
import { UserAssignmentSection } from "@/src/features/annotation-queues/components/UserAssignmentSection";
import { showErrorToast } from "@/src/features/notifications/showErrorToast";
export const CreateOrEditAnnotationQueueButton = ({
projectId,
@@ -52,21 +60,26 @@ export const CreateOrEditAnnotationQueueButton = ({
size?: ButtonProps["size"];
}) => {
const [isOpen, setIsOpen] = useState(false);
const hasAccess = useHasProjectAccess({
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const hasQueueAccess = useHasProjectAccess({
projectId: projectId,
scope: "annotationQueues:CUD",
});
const hasQueueAssignmentsReadAccess = useHasProjectAccess({
projectId: projectId,
scope: "annotationQueueAssignments:read",
});
const queueLimit = useEntitlementLimit("annotation-queue-count");
const router = useRouter();
const capture = usePostHogClientCapture();
const queueQuery = api.annotationQueues.byId.useQuery(
{ projectId, queueId: queueId as string },
{ enabled: !!queueId && hasAccess },
{ enabled: !!queueId && hasQueueAccess },
);
const form = useForm({
resolver: zodResolver(CreateQueueData),
resolver: zodResolver(CreateQueueWithAssignmentsData),
});
useEffect(() => {
@@ -77,11 +90,13 @@ export const CreateOrEditAnnotationQueueButton = ({
scoreConfigIds: queueQuery.data.scoreConfigs.map(
(config: ValidatedScoreConfig) => config.id,
),
newAssignmentUserIds: [],
});
} else {
form.reset({
name: "",
scoreConfigIds: [],
newAssignmentUserIds: [],
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -89,24 +104,14 @@ export const CreateOrEditAnnotationQueueButton = ({
const utils = api.useUtils();
const createQueueMutation = api.annotationQueues.create.useMutation({
onSuccess: async () => {
await Promise.all([utils.annotationQueues.invalidate()]);
form.reset();
setIsOpen(false);
},
});
const editQueueMutation = api.annotationQueues.update.useMutation({
onSuccess: async () => {
await Promise.all([utils.annotationQueues.invalidate()]);
form.reset();
setIsOpen(false);
},
});
const createQueueMutation = api.annotationQueues.create.useMutation();
const editQueueMutation = api.annotationQueues.update.useMutation();
const createQueueAssignmentsMutation =
api.annotationQueueAssignments.createMany.useMutation();
const queueCountData = api.annotationQueues.count.useQuery(
{ projectId },
{ enabled: hasAccess },
{ enabled: hasQueueAccess },
);
const configsData = api.scoreConfigs.all.useQuery(
@@ -114,13 +119,13 @@ export const CreateOrEditAnnotationQueueButton = ({
projectId,
},
{
enabled: hasAccess && isOpen,
enabled: hasQueueAccess && isOpen,
},
);
const allQueueNamesAndIds = api.annotationQueues.allNamesAndIds.useQuery(
{ projectId },
{ enabled: hasAccess && !queueId },
{ enabled: hasQueueAccess && !queueId },
);
const allQueueNames = useMemo(() => {
@@ -138,18 +143,54 @@ export const CreateOrEditAnnotationQueueButton = ({
const configs = configsData.data?.configs ?? [];
const onSubmit = (data: CreateQueue) => {
if (queueId) {
editQueueMutation.mutateAsync({
...data,
projectId,
queueId,
});
} else {
createQueueMutation.mutateAsync({
...data,
projectId,
});
const onSubmit = async (data: CreateQueueWithAssignments) => {
try {
// Step 1: Create or update the queue
let queueResponse;
if (queueId) {
// Update existing queue
queueResponse = await editQueueMutation.mutateAsync({
name: data.name,
description: data.description,
scoreConfigIds: data.scoreConfigIds,
projectId,
queueId,
});
} else {
// Create new queue
queueResponse = await createQueueMutation.mutateAsync({
name: data.name,
description: data.description,
scoreConfigIds: data.scoreConfigIds,
projectId,
});
}
// Step 2: Handle assignment if provided
if (data.newAssignmentUserIds && data.newAssignmentUserIds.length > 0) {
const targetQueueId = queueId || queueResponse.id;
await createQueueAssignmentsMutation.mutateAsync({
projectId,
queueId: targetQueueId,
userIds: data.newAssignmentUserIds,
});
}
// Step 3: Success handling
await Promise.all([
utils.annotationQueues.invalidate(),
utils.annotationQueueAssignments.invalidate(),
]);
form.reset();
setIsOpen(false);
// capture posthog event
} catch (error) {
showErrorToast(
"Operation failed",
"Failed to create or update queue or assign users. Please try again.",
);
}
};
@@ -183,7 +224,7 @@ export const CreateOrEditAnnotationQueueButton = ({
<PlusIcon className="h-4 w-4" aria-hidden="true" />
)
}
hasAccess={hasAccess}
hasAccess={hasQueueAccess}
limitValue={queueCountData.data}
limit={queueLimit}
size={size}
@@ -258,6 +299,7 @@ export const CreateOrEditAnnotationQueueButton = ({
<MultiSelectKeyValues
placeholder="Value"
align="end"
variant="outline"
className="grid grid-cols-[auto,1fr,auto,auto] gap-2"
onValueChange={handleOnValueChange}
options={configs
@@ -299,14 +341,80 @@ export const CreateOrEditAnnotationQueueButton = ({
</FormItem>
)}
/>
{/* Advanced Section */}
<FormField
control={form.control}
name="newAssignmentUserIds"
render={({ field }) => (
<FormItem>
<FormLabel>Advanced Settings</FormLabel>
<div className="mt-1 rounded-md border">
<Collapsible
open={isAdvancedOpen && hasQueueAssignmentsReadAccess}
onOpenChange={(open) => {
if (!hasQueueAssignmentsReadAccess) {
setIsAdvancedOpen(false);
} else {
setIsAdvancedOpen(open);
}
}}
>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
className="group flex w-full items-center justify-between px-3 py-2.5 text-left hover:bg-transparent"
>
<div className="flex items-center gap-2">
{isAdvancedOpen ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-sm font-medium">
User Assignment
</span>
</div>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="border-t border-border/20 px-3 pb-3 pt-1">
{hasQueueAssignmentsReadAccess && (
<>
<FormControl>
<UserAssignmentSection
projectId={projectId}
queueId={queueId}
selectedUserIds={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</>
)}
</CollapsibleContent>
</Collapsible>
</div>
</FormItem>
)}
/>
</DialogBody>
<DialogFooter>
<Button
type="submit"
className="text-xs"
disabled={!!form.formState.errors.name}
disabled={
!!form.formState.errors.name ||
createQueueMutation.isLoading ||
editQueueMutation.isLoading ||
createQueueAssignmentsMutation.isLoading
}
>
{queueId ? "Save" : "Create"} queue
{createQueueMutation.isLoading ||
editQueueMutation.isLoading ||
createQueueAssignmentsMutation.isLoading
? "Processing..."
: `${queueId ? "Save" : "Create"} queue`}
</Button>
</DialogFooter>
</form>
@@ -0,0 +1,165 @@
import { api } from "@/src/utils/api";
import { MoreHorizontal, X } from "lucide-react";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { Button } from "@/src/components/ui/button";
import { MultiSelectCombobox } from "@/src/components/ui/multi-select-combobox";
import { useUserSearch } from "@/src/features/annotation-queues/hooks/useUserSearch";
import { useSelectedUsers } from "@/src/features/annotation-queues/hooks/useSelectedUsers";
interface UserAssignmentSectionProps {
projectId: string;
selectedUserIds: string[];
onChange: (userIds: string[]) => void;
queueId?: string;
}
export const UserAssignmentSection = ({
projectId,
selectedUserIds,
onChange,
queueId,
}: UserAssignmentSectionProps) => {
const hasQueueAssignmentsReadAccess = useHasProjectAccess({
projectId: projectId,
scope: "annotationQueueAssignments:read",
});
const hasQueueAssignmentWriteAccess = useHasProjectAccess({
projectId: projectId,
scope: "annotationQueueAssignments:CUD",
});
// Get current assigned users
const queueAssignmentsQuery =
api.annotationQueueAssignments.byQueueId.useQuery(
{ projectId, queueId: queueId as string },
{ enabled: !!queueId && hasQueueAssignmentsReadAccess },
);
// Combine selected users and assigned users for exclusion
const assignedUserIds =
queueAssignmentsQuery.data?.assignments.map((user: any) => user.id) || [];
const excludeUserIds = [...new Set([...selectedUserIds, ...assignedUserIds])];
const userSearch = useUserSearch({
projectId,
excludeUserIds,
});
const { selectedUsers } = useSelectedUsers({
projectId,
selectedUserIds,
});
// Handle user selection changes
const handleUsersChange = (users: typeof userSearch.searchResults) => {
const userIds = users.map((user) => user.id);
onChange(userIds);
};
// Check if there are more assigned users than shown
const hasMoreAssignedUsers =
queueAssignmentsQuery.data &&
queueAssignmentsQuery.data.totalCount >
queueAssignmentsQuery.data.assignments.length;
return (
<div className="space-y-4">
{/* User Selection Combobox */}
<MultiSelectCombobox
selectedItems={selectedUsers}
onItemsChange={handleUsersChange}
searchQuery={userSearch.searchQuery}
onSearchChange={userSearch.setSearchQuery}
searchResults={userSearch.searchResults}
isLoading={userSearch.isLoading}
disabled={!hasQueueAssignmentWriteAccess}
placeholder="Search users to add..."
hasMoreResults={userSearch.hasMoreResults}
getItemKey={(user) => user.id}
renderSelectedItem={(user, onRemove) => (
<div className="flex flex-shrink-0 items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs">
<span className="max-w-32 truncate">{user.name || user.email}</span>
<Button
variant="ghost"
size="sm"
className="h-4 w-4 p-0 hover:bg-muted-foreground/20"
onClick={onRemove}
>
<X className="h-3 w-3" />
</Button>
</div>
)}
renderItem={(user, isSelected, onToggle) => (
<div
className="flex cursor-pointer items-center gap-3 px-3 py-2 transition-colors hover:bg-muted/50"
onClick={onToggle}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
<p className="truncate text-xs font-medium">
{user.name || "Unnamed User"}
</p>
<p className="truncate text-xs text-muted-foreground">
{user.email}
</p>
</div>
</div>
{isSelected && (
<div className="text-xs text-muted-foreground"></div>
)}
</div>
)}
/>
{/* Assigned Users Section */}
{queueAssignmentsQuery.data &&
queueAssignmentsQuery.data?.totalCount > 0 && (
<div className="space-y-2">
<h4 className="text-sm text-muted-foreground">
Assigned to ({queueAssignmentsQuery.data?.totalCount})
</h4>
<div className="max-h-32 overflow-y-auto rounded-md border bg-background">
{queueAssignmentsQuery.data?.assignments.map(
(user: any, index: number) => (
<div key={user.id}>
<div className="flex items-center justify-between gap-3 px-3 py-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
<p className="truncate text-xs font-medium">
{user.name || "Unnamed User"}
</p>
<p className="truncate text-xs text-muted-foreground">
{user.email}
</p>
</div>
</div>
{/* <Button variant="ghost" size="icon-sm">
<X className="h-3 w-3" />
</Button> */}
</div>
{(index <
queueAssignmentsQuery.data?.assignments.length - 1 ||
hasMoreAssignedUsers) && (
<div className="border-b border-border/50" />
)}
</div>
),
)}
{hasMoreAssignedUsers && (
<div className="flex items-center gap-3 px-3 py-2 text-muted-foreground">
<MoreHorizontal className="h-4 w-4" />
<div className="min-w-0 flex-1">
<p className="text-xs italic">
{queueAssignmentsQuery.data.totalCount -
queueAssignmentsQuery.data.assignments.length}{" "}
more assigned users
</p>
</div>
</div>
)}
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,37 @@
import { api } from "@/src/utils/api";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
interface UseSelectedUsersProps {
projectId: string;
selectedUserIds: string[];
}
export function useSelectedUsers({
projectId,
selectedUserIds,
}: UseSelectedUsersProps) {
const hasProjectMembersReadAccess = useHasProjectAccess({
projectId: projectId,
scope: "projectMembers:read",
});
// Get all users without search filter to maintain selected users data
const allUsersForPills = api.members.byProjectId.useQuery(
{
projectId,
},
{
enabled: hasProjectMembersReadAccess && selectedUserIds.length > 0,
},
);
const selectedUsers =
allUsersForPills.data?.users.filter((user) =>
selectedUserIds.includes(user.id),
) || [];
return {
selectedUsers,
isLoading: allUsersForPills.isLoading,
};
}
@@ -0,0 +1,59 @@
import { useState, useEffect } from "react";
import { api } from "@/src/utils/api";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
interface UseUserSearchProps {
projectId: string;
excludeUserIds?: string[];
}
export function useUserSearch({
projectId,
excludeUserIds,
}: UseUserSearchProps) {
const [searchQuery, setSearchQuery] = useState("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
const hasProjectMembersReadAccess = useHasProjectAccess({
projectId: projectId,
scope: "projectMembers:read",
});
// Debounce search query
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchQuery(searchQuery);
}, 300);
return () => clearTimeout(timer);
}, [searchQuery]);
// Get search results
const searchResults = api.members.byProjectId.useQuery(
{
projectId,
searchQuery: debouncedSearchQuery || undefined,
page: 0,
limit: 50,
excludeUserIds:
excludeUserIds && excludeUserIds.length > 0
? excludeUserIds
: undefined,
},
{
enabled: hasProjectMembersReadAccess,
},
);
const hasMoreResults =
searchResults.data &&
searchResults.data.totalCount > searchResults.data.users.length;
return {
searchQuery,
setSearchQuery,
searchResults: searchResults.data?.users || [],
isLoading: searchResults.isLoading,
hasMoreResults: hasMoreResults || false,
};
}

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