Compare commits

...
145 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
Nimar bb9d118853 chore: release v3.89.0 2025-07-30 16:09:51 +02:00
marliessophieandGitHub df57ff60d6 feat(dataset-run-items): support reads for public APIs (#8179)
* 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
2025-07-30 13:52:09 +00:00
NimarandGitHub bae2c5a65d fix(json-table): render links clickable (#8209)
* fix(json-table): render links clickable

* export
2025-07-30 15:48:01 +02:00
Steffen SchmitzandGitHub 3efa696513 fix(sessions): allow filter combination of environment and session_id (#8210) 2025-07-30 13:20:04 +00:00
Hassieb PakzadandGitHub 58ebf006b8 fix(llm-conenctions): drop system message from test call (#8189) 2025-07-30 11:49:31 +00:00
Thorsten SpiekerandGitHub 16743363dc fix: slack channel search component (#8208) 2025-07-30 13:56:44 +02:00
NimarandGitHub ebf0e35073 fix(json-view): smart retain collapsed/expanded state of json table view (#8191)
* handle user set expansion state in context provider

* update

* update2

* fix collapse state

* fix state bool

* final cleanup

* fix collapse all

* fix lint

* cleanup

* simplify

* remove log

* fix expand

* fix types

* lint
2025-07-30 11:21:42 +00:00
Thorsten SpiekerandGitHub 2c4799340f fix: set slack redirect uri specifically per environment (#8205) 2025-07-30 12:50:15 +02:00
Steffen SchmitzGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
5612c6a9a5 feat(cloud): add billing alerts (#7903)
* feat(cloud): add billing alerts

* chore: checkpoint state

* chore: cleanup

* chore: remove implementation plan

* chore: update send email behaviour

* chore: add usage alert config options in UI

* chore: implement stripe hook and email

* chore: optimize email

* chore: linting

* chore: use invoice hooks to retrigger usage alerts in each cycle

* chore: add log statement if usage alert got recreated

* chore: update log messages

* chore: replace cloud-billing-alerts with cloud-usage-alerts

* Update web/src/ee/features/billing/components/UsageAlerts.tsx

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-07-30 09:48:59 +00:00
Max DeichmannandGitHub b62962ca08 chore: move slack database migration in right order (#8203)
push
2025-07-30 11:12:28 +02:00
Thorsten SpiekerandGitHub 3a46283bf2 feat: add slack integration (#8073) 2025-07-30 10:52:57 +02:00
Marlies Mayerhofer 07801180f8 chore: release v3.88.1 2025-07-30 10:49:42 +02:00
marliessophieandGitHub 14831902ef fix(annotation-queues): remove unnecessary refetchOnMount option and ensure current item type is defined (#8199) 2025-07-30 08:28:46 +00:00
marliessophieandGitHub d377f02a49 fix(dataset-items): ensure deterministic ordering given pagination for GET dataset items api (#8200)
* fix(dataset-items): ensure deterministic ordering given pagination for GET dataset items api

* chore: push
2025-07-30 08:28:22 +00:00
Nimar e07120b163 chore: release v3.88.0 2025-07-29 20:27:11 +02:00
NimarandGitHub 506482dbe4 fix(playground): enable jumpt to playground for gemini (#8188)
* fix(playground): enable jumpt to playground for gemini

* fix exhaustive check

* correct types
2025-07-29 17:34:02 +00:00
Xichen PanandGitHub ec61f4a420 fix: set explicit ES2021 target in tsconfig to support Unicode regex (#8174)
* fix(worker): set explicit ES2021 target in tsconfig to support Unicode regex

* fix(worker): remove redundant lib config from tsconfig.json
2025-07-29 17:30:58 +00:00
marliessophieandGitHub 4db08a2960 fix(eval): trace filter preview enable dynamic sorting based on control visibility (#8187) 2025-07-29 17:11:37 +00:00
NimarandGitHub 2351d0d370 fix(markdown): don't break after list item number (#8181) 2025-07-29 12:03:49 +00:00
marliessophieandGitHub 99b3401549 feat(dataset-run-items): implement experiment service writes (#8090)
* feat(dataset-run-items): implement experiment service writes

* chore: add

* fix(experiments): update error messages for missing API key using constants

* chore(experimentCreateQueue): increase backoff delay to 10 seconds

* chore(auth): update ingestion types for improved flexibility

* chore: create traces & error-level generations for DRIs

* chore: unify timestamps

* chore: revert test to PG implementation until we add DRI migration across environments

* chore: typo

* chore: implement unified trace ID generation for ClickHouse and PostgreSQL executions to prevent duplicate traces

* chore: refactor trace creation logic for ClickHouse and PostgreSQL executions to use a unified approach

* chore: eslint

* chore: comment

* chore: rebase

* chore: rebase

* chore: rename function for clarity and fix typo in comments

* refactor: remove fetchDatasetRun function and replace with direct Prisma query for dataset run retrieval

* chore: eslint

* chore: fix test

* chore: fix test

* refactor: rename function and update comments for clarity in experimentServiceClickhouse
2025-07-29 11:14:30 +00:00
Nimar 6fb796df40 chore: release v3.87.1 2025-07-29 12:04:14 +02:00
NimarandGitHub 846d6e6c37 fix(markdown-rendering): tighter rendering (#8177)
* fix(trace-view): better markdown spacing for density

* better spacing
2025-07-29 09:55:05 +00:00
marliessophieandGitHub 1fa4fc6329 fix(annotation): mobile responsivness behaviour (#8154)
* fix(annotation): mobile behavior of annotation queue item view

* fix(ItemBadge): enhance label display with truncation and overflow handling

* fix(annotation-processing): improve layout by adjusting panel height properties for better overflow handling
2025-07-29 08:29:31 +00:00
Marlies Mayerhofer a1a48d5e7b chore: release v3.87.0 2025-07-29 00:15:18 +02:00
NimarandGitHub fcb7563763 chore: upgrade turbo to 2.5.5 (#8171) 2025-07-28 21:00:45 +00:00
Thorsten SpiekerandGitHub ae754b146d fix: scroll area for large lists of variables (#8170) 2025-07-28 21:13:56 +02:00
marliessophieandGitHub 49343b9a0c feat(annotation-queues): add support for batch adding sessions to annotation queues (#8169)
* feat(annotation-queues): add support for batch adding sessions to annotation queues

* chore: eslint types
2025-07-28 18:44:06 +00:00
Steffen SchmitzandGitHub 76bf5c0f4e fix: add queue prefix to webhook queue (#8165) 2025-07-28 16:12:48 +00:00
marliessophieandGitHub e91a29be61 fix(members-table): update table column visibility and order logic based on project context (#8164)
refactor(members-table): update column visibility and order logic based on project context
2025-07-28 14:38:14 +00:00
marliessophieandGitHub 81694363a2 fix(metrics): convert latency from milliseconds to seconds for accurate display (#8162) 2025-07-28 12:38:43 +00:00
marliessophieandGitHub 5998266680 feat(dataset-run-items): implement deletion for CH writes (#8074)
* feat(dataset): implement dataset run items deletion queue and processing

* feat(dataset): add dataset run items deletion functionality and integrate with deletion queue

* chore: revert imports

* chore: handle DRI deletion on project deletion conditionally

* fix(dataset-router): await Promise.all for dataset run items deletion

* refactor(dataset-router): streamline dataset deletion process and ensure async handling of dataset run items

* fix(env): add new environment variable for dataset run items deletion concurrency duration

* chore: rm comment

* refactor(dataset-run-items): update delete functions to use object destructuring for parameters

* fix(dataset-run-items): replace hardcoded request timeout with environment variable for deletion timeout

* refactor(dataset): rename and restructure dataset deletion functionality, replacing dataset run items deletion with a unified dataset deletion queue
2025-07-28 11:36:05 +00:00
Nimar 462e8e847d chore: release v3.86.1 2025-07-28 12:53:59 +02:00
NimarandGitHub d7c186858b fix(observations): if value is 0 show it instead of null (#8158) 2025-07-28 10:22:03 +00:00
NimarandGitHub e686aedac9 fix(trace-table): smart expand json table if not many rows (#8142)
* fix(trace-table): smart expand json table if not many rows

* clean up comments

* better performance

* cleanup

* fix lint

* clean
2025-07-28 09:58:02 +00:00
Max DeichmannandGitHub 85f75a5e8e chore: improve webhook retries (#8098)
* chore: improve webhook retries

* chore: improve webhook retries

* chore: improve webhook retries

* chore: improve webhook retries

* fixes

* push

* fixes

* fixes
2025-07-26 13:26:15 +00:00
Max DeichmannandGitHub 1ea643300d chore: remove ingestion via bullmq (#8143) 2025-07-26 00:13:53 +02:00
596486c1ec chore: ingest secondary ingestion queue data (#8137)
* push

* add duration tracking

* push

---------

Co-authored-by: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com>
2025-07-25 17:13:03 +00:00
Hassieb PakzadandGitHub cb65391c2d fix(model-selector): correct combinations provider model (#8135)
* fix(model-selector): correct combinations provider model

* push

* push

* push

* push

* push
2025-07-25 16:29:06 +00:00
marliessophieandGitHub 419e07260d fix(evaluator): update button label based on form mode (#8134) 2025-07-25 14:06:59 +00:00
marliessophieandGitHub 825f030e81 feat(annotation): support annotation on sessions (#8130)
* feat(session): integrate CreateNewAnnotationQueueItem component into session page

* refactor(annotation-queues): enhance QueueItemRowData structure to support multiple object types

* chore: refactor to extract common functionality

* fixup: refactor to extract common functionality

* chore: handle session annotation type in annotation processing UI

* feat(annotation-queues): add object type column and enhance source display in AnnotationQueueItemsTable

* chore: add session annotation ui

* feat(annotation-queues): add tooltip for detailed view toggle and enhance session item handling

* chore: small improvement

* feat(annotation-queues): implement intersection observer for trace visibility and update pagination logic

* fix(annotation-queues): update current item type retrieval and improve session item handling logic

* chore: eslint

* chore: nits
2025-07-25 13:32:54 +00:00
Max DeichmannandGitHub 34ded9c927 chore: adjust sampling metric (#8132) 2025-07-25 11:53:15 +02:00
Hassieb PakzadandGitHub c935d4ae73 chore: add sampling to ingestion pipeline (#8128) 2025-07-25 11:26:30 +02:00
Hassieb PakzadandGitHub e7283ac06e fix(ingestion): cached model not found (#8122) 2025-07-25 06:51:18 +00:00
Max DeichmannandGitHub 20d0106626 chore: fix date parsing for eval retries (#8119) 2025-07-25 02:00:48 +02:00
Max DeichmannandGitHub 849591fdf2 chore: improve evals logging (#8118) 2025-07-25 01:34:52 +02:00
Max DeichmannandGitHub 0c7b50e564 chore: fix eval event envelope timestmp (#8117) 2025-07-25 01:10:40 +02:00
Max DeichmannandGitHub 37b4f43351 chore: increase observability and delay of eval executions (#8116) 2025-07-25 00:08:54 +02:00
marliessophieandGitHub 7a3b0379e5 fix(experiments): update loading condition in CreateExperimentsForm to check for datasetId (#8115) 2025-07-24 21:11:42 +00:00
marliessophieandGitHub f458d7626c chore: add sessions type for annotation queue (#8107) 2025-07-24 17:11:47 +00:00
Max DeichmannandGitHub 258dde4691 fix: fix automation updates (#8108)
* fix: fix automation updates

* fix: fix automation updates

* fix: fix automation updates
2025-07-24 16:35:33 +00:00
marliessophieandGitHub f15246d9df feat(experiments): support remote experiment trigger (#8094)
* fixup(experiments): support trigger for remote experiment run

* chore: remove instructions

* chore: rename webhook > remote experiment server side

* chore: rename webhook > remote experiment client side

* style: design review

* chore: lint

* fix(experiments): change URL validation to use z.url() in RemoteExperimentUpsertForm

* chore: rename

* feat(datasets): add transformation function for datasets and update API responses
2025-07-24 16:12:59 +00:00
NimarandGitHub edb43a24ab fix(trace): show linebreaks in only text markdown view (#8106) 2025-07-24 16:08:00 +00:00
Steffen SchmitzandGitHub c45ae1b140 chore: adjust getSessionsTable query for AMTs (#8102)
* chore: adjust getSessionsTable query for AMTs

* chore: add additional index on traces_all_amt
2025-07-24 14:56:43 +00:00
Ted KimandGitHub 90eeeeaf9d fix: remove 'ON CLUSTER' instruction from unclustered migration (#7959) 2025-07-24 16:49:00 +02:00
Nimar 66accb563c chore: release v3.86.0 2025-07-24 16:30:43 +02:00
NimarandGitHub 9f2e8906d0 fix(trace-table): expanded state and make it fast! (#8099)
* preserve expanded state

* lazy load table for better performance

* fix broken commit

* pending: verbose state tracking working again

* fix states

* fix lint

* cleanup

* cleaner even

* fix build
2025-07-24 14:24:47 +00:00
c5d61b7ed2 chore: Remove add window button and its imports (#8097)
* Remove Add Window button from playground page

Co-authored-by: max <max@langfuse.com>

* feat(playground): remove Add Window button from header

- Remove Add Window button from playground page header
- Clean up unused imports (Plus icon, MULTI_WINDOW_CONFIG)
- Remove unused isAddWindowDisabled variable
- Preserve addWindow function for copy functionality
- Fix linting warnings for unused imports

Users can still add windows via the copy button on individual windows.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-07-24 13:05:27 +00:00
Max DeichmannandGitHub f95dd872e6 chore: fix output box for playground (#8096) 2025-07-24 12:38:11 +00:00
marliessophieandGitHub 04339741a9 chore(dataset-run-items): adjust converter for background migration (#8075)
* chore: reorder converter

* chore: treat numbers and booleans as strings

* chore: adjust metadata conversion

* Revert "chore: adjust metadata conversion"

This reverts commit d5ca3a5cb1b6e5972e5a321ab4884c589b938087.
2025-07-24 11:38:48 +00:00
marliessophieandGitHub 9c85e46996 feat(table): persist column sizes in local storage (#7816)
* feat(table): persist column sizing in localStorage

* chore: apply to all tables

* chore: push

* chore: add string
2025-07-24 11:37:16 +00:00
Steffen SchmitzandGitHub 4a7236451f chore: bump clickhouse client to 1.12.0 (#8092) 2025-07-24 11:20:21 +00:00
Steffen SchmitzandGitHub 5cb5204181 chore: migrate remaining trace reads to AMT (#8056)
* chore: migrate remaining trace reads to AMT

* chore: revert box ticks

* chore: revert streamed exports

* chore: confirm trace count by project in creation interval

* chore: confirm trace count since created

* chore: confirm getTracesByIdsForAnyProject

* chore: session table simplification

* chore: refactor user metrics

* chore: update
2025-07-24 10:49:44 +00:00
NimarandGitHub c19066afa0 fix(playground): api key link should show llm connection (#8080) 2025-07-24 09:43:03 +00:00
Max DeichmannandGitHub bbb9dc285d chore: remove delay for otel (#8085) 2025-07-24 11:16:59 +02:00
Steffen SchmitzandGitHub b750acba06 feat(admin-api): add okta role assignment capabilities (#8084)
* feat(admin-api): add okta role assignment capabilities

* chore: lint
2025-07-24 08:21:36 +00:00
Max DeichmannandGitHub bb06e079eb chore: upgrade prism (#8083)
* chore: upgrade prismjs

* fix
2025-07-24 07:49:50 +00:00
Max DeichmannandGitHub 5f711780e0 chore: upgrade prismjs (#8077) 2025-07-23 17:20:53 +00:00
Max DeichmannandGitHub 0f58ea1ebf chore: remove trivy (#8076) 2025-07-23 17:11:59 +00:00
316 changed files with 24553 additions and 5383 deletions
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse-development",
"forwardPorts": [3000, 5432, 6379, 8123, 9000],
"onCreateCommand": "npm install -g pnpm@9.5.0",
"postCreateCommand": "curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz && git restore LICENSE README.md && chmod +x migrate && sudo mv migrate /usr/bin && cp .env.dev.example .env && npm install -g @anthropic-ai/claude-code && pnpm i"
"name": "langfuse-development",
"forwardPorts": [3000, 5432, 6379, 8123, 9000],
"onCreateCommand": "npm install -g pnpm@9.5.0",
"postCreateCommand": "curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz && git restore LICENSE README.md && chmod +x migrate && sudo mv migrate /usr/bin && cp .env.dev.example .env && npm install -g @anthropic-ai/claude-code && pnpm i"
}
+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
+5
View File
@@ -83,3 +83,8 @@ NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
# For SDK integration tests to pass, decrease the ingestion queue delay by uncommenting the env vars:
# LANGFUSE_INGESTION_QUEUE_DELAY_MS=10
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=10
# Slack credentials for development
SLACK_CLIENT_ID=your_slack_client_id
SLACK_CLIENT_SECRET=your_slack_client_secret
SLACK_STATE_SECRET=your_slack_state_secret
+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
-43
View File
@@ -1,43 +0,0 @@
name: Trivy Container Security
on:
push:
branches: ["main"]
jobs:
trivy-web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Run Trivy vulnerability scanner (web)
uses: aquasecurity/trivy-action@master
with:
image-ref: "langfuse/langfuse"
format: "sarif"
output: "trivy-web.sarif"
- name: Upload Trivy scan results to GitHub Security tab (web)
if: always() && hashFiles('trivy-web.sarif') != ''
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: "trivy-web.sarif"
category: trivy-web
trivy-worker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Run Trivy vulnerability scanner (worker)
uses: aquasecurity/trivy-action@master
with:
image-ref: "langfuse/langfuse-worker"
format: "sarif"
output: "trivy-worker.sarif"
- name: Upload Trivy scan results to GitHub Security tab (worker)
if: always() && hashFiles('trivy-worker.sarif') != ''
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: "trivy-worker.sarif"
category: trivy-worker
+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
+3
View File
@@ -192,3 +192,6 @@ To get a project, use the `get_project` capability with the full project name as
## General Coding Guidelines
- For easier code reviews, prefer not to move functions etc around within a file unless necessary or instructed to do so
## Development Tips
- Before trying to build the package, try running the linter once first
+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/
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.85.2",
"version": "3.95.2",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
@@ -40,7 +40,7 @@
"husky": "^9.0.11",
"prettier": "^3.6.2",
"release-it": "^19.0.3",
"turbo": "^2.5.4"
"turbo": "^2.5.5"
},
"release-it": {
"git": {
+1 -1
View File
@@ -13,7 +13,7 @@
"@vercel/style-guide": "^6.0.0",
"eslint-config-next": "^14.2.15",
"eslint-config-prettier": "^9.1.0",
"eslint-config-turbo": "^2.5.4",
"eslint-config-turbo": "^2.5.5",
"eslint-plugin-only-warn": "^1.1.0",
"typescript": "^5.4.5"
}
@@ -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;
@@ -1 +1 @@
ALTER TABLE traces ON CLUSTER default DROP INDEX IF EXISTS idx_user_id;
ALTER TABLE traces DROP INDEX IF EXISTS idx_user_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;
+3 -1
View File
@@ -61,7 +61,7 @@
"@aws-sdk/lib-storage": "^3.675.0",
"@aws-sdk/s3-request-presigner": "^3.679.0",
"@azure/storage-blob": "^12.26.0",
"@clickhouse/client": "^1.11.2",
"@clickhouse/client": "^1.12.0",
"@google-cloud/storage": "^7.15.2",
"@langchain/anthropic": "^0.3.22",
"@langchain/aws": "^0.1.11",
@@ -73,6 +73,8 @@
"@prisma/client": "^6.10.1",
"@react-email/components": "^0.1.0",
"@react-email/render": "^1.1.2",
"@slack/oauth": "^2.6.0",
"@slack/web-api": "^7.0.0",
"@types/bcryptjs": "^2.4.6",
"axios": "^1.8.2",
"bcryptjs": "^2.4.3",
+34
View File
@@ -55,6 +55,7 @@ export type AnnotationQueueStatus =
export const AnnotationQueueObjectType = {
TRACE: "TRACE",
OBSERVATION: "OBSERVATION",
SESSION: "SESSION",
} as const;
export type AnnotationQueueObjectType =
(typeof AnnotationQueueObjectType)[keyof typeof AnnotationQueueObjectType];
@@ -138,6 +139,7 @@ export type DashboardWidgetChartType =
(typeof DashboardWidgetChartType)[keyof typeof DashboardWidgetChartType];
export const ActionType = {
WEBHOOK: "WEBHOOK",
SLACK: "SLACK",
} as const;
export type ActionType = (typeof ActionType)[keyof typeof ActionType];
export const ActionExecutionStatus = {
@@ -183,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;
@@ -359,6 +369,8 @@ export type Dataset = {
name: string;
description: string | null;
metadata: unknown | null;
remote_experiment_url: string | null;
remote_experiment_payload: unknown | null;
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
};
@@ -624,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;
@@ -711,6 +732,16 @@ export type Session = {
user_id: string;
expires: Timestamp;
};
export type SlackIntegration = {
id: string;
project_id: string;
team_id: string;
team_name: string;
bot_token: string;
bot_user_id: string;
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
};
export type SsoConfig = {
domain: string;
created_at: Generated<Timestamp>;
@@ -781,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;
@@ -813,6 +845,7 @@ export type DB = {
observations: LegacyPrismaObservation;
organization_memberships: OrganizationMembership;
organizations: Organization;
pending_deletions: PendingDeletion;
posthog_integrations: PosthogIntegration;
prices: Price;
project_memberships: ProjectMembership;
@@ -823,6 +856,7 @@ export type DB = {
score_configs: ScoreConfig;
scores: LegacyPrismaScore;
Session: Session;
slack_integrations: SlackIntegration;
sso_configs: SsoConfig;
table_view_presets: TableViewPreset;
trace_media: TraceMedia;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "datasets" ADD COLUMN "remote_experiment_payload" JSONB,
ADD COLUMN "remote_experiment_url" TEXT;
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "AnnotationQueueObjectType" ADD VALUE 'SESSION';
@@ -0,0 +1,30 @@
-- Migration: Add Slack Integration Support
-- This migration adds support for Slack automation actions by:
-- 1. Adding SLACK to the ActionType enum
-- 2. Creating slack_integrations table for centralized token storage
-- AlterEnum
ALTER TYPE "ActionType" ADD VALUE 'SLACK';
-- CreateTable
CREATE TABLE "slack_integrations" (
"id" TEXT NOT NULL,
"project_id" TEXT NOT NULL,
"team_id" TEXT NOT NULL,
"team_name" TEXT NOT NULL,
"bot_token" TEXT NOT NULL,
"bot_user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "slack_integrations_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "slack_integrations_project_id_key" ON "slack_integrations"("project_id");
-- CreateIndex
CREATE INDEX "slack_integrations_team_id_idx" ON "slack_integrations"("team_id");
-- AddForeignKey
ALTER TABLE "slack_integrations" ADD CONSTRAINT "slack_integrations_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -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;
+150 -88
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,52 +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[]
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")
@@ -489,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])
@@ -539,6 +544,22 @@ enum AnnotationQueueStatus {
enum AnnotationQueueObjectType {
TRACE
OBSERVATION
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 {
@@ -551,16 +572,18 @@ model CronJobs {
}
model Dataset {
id String @default(cuid())
projectId String @map("project_id")
name String
description String?
metadata Json?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
datasetItems DatasetItem[]
datasetRuns DatasetRuns[]
id String @default(cuid())
projectId String @map("project_id")
name String
description String?
metadata Json?
remoteExperimentUrl String? @map("remote_experiment_url")
remoteExperimentPayload Json? @map("remote_experiment_payload")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
datasetItems DatasetItem[]
datasetRuns DatasetRuns[]
@@id([id, projectId])
@@unique([projectId, name])
@@ -1295,6 +1318,7 @@ model Automation {
enum ActionType {
WEBHOOK
SLACK
// More action types can be added as needed
}
@@ -1335,3 +1359,41 @@ model AutomationExecution {
@@index([projectId])
@@map("automation_executions")
}
// Slack Integration: Stores centralized Slack workspace connection for each project
// One project can connect to one Slack workspace, supporting multiple channel automations
model SlackIntegration {
id String @id @default(cuid())
projectId String @unique @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
// Installation details (encrypted using shared encryption utilities)
teamId String @map("team_id") // Slack workspace ID
teamName String @map("team_name") // Human-readable workspace name
botToken String @map("bot_token") // Encrypted bot token for API calls
botUserId String @map("bot_user_id") // Bot user ID for workspace
createdAt DateTime @default(now()) @map("created_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")
}
+143 -143
View File
@@ -11,29 +11,29 @@
## 🎯 Getting Started
### Prerequisites
\`\`\`bash
npm install @langfuse/core
pip install langfuse
\`\`\`bash
npm install @langfuse/core
pip install langfuse
\`\`\`
### Quick Setup
1. **Initialize your project**
\`\`\`typescript
import { Langfuse } from 'langfuse'
const langfuse = new Langfuse({
secretKey: process.env.LANGFUSE_SECRET_KEY,
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
baseUrl: 'https://cloud.langfuse.com'
})
\`\`\`typescript
import { Langfuse } from 'langfuse'
const langfuse = new Langfuse({
secretKey: process.env.LANGFUSE_SECRET_KEY,
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
baseUrl: 'https://cloud.langfuse.com'
})
\`\`\`
2. **Create your first trace**
\`\`\`python
from langfuse import Langfuse
langfuse = Langfuse()
trace = langfuse.trace(name="chat-application")
\`\`\`python
from langfuse import Langfuse
langfuse = Langfuse()
trace = langfuse.trace(name="chat-application")
\`\`\`
---
@@ -72,75 +72,75 @@ graph TD
> **Note:** Traces are the foundation of observability in LLM applications.
#### Creating Traces
\`\`\`typescript
// Basic trace creation
const trace = langfuse.trace({
name: "user-query-processing",
userId: "user-123",
sessionId: "session-456",
metadata: {
environment: "production",
version: "2.1.0"
}
})
\`\`\`typescript
// Basic trace creation
const trace = langfuse.trace({
name: "user-query-processing",
userId: "user-123",
sessionId: "session-456",
metadata: {
environment: "production",
version: "2.1.0"
}
})
// Nested observations
const span = trace.span({
name: "document-retrieval",
input: { query: "What is machine learning?" },
metadata: { vectorStore: "pinecone" }
})
// Nested observations
const span = trace.span({
name: "document-retrieval",
input: { query: "What is machine learning?" },
metadata: { vectorStore: "pinecone" }
})
const generation = span.generation({
name: "answer-generation",
model: "gpt-4",
input: retrievedDocs,
output: generatedAnswer,
usage: {
promptTokens: 1250,
completionTokens: 420,
totalTokens: 1670
}
})
const generation = span.generation({
name: "answer-generation",
model: "gpt-4",
input: retrievedDocs,
output: generatedAnswer,
usage: {
promptTokens: 1250,
completionTokens: 420,
totalTokens: 1670
}
})
\`\`\`
### Advanced Features
#### 🔄 Async Processing
\`\`\`python
import asyncio
from langfuse import Langfuse
\`\`\`python
import asyncio
from langfuse import Langfuse
async def process_batch():
langfuse = Langfuse()
tasks = []
for item in batch_items:
task = asyncio.create_task(
process_item_with_tracing(langfuse, item)
)
tasks.append(task)
results = await asyncio.gather(*tasks)
return results
async def process_batch():
langfuse = Langfuse()
tasks = []
for item in batch_items:
task = asyncio.create_task(
process_item_with_tracing(langfuse, item)
)
tasks.append(task)
results = await asyncio.gather(*tasks)
return results
\`\`\`
#### 🎯 Custom Scoring
\`\`\`typescript
// Automated scoring
trace.score({
name: "relevance",
value: 0.95,
comment: "Highly relevant response"
})
\`\`\`typescript
// Automated scoring
trace.score({
name: "relevance",
value: 0.95,
comment: "Highly relevant response"
})
// Human feedback scoring
trace.score({
name: "user-satisfaction",
value: 1,
source: "user-feedback",
comment: "User rated 5/5 stars"
})
// Human feedback scoring
trace.score({
name: "user-satisfaction",
value: 1,
source: "user-feedback",
comment: "User rated 5/5 stars"
})
\`\`\`
---
@@ -156,20 +156,20 @@ trace.score({
- **User Satisfaction**: Quality metrics
#### Dashboard Setup
\`\`\`yaml
# monitoring-config.yml
dashboards:
- name: "LLM Performance"
panels:
- type: "time-series"
title: "Response Latency"
query: "avg(response_time) by (model)"
- type: "stat"
title: "Daily Token Usage"
query: "sum(tokens_used)"
- type: "table"
title: "Top Errors"
query: "topk(10, count by (error_type))"
\`\`\`yaml
# monitoring-config.yml
dashboards:
- name: "LLM Performance"
panels:
- type: "time-series"
title: "Response Latency"
query: "avg(response_time) by (model)"
- type: "stat"
title: "Daily Token Usage"
query: "sum(tokens_used)"
- type: "table"
title: "Top Errors"
query: "topk(10, count by (error_type))"
\`\`\`
### 🔐 Security Considerations
@@ -177,40 +177,40 @@ dashboards:
> ⚠️ **Important**: Never log sensitive user data in traces
#### Data Sanitization
\`\`\`python
def sanitize_input(data):
"""Remove PII from trace data"""
sanitized = data.copy()
# Remove email addresses
sanitized = re.sub(r'\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Z|a-z]{2,}\\b',
'[EMAIL_REDACTED]', sanitized)
# Remove phone numbers
sanitized = re.sub(r'\\b\\d{3}-\\d{3}-\\d{4}\\b',
'[PHONE_REDACTED]', sanitized)
return sanitized
\`\`\`python
def sanitize_input(data):
"""Remove PII from trace data"""
sanitized = data.copy()
# Remove email addresses
sanitized = re.sub(r'\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Z|a-z]{2,}\\b',
'[EMAIL_REDACTED]', sanitized)
# Remove phone numbers
sanitized = re.sub(r'\\b\\d{3}-\\d{3}-\\d{4}\\b',
'[PHONE_REDACTED]', sanitized)
return sanitized
\`\`\`
### 🚀 Performance Optimization
#### Batch Processing
\`\`\`typescript
// Efficient batch uploads
const batchSize = 100
const traces = []
\`\`\`typescript
// Efficient batch uploads
const batchSize = 100
const traces = []
for (let i = 0; i < data.length; i += batchSize) {
const batch = data.slice(i, i + batchSize)
const processedBatch = await Promise.all(
batch.map(item => processWithLangfuse(item))
)
traces.push(...processedBatch)
}
for (let i = 0; i < data.length; i += batchSize) {
const batch = data.slice(i, i + batchSize)
const processedBatch = await Promise.all(
batch.map(item => processWithLangfuse(item))
)
traces.push(...processedBatch)
}
// Flush all traces at once
await langfuse.flushAsync()
// Flush all traces at once
await langfuse.flushAsync()
\`\`\`
---
@@ -219,34 +219,34 @@ await langfuse.flushAsync()
### Multi-Agent System Tracing
\`\`\`python
class MultiAgentTracer:
def __init__(self):
self.langfuse = Langfuse()
async def orchestrate_agents(self, task):
# Main orchestration trace
main_trace = self.langfuse.trace(
name="multi-agent-orchestration",
input={"task": task}
)
# Agent 1: Research
research_span = main_trace.span(name="research-agent")
research_result = await self.research_agent.process(task)
research_span.end(output=research_result)
class MultiAgentTracer:
def __init__(self):
self.langfuse = Langfuse()
async def orchestrate_agents(self, task):
# Main orchestration trace
main_trace = self.langfuse.trace(
name="multi-agent-orchestration",
input={"task": task}
)
# Agent 1: Research
research_span = main_trace.span(name="research-agent")
research_result = await self.research_agent.process(task)
research_span.end(output=research_result)
# Agent 2: Analysis
analysis_span = main_trace.span(name="analysis-agent")
analysis_result = await self.analysis_agent.process(research_result)
analysis_span.end(output=analysis_result)
# Agent 3: Synthesis
synthesis_span = main_trace.span(name="synthesis-agent")
final_result = await self.synthesis_agent.process(analysis_result)
synthesis_span.end(output=final_result)
main_trace.end(output=final_result)
return final_result
analysis_span = main_trace.span(name="analysis-agent")
analysis_result = await self.analysis_agent.process(research_result)
analysis_span.end(output=analysis_result)
# Agent 3: Synthesis
synthesis_span = main_trace.span(name="synthesis-agent")
final_result = await self.synthesis_agent.process(analysis_result)
synthesis_span.end(output=final_result)
main_trace.end(output=final_result)
return final_result
\`\`\`
---
@@ -256,11 +256,11 @@ class MultiAgentTracer:
With proper implementation of Langfuse tracing, you can:
- ✅ **Monitor** your LLM applications in real-time
- ✅ **Debug** issues with detailed trace information
- ✅ **Debug** issues with detailed trace information
- ✅ **Optimize** performance and costs
- ✅ **Scale** your applications with confidence
### Next Steps
1. Review the [official documentation](https://langfuse.com/docs)
2. Join our [Discord community](https://discord.gg/langfuse)
3. Check out [example projects](https://github.com/langfuse/langfuse)
3. Check out [example projects](https://github.com/langfuse/langfuse)
+83 -6
View File
@@ -30,14 +30,14 @@ export type AutomationDomain = {
};
export type ActionDomain = Omit<Action, "config"> & {
config: SafeWebhookActionConfig;
config: SafeActionConfig;
};
export type ActionDomainWithSecrets = Omit<Action, "config"> & {
config: WebhookActionConfigWithSecrets;
config: ActionConfigWithSecrets;
};
export const ActionTypeSchema = z.enum(["WEBHOOK"]);
export const ActionTypeSchema = z.enum(["WEBHOOK", "SLACK"]);
export const AvailableWebhookApiSchema = z.record(
z.enum(["prompt"]),
@@ -52,12 +52,13 @@ export const RequestHeaderSchema = z.object({
export const WebhookActionConfigSchema = z.object({
type: z.literal("WEBHOOK"),
url: z.url(),
headers: z.record(z.string(), z.string()),
requestHeaders: z.record(z.string(), RequestHeaderSchema),
displayHeaders: z.record(z.string(), RequestHeaderSchema),
headers: z.record(z.string(), z.string()).optional(), // deprecated field, use requestHeaders instead
requestHeaders: z.record(z.string(), RequestHeaderSchema).optional(), // might not exist on legacy webhooks
displayHeaders: z.record(z.string(), RequestHeaderSchema).optional(), // might not exist on legacy webhooks
apiVersion: AvailableWebhookApiSchema,
secretKey: z.string(),
displaySecretKey: z.string(),
lastFailingExecutionId: z.string().nullish(),
});
export const SafeWebhookActionConfigSchema = WebhookActionConfigSchema.omit({
@@ -77,18 +78,94 @@ export const WebhookActionCreateSchema = WebhookActionConfigSchema.omit({
displayHeaders: true,
});
export const SlackActionConfigSchema = z.object({
type: z.literal("SLACK"),
channelId: z.string(),
channelName: z.string(),
messageTemplate: z.string().optional(),
});
export type SlackActionConfig = z.infer<typeof SlackActionConfigSchema>;
export const ActionConfigSchema = z.discriminatedUnion("type", [
WebhookActionConfigSchema,
SlackActionConfigSchema,
]);
export const ActionCreateSchema = z.discriminatedUnion("type", [
WebhookActionCreateSchema,
SlackActionConfigSchema,
]);
export const SafeActionConfigSchema = z.discriminatedUnion("type", [
SafeWebhookActionConfigSchema,
SlackActionConfigSchema,
]);
export type ActionTypes = z.infer<typeof ActionTypeSchema>;
export type ActionConfig = z.infer<typeof ActionConfigSchema>;
export type ActionCreate = z.infer<typeof ActionCreateSchema>;
export type SafeActionConfig = z.infer<typeof SafeActionConfigSchema>;
export type WebhookActionCreate = z.infer<typeof WebhookActionCreateSchema>;
export type WebhookActionConfigWithSecrets = z.infer<
typeof WebhookActionConfigSchema
>;
export type ActionConfigWithSecrets = z.infer<typeof ActionConfigSchema>;
// Type Guards for Runtime Validation
// Using existing Zod schemas to provide both compile-time and runtime type safety
/**
* Type guard to check if a config is a valid webhook configuration with secrets
*/
export function isWebhookActionConfig(
config: unknown,
): config is WebhookActionConfigWithSecrets {
return WebhookActionConfigSchema.safeParse(config).success;
}
/**
* Type guard to check if a config is a valid Slack configuration
*/
export function isSlackActionConfig(
config: unknown,
): config is SlackActionConfig {
return SlackActionConfigSchema.safeParse(config).success;
}
/**
* Type guard to check if an entire action has valid webhook configuration
*/
export function isWebhookAction(action: {
type: string;
config: unknown;
}): action is { type: "WEBHOOK"; config: WebhookActionConfigWithSecrets } {
return action.type === "WEBHOOK" && isWebhookActionConfig(action.config);
}
/**
* Type guard for safe webhook config (without secrets)
*/
export function isSafeWebhookActionConfig(
config: unknown,
): config is SafeWebhookActionConfig {
return SafeWebhookActionConfigSchema.safeParse(config).success;
}
/**
* Converts webhook config with secrets to safe config by only including allowed fields
*/
export function convertToSafeWebhookConfig(
webhookConfig: WebhookActionConfigWithSecrets,
): SafeWebhookActionConfig {
return {
type: webhookConfig.type,
url: webhookConfig.url,
displayHeaders: webhookConfig.displayHeaders,
apiVersion: webhookConfig.apiVersion,
displaySecretKey: webhookConfig.displaySecretKey,
lastFailingExecutionId: webhookConfig.lastFailingExecutionId,
};
}
+62
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,60 @@ 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()
.transform((val) => {
try {
if (!val) return new Map<string, number>();
const map = new Map<string, number>();
const parts = val.split(",");
for (const part of parts) {
const [projectId, sampleRateStr] = part.split(":");
if (!projectId || sampleRateStr === undefined) {
throw new Error(`Invalid format: ${part}`);
}
// Validate sample rate is between 0 and 1
const sampleRate = z.coerce
.number()
.min(0)
.max(1)
.parse(sampleRateStr);
map.set(projectId, sampleRate);
}
return map;
} catch (err) {
return new Map<string, number>();
}
}),
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
>;
@@ -14,6 +14,8 @@ const ActionIdSchema = z.enum([
"score-delete",
"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
View File
@@ -18,6 +18,7 @@ export * from "./features/entitlements/plans";
export * from "./interfaces/rate-limits";
export * from "./tableDefinitions/typeHelpers";
export * from "./domain/webhooks";
export * from "./domain/dataset-run-items";
// llm api
export * from "./server/llm/types";
@@ -18,6 +18,21 @@ export const CloudConfigSchema = z.object({
// custom rate limits for an organization
rateLimitOverrides: CloudConfigRateLimit.optional(),
// billing alert configuration
usageAlerts: z
.object({
enabled: z.boolean().default(true),
type: z.enum(["STRIPE"]).default("STRIPE"),
threshold: z.number().int().positive(),
alertId: z.string(), // Alert ID for tracking
meterId: z.string(), // Meter ID for usage tracking
notifications: z.object({
email: z.boolean().default(true),
recipients: z.array(z.string().email()).default([]),
}),
})
.optional(),
});
export type CloudConfigSchema = z.infer<typeof CloudConfigSchema>;
+15 -2
View File
@@ -1,7 +1,7 @@
import z from "zod/v4";
import { Plan, plans } from "../../features/entitlements/plans";
import { CloudConfigRateLimit } from "../../interfaces/rate-limits";
import { ApiKeyScope } from "../../";
import { ApiKeyScope, MakeOptional } from "../../";
const ApiKeyBaseSchema = z.object({
id: z.string(),
@@ -48,12 +48,25 @@ export type AuthHeaderValidVerificationResult = {
scope: ApiAccessScope;
};
export type ApiAccessScope = {
export type AuthHeaderValidVerificationResultIngestion = {
validKey: true;
scope: ApiAccessScopeIngestion;
};
type BaseApiAccessScope = {
projectId: string | null;
accessLevel: "organization" | "project" | "scores";
};
type ApiAccessScopeMetadata = {
orgId: string;
plan: Plan;
rateLimitOverrides: z.infer<typeof CloudConfigRateLimit>;
apiKeyId: string;
publicKey: string;
};
export type ApiAccessScopeIngestion = BaseApiAccessScope &
MakeOptional<ApiAccessScopeMetadata>;
export type ApiAccessScope = BaseApiAccessScope & ApiAccessScopeMetadata;
@@ -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,6 +2,7 @@ export const ClickhouseTableNames = {
traces: "traces",
observations: "observations",
scores: "scores",
dataset_run_items: "dataset_run_items",
// Virtual tables for dashboards
// TODO: Check if we can do this more elegantly
@@ -0,0 +1,34 @@
import { DatasetDeleteQueue } from "../redis/datasetDelete";
import { QueueJobs } from "../queues";
import { redis } from "../redis/redis";
import { randomUUID } from "crypto";
type DatasetDeletionType = "dataset" | "dataset-runs";
type DatasetDeletionPayload = {
deletionType: DatasetDeletionType;
projectId: string;
datasetId: string;
datasetRunIds?: string[];
};
export const addToDeleteDatasetQueue = async ({
deletionType,
projectId,
datasetId,
datasetRunIds = [],
}: DatasetDeletionPayload) => {
if (redis) {
await DatasetDeleteQueue.getInstance()?.add(QueueJobs.DatasetDelete, {
payload: {
deletionType,
projectId,
datasetId,
datasetRunIds,
},
id: randomUUID(),
timestamp: new Date(),
name: QueueJobs.DatasetDelete,
});
}
};
+7
View File
@@ -2,6 +2,7 @@ export * from "./services/StorageService";
export * from "./services/email/organizationInvitation/sendMembershipInvitationEmail";
export * from "./services/email/batchExportSuccess/sendBatchExportSuccessEmail";
export * from "./services/email/passwordReset/sendResetPasswordVerificationRequest";
export * from "./services/email/billingAlert/sendBillingAlertEmail";
export * from "./services/PromptService";
export * from "./services/PromptService/types";
export * from "./services/traces-ui-table-service";
@@ -32,6 +33,7 @@ export * from "./redis/webhookQueue";
export * from "./redis/traceDelete";
export * from "./redis/projectDelete";
export * from "./redis/scoreDelete";
export * from "./redis/datasetDelete";
export * from "./redis/datasetRunItemUpsert";
export * from "./redis/batchExport";
export * from "./redis/batchActionQueue";
@@ -56,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";
@@ -63,13 +66,17 @@ export * from "./services/DashboardService";
export * from "./services/TableViewService";
export * from "./services/DefaultEvaluationModelService";
export * from "./clickhouse/measureAndReturn";
export * from "./services/SlackService";
export * from "./data-deletion/ingestionFileDeletion";
export * from "./s3";
// dataset run items
export * from "./dataset-run-items/datasetExecution";
export * from "./dataset-run-items/types";
export * from "./dataset-run-items/addToDeleteQueue";
// test utils
export * from "./test-utils";
export * from "./utils/headerUtils";
export * from "./traceDeletionProcessor";
@@ -83,7 +83,7 @@ const getModelFromRedis = async (
if (redisModel) {
recordIncrement("langfuse.model_match.cache_hit", 1);
if (redisModel === NOT_FOUND_TOKEN) {
return null;
return NOT_FOUND_TOKEN;
}
const model = redisModelToPrismaModel(redisModel);
return model;
@@ -8,7 +8,7 @@ import {
LangfuseNotFoundError,
UnauthorizedError,
} from "../../errors";
import { AuthHeaderValidVerificationResult } from "../auth/types";
import { AuthHeaderValidVerificationResultIngestion } from "../auth/types";
import { getClickhouseEntityType } from "../clickhouse/schemaUtils";
import {
getCurrentSpan,
@@ -30,6 +30,7 @@ import {
StorageService,
StorageServiceFactory,
} from "../services/StorageService";
import { isTraceIdInSample } from "./sampling";
let s3StorageServiceClient: StorageService;
@@ -61,7 +62,7 @@ export type TokenCountDelegate = (p: {
* We need the delay around date boundaries to avoid duplicates for out-of-order processing of events.
* @param delay - Delay overwrite. Used if non-null.
*/
const getDelay = (delay: number | null) => {
const getDelay = (delay: number | null, source: "api" | "otel") => {
if (delay !== null) {
return delay;
}
@@ -73,6 +74,10 @@ const getDelay = (delay: number | null) => {
return env.LANGFUSE_INGESTION_QUEUE_DELAY_MS;
}
if (source === "otel") {
return 0;
}
// Use 5s here to avoid duplicate processing on the worker. If the ingestion delay is set to a lower value,
// we use this instead.
// Values should be revisited based on a cost/performance trade-off.
@@ -94,12 +99,12 @@ type ProcessEventBatchOptions = {
/**
* Processes a batch of events.
* @param input - Batch of IngestionEventType. Will validate the types first thing and return errors if they are invalid.
* @param authCheck - AuthHeaderValidVerificationResult
* @param authCheck - AuthHeaderValidVerificationResultIngestion
* @param options - (Optional) Options for the event batch processing.
*/
export const processEventBatch = async (
input: unknown[],
authCheck: AuthHeaderValidVerificationResult,
authCheck: AuthHeaderValidVerificationResultIngestion,
options: ProcessEventBatchOptions = {},
): Promise<{
successes: { id: string; status: number }[];
@@ -124,8 +129,10 @@ export const processEventBatch = async (
"langfuse.project.id",
authCheck.scope.projectId ?? "",
);
currentSpan?.setAttribute("langfuse.org.id", authCheck.scope.orgId);
currentSpan?.setAttribute("langfuse.org.plan", authCheck.scope.plan);
if (authCheck.scope.orgId)
currentSpan?.setAttribute("langfuse.org.id", authCheck.scope.orgId);
if (authCheck.scope.plan)
currentSpan?.setAttribute("langfuse.org.plan", authCheck.scope.plan);
/**************
* VALIDATION *
@@ -269,6 +276,27 @@ export const processEventBatch = async (
const shouldSkipS3List =
isDatasetRunItemEvent || (isObservationEvent && isOtelOrSkipS3Project);
const { isSampled, isSamplingConfigured } = isTraceIdInSample({
projectId: authCheck.scope.projectId,
event: eventData.data[0],
});
if (!isSampled) {
recordIncrement("langfuse.ingestion.sampling", eventData.data.length, {
projectId: authCheck.scope.projectId ?? "<not set>",
sampling_decision: "out",
});
return;
}
if (isSamplingConfigured) {
recordIncrement("langfuse.ingestion.sampling", eventData.data.length, {
projectId: authCheck.scope.projectId ?? "<not set>",
sampling_decision: "in",
});
}
return queue
? queue.add(
QueueJobs.IngestionJob,
@@ -292,7 +320,7 @@ export const processEventBatch = async (
},
},
},
{ delay: getDelay(delay) },
{ delay: getDelay(delay, source) },
)
: Promise.reject("Failed to instantiate queue");
}),
@@ -307,7 +335,7 @@ export const processEventBatch = async (
const isAuthorized = (
event: IngestionEventType,
authScope: AuthHeaderValidVerificationResult,
authScope: AuthHeaderValidVerificationResultIngestion,
): boolean => {
if (event.type === eventTypes.SDK_LOG) {
return true;
@@ -0,0 +1,59 @@
import crypto from "node:crypto";
import { logger } from "../logger";
import { env } from "../../env";
import { IngestionEventType } from "./types";
export function isTraceIdInSample(params: {
projectId: string | null;
event: IngestionEventType;
}): { isSampled: boolean; isSamplingConfigured: boolean } {
const { projectId, event } = params;
const sampledProjects = env.LANGFUSE_INGESTION_PROCESSING_SAMPLED_PROJECTS;
if (!projectId || !sampledProjects.has(projectId))
return { isSampled: true, isSamplingConfigured: false };
const sampleRate = sampledProjects.get(projectId);
if (sampleRate === undefined)
return { isSampled: true, isSamplingConfigured: true };
const traceId = parseTraceId(event);
if (!traceId) return { isSampled: true, isSamplingConfigured: true };
return {
isSampled: isInSample(traceId, sampleRate),
isSamplingConfigured: true,
};
}
function isInSample(traceId: string, sampleRate: number) {
if (sampleRate < 0 || sampleRate > 1) {
logger.error(`Invalid sample rate ${sampleRate}`);
// Be conservative and keep the trace ID in sample for invalid configs
return true;
}
if (sampleRate === 0) return false;
if (sampleRate === 1) return true;
// Create SHA-256 hash of the input
const hash = crypto.createHash("sha256").update(traceId).digest("hex");
// Take first 8 characters and convert to integer
// Equivalent to 4 bytes, 32 bit integer
const hashInt = parseInt(hash.substring(0, 8), 16);
// Convert to a value between 0 and 1 by dividing by largest integer
const normalizedHash = hashInt / 0xffffffff;
// Return true if normalized hash is less than sample rate
return normalizedHash < sampleRate;
}
function parseTraceId(event: IngestionEventType): string | null | undefined {
if (event.type === "trace-create") return event.body.id;
return "traceId" in event.body ? event.body.traceId : null;
}
@@ -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;
+11 -4
View File
@@ -113,6 +113,7 @@ export enum ChatMessageRole {
User = "user",
Assistant = "assistant",
Tool = "tool",
Model = "model", // Google Gemini assistant format
}
// Thought: should placeholder not semantically be part of this, because it can be
@@ -124,6 +125,7 @@ export enum ChatMessageType {
AssistantText = "assistant-text",
AssistantToolCall = "assistant-tool-call",
ToolResult = "tool-result",
ModelText = "model-text",
PublicAPICreated = "public-api-created",
Placeholder = "placeholder",
}
@@ -156,6 +158,13 @@ export const AssistantTextMessageSchema = z.object({
});
export type AssistantTextMessage = z.infer<typeof AssistantTextMessageSchema>;
export const ModelMessageSchema = z.object({
type: z.literal(ChatMessageType.ModelText),
role: z.literal(ChatMessageRole.Model),
content: z.string(),
});
export type ModelMessage = z.infer<typeof ModelMessageSchema>;
export const AssistantToolCallMessageSchema = z.object({
type: z.literal(ChatMessageType.AssistantToolCall),
role: z.literal(ChatMessageRole.Assistant),
@@ -193,6 +202,7 @@ export const ChatMessageSchema = z.union([
AssistantTextMessageSchema,
AssistantToolCallMessageSchema,
ToolResultMessageSchema,
ModelMessageSchema,
z
.object({
role: z.union([ChatMessageDefaultRoleSchema, z.string()]), // Users may ingest any string as role via API/SDK
@@ -226,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",
@@ -322,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",
@@ -368,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 = {
@@ -379,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,
+52 -2
View File
@@ -40,6 +40,21 @@ export const ScoresQueueEventSchema = z.object({
projectId: z.string(),
scoreIds: z.array(z.string()),
});
export const DatasetQueueEventSchema = z.discriminatedUnion("deletionType", [
// Delete all run items for a specific dataset
z.object({
deletionType: z.literal("dataset"),
projectId: z.string(),
datasetId: z.string(),
}),
// Delete all run items for multiple dataset runs (also used for single run deletion)
z.object({
deletionType: z.literal("dataset-runs"),
projectId: z.string(),
datasetId: z.string(),
datasetRunIds: z.array(z.string()),
}),
]);
export const ProjectQueueEventSchema = z.object({
projectId: z.string(),
orgId: z.string(),
@@ -101,6 +116,24 @@ export const BatchActionProcessingEventSchema = z.discriminatedUnion(
targetId: z.string().optional(),
type: z.enum(BatchActionType),
}),
z.object({
actionId: z.literal("session-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("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"]),
@@ -144,6 +177,7 @@ export const WebhookInputSchema = z.object({
payload: WebhookOutboundEnvelopeSchema,
});
export type WebhookInput = z.infer<typeof WebhookInputSchema>;
export const EntityChangeEventSchema = z.discriminatedUnion("entityType", [
z.object({
entityType: z.literal("prompt-version"),
@@ -154,8 +188,6 @@ export const EntityChangeEventSchema = z.discriminatedUnion("entityType", [
}),
// Add other entity types here in the future
]);
export type WebhookInput = z.infer<typeof WebhookInputSchema>;
export type EntityChangeEventType = z.infer<typeof EntityChangeEventSchema>;
export type CreateEvalQueueEventType = z.infer<
@@ -165,6 +197,7 @@ export type BatchExportJobType = z.infer<typeof BatchExportJobSchema>;
export type TraceQueueEventType = z.infer<typeof TraceQueueEventSchema>;
export type TracesQueueEventType = z.infer<typeof TracesQueueEventSchema>;
export type ScoresQueueEventType = z.infer<typeof ScoresQueueEventSchema>;
export type DatasetQueueEventType = z.infer<typeof DatasetQueueEventSchema>;
export type ProjectQueueEventType = z.infer<typeof ProjectQueueEventSchema>;
export type DatasetRunItemUpsertEventType = z.infer<
typeof DatasetRunItemUpsertEventSchema
@@ -192,6 +225,13 @@ export type DeadLetterRetryQueueEventType = z.infer<
export type WebhookQueueEventType = z.infer<typeof WebhookInputSchema>;
export const RetryBaggage = z.object({
originalJobTimestamp: z.date(),
attempt: z.number(),
});
export type RetryBaggage = z.infer<typeof RetryBaggage>;
export enum QueueName {
TraceUpsert = "trace-upsert", // Ingestion pipeline adds events on each Trace upsert
TraceDelete = "trace-delete",
@@ -214,6 +254,7 @@ export enum QueueName {
BatchActionQueue = "batch-action-queue",
CreateEvalQueue = "create-eval-queue",
ScoreDelete = "score-delete",
DatasetDelete = "dataset-delete-queue",
DeadLetterRetryQueue = "dead-letter-retry-queue",
WebhookQueue = "webhook-queue",
EntityChangeQueue = "entity-change-queue",
@@ -241,6 +282,7 @@ export enum QueueJobs {
BatchActionProcessingJob = "batch-action-processing-job",
CreateEvalJob = "create-eval-job",
ScoreDelete = "score-delete",
DatasetDelete = "dataset-delete-job",
DeadLetterRetryJob = "dead-letter-retry-job",
WebhookJob = "webhook-job",
EntityChangeJob = "entity-change-job",
@@ -265,6 +307,12 @@ export type TQueueJobTypes = {
payload: ScoresQueueEventType;
name: QueueJobs.ScoreDelete;
};
[QueueName.DatasetDelete]: {
timestamp: Date;
id: string;
payload: DatasetQueueEventType;
name: QueueJobs.DatasetDelete;
};
[QueueName.ProjectDelete]: {
timestamp: Date;
id: string;
@@ -282,6 +330,7 @@ export type TQueueJobTypes = {
id: string;
payload: EvalExecutionEventType;
name: QueueJobs.EvaluationExecution;
retryBaggage?: RetryBaggage;
};
[QueueName.BatchExport]: {
timestamp: Date;
@@ -306,6 +355,7 @@ export type TQueueJobTypes = {
id: string;
payload: ExperimentCreateEventType;
name: QueueJobs.ExperimentCreateJob;
retryBaggage?: RetryBaggage;
};
[QueueName.PostHogIntegrationProcessingQueue]: {
timestamp: Date;
@@ -0,0 +1,50 @@
import { QueueName, TQueueJobTypes } from "../queues";
import { Queue } from "bullmq";
import {
createNewRedisInstance,
redisQueueRetryOptions,
getQueuePrefix,
} from "./redis";
import { logger } from "../logger";
export class DatasetDeleteQueue {
private static instance: Queue<
TQueueJobTypes[QueueName.DatasetDelete]
> | null = null;
public static getInstance(): Queue<
TQueueJobTypes[QueueName.DatasetDelete]
> | null {
if (DatasetDeleteQueue.instance) return DatasetDeleteQueue.instance;
const newRedis = createNewRedisInstance({
enableOfflineQueue: false,
...redisQueueRetryOptions,
});
DatasetDeleteQueue.instance = newRedis
? new Queue<TQueueJobTypes[QueueName.DatasetDelete]>(
QueueName.DatasetDelete,
{
connection: newRedis,
prefix: getQueuePrefix(QueueName.DatasetDelete),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100_000,
attempts: 2,
backoff: {
type: "exponential",
delay: 30_000,
},
},
},
)
: null;
DatasetDeleteQueue.instance?.on("error", (err) => {
logger.error("DatasetDeleteQueue error", err);
});
return DatasetDeleteQueue.instance;
}
}
@@ -34,7 +34,7 @@ export class ExperimentCreateQueue {
attempts: 10,
backoff: {
type: "exponential",
delay: 1000,
delay: 10_000, // 10 seconds
},
},
},
@@ -23,6 +23,7 @@ import { ScoreDeleteQueue } from "./scoreDelete";
import { DeadLetterRetryQueue } from "./dlqRetryQueue";
import { WebhookQueue } from "./webhookQueue";
import { EntityChangeQueue } from "./entityChangeQueue";
import { DatasetDeleteQueue } from "./datasetDelete";
// IngestionQueue is sharded and requires a sharding key
// Use IngestionQueue.getInstance({ shardName: queueName }) directly instead
@@ -36,6 +37,8 @@ export function getQueue(
return CloudUsageMeteringQueue.getInstance();
case QueueName.DatasetRunItemUpsert:
return DatasetRunItemUpsertQueue.getInstance();
case QueueName.DatasetDelete:
return DatasetDeleteQueue.getInstance();
case QueueName.EvaluationExecution:
return EvalExecutionQueue.getInstance();
case QueueName.ExperimentCreate:
@@ -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,
@@ -1,6 +1,10 @@
import { QueueName, TQueueJobTypes } from "../queues";
import { Queue } from "bullmq";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import {
createNewRedisInstance,
getQueuePrefix,
redisQueueRetryOptions,
} from "./redis";
import { logger } from "../logger";
export class WebhookQueue {
@@ -23,6 +27,7 @@ export class WebhookQueue {
QueueName.WebhookQueue,
{
connection: newRedis,
prefix: getQueuePrefix(QueueName.WebhookQueue),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100_000,
@@ -2,17 +2,22 @@ import {
Action,
ActionExecutionStatus,
JobConfigState,
Prisma,
prisma,
Trigger,
} from "../../db";
import {
TriggerEventSource,
WebhookActionConfigWithSecrets,
TriggerDomain,
TriggerEventAction,
ActionDomain,
AutomationDomain,
SafeWebhookActionConfig,
ActionDomainWithSecrets,
SafeActionConfig,
isWebhookActionConfig,
WebhookActionConfigWithSecrets,
isSafeWebhookActionConfig,
convertToSafeWebhookConfig,
} from "../../domain/automations";
import { FilterState } from "../../types";
import { decryptSecretHeaders, mergeHeaders } from "../utils/headerUtils";
@@ -23,7 +28,7 @@ export const getActionByIdWithSecrets = async ({
}: {
projectId: string;
actionId: string;
}) => {
}): Promise<ActionDomainWithSecrets | null> => {
const actionConfig = await prisma.action.findFirst({
where: {
id: actionId,
@@ -35,31 +40,41 @@ export const getActionByIdWithSecrets = async ({
return null;
}
const config = actionConfig.config as WebhookActionConfigWithSecrets;
if (isWebhookActionConfig(actionConfig.config)) {
const config = actionConfig.config; // Type guard ensures this is WebhookActionConfigWithSecrets
// Decrypt secret headers for webhook execution using new structure
const decryptedHeaders = config.requestHeaders
? decryptSecretHeaders(mergeHeaders(config.headers, config.requestHeaders))
: Object.entries(config.headers).reduce(
(acc, [key, value]) => {
acc[key] = { secret: false, value };
return acc;
},
{} as Record<string, { secret: boolean; value: string }>,
);
// Decrypt secret headers for webhook execution using new structure
const decryptedHeaders = config.requestHeaders
? decryptSecretHeaders(
mergeHeaders(config.headers, config.requestHeaders),
)
: config.headers
? Object.entries(config.headers).reduce(
(acc, [key, value]) => {
acc[key] = { secret: false, value };
return acc;
},
{} as Record<string, { secret: boolean; value: string }>,
)
: {};
return {
...actionConfig,
config: {
type: config.type,
url: config.url,
requestHeaders: decryptedHeaders,
displayHeaders: config.displayHeaders,
apiVersion: config.apiVersion,
displaySecretKey: config.displaySecretKey,
secretKey: config.secretKey,
},
};
return {
...actionConfig,
config: {
type: config.type,
url: config.url,
requestHeaders: decryptedHeaders,
displayHeaders: getDisplayHeaders(config),
apiVersion: config.apiVersion,
displaySecretKey: config.displaySecretKey,
secretKey: config.secretKey,
lastFailingExecutionId: config.lastFailingExecutionId,
},
};
}
// For SLACK and others, return as stored (already safe)
return actionConfig as ActionDomainWithSecrets;
};
export const getActionById = async ({
@@ -128,10 +143,7 @@ const convertTriggerToDomain = (trigger: Trigger): TriggerDomain => {
};
};
const convertActionToDomain = (action: Action): ActionDomain => {
const config = action.config as WebhookActionConfigWithSecrets;
// Handle legacy headers - convert them to displayHeaders format if displayHeaders is undefined
const getDisplayHeaders = (config: WebhookActionConfigWithSecrets) => {
let displayHeaders = config.displayHeaders;
if (!displayHeaders && config.headers) {
// Convert legacy headers to displayHeaders format
@@ -143,17 +155,25 @@ const convertActionToDomain = (action: Action): ActionDomain => {
{} as Record<string, { secret: boolean; value: string }>,
);
}
return displayHeaders;
};
const convertActionToDomain = (action: Action): ActionDomain => {
if (isWebhookActionConfig(action.config)) {
const config = action.config;
config.displayHeaders = getDisplayHeaders(config);
return {
...action,
config: convertToSafeWebhookConfig(config),
};
}
// For SLACK (or future types) return config as-is
return {
...action,
config: {
type: config.type,
url: config.url,
displayHeaders,
apiVersion: config.apiVersion,
displaySecretKey: config.displaySecretKey,
} as SafeWebhookActionConfig,
};
config: action.config as SafeActionConfig,
} as ActionDomain;
};
export const getAutomationById = async ({
@@ -225,28 +245,49 @@ export const getConsecutiveAutomationFailures = async ({
automationId: string;
projectId: string;
}): Promise<number> => {
// First get the automation to extract triggerId and actionId
const automation = await prisma.automation.findFirst({
where: {
id: automationId,
projectId,
},
const automation = await getAutomationById({
automationId,
projectId,
});
if (!automation) {
return 0;
}
const { triggerId, actionId } = automation;
const executions = await prisma.automationExecution.findMany({
where: {
triggerId,
actionId,
projectId,
status: {
in: [ActionExecutionStatus.ERROR, ActionExecutionStatus.COMPLETED],
},
// Build where clause - if lastFailingExecutionId is set, only consider executions newer than it
const whereClause: Prisma.AutomationExecutionWhereInput = {
triggerId: automation.trigger.id,
actionId: automation.action.id,
projectId,
status: {
in: [ActionExecutionStatus.ERROR, ActionExecutionStatus.COMPLETED],
},
};
// If there's a lastFailingExecutionId, we need to get executions that are newer than that execution
if (
isSafeWebhookActionConfig(automation.action.config) &&
automation.action.config.lastFailingExecutionId
) {
// First get the timestamp of the last failing execution
const lastFailingExecution = await prisma.automationExecution.findUnique({
where: {
id: automation.action.config.lastFailingExecutionId,
},
select: {
createdAt: true,
},
});
if (lastFailingExecution) {
whereClause.createdAt = {
gt: lastFailingExecution.createdAt,
};
}
}
const executions = await prisma.automationExecution.findMany({
where: whereClause,
orderBy: {
createdAt: "desc",
},
@@ -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>;
@@ -0,0 +1,458 @@
import { DatasetRunItemDomain } from "../../domain/dataset-run-items";
import { type OrderByState } from "../../interfaces/orderBy";
import { datasetRunItemsTableUiColumnDefinitions } from "../../tableDefinitions";
import { FilterState } from "../../types";
import {
createFilterFromFilterState,
FilterList,
orderByToClickhouseSql,
StringFilter,
} from "../queries";
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,
) => {
return {
datasetRunItemsFilter: new FilterList([
new StringFilter({
clickhouseTable: "dataset_run_items",
field: "project_id",
operator: "=",
value: projectId,
}),
new StringFilter({
clickhouseTable: "dataset_run_items",
field: "dataset_id",
operator: "=",
value: datasetId,
}),
]),
};
};
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";
tags: Record<string, string>;
},
): Promise<Array<T>> => {
const { projectId, datasetId, filter, orderBy, limit, offset } = opts;
let selectString = "";
switch (opts.select) {
case "count":
selectString =
"count(DISTINCT dri.project_id, dri.dataset_id, dri.dataset_run_id, dri.dataset_item_id) as count";
break;
case "rows":
selectString = `
dri.id as id,
dri.project_id as project_id,
dri.trace_id as trace_id,
dri.observation_id as observation_id,
dri.dataset_id as dataset_id,
dri.dataset_run_id as dataset_run_id,
dri.dataset_item_id as dataset_item_id,
dri.error as error,
dri.created_at as created_at,
dri.updated_at as updated_at,
dri.dataset_run_name as dataset_run_name,
dri.dataset_run_description as dataset_run_description,
dri.dataset_run_metadata as dataset_run_metadata,
dri.dataset_run_created_at as dataset_run_created_at,
dri.dataset_item_input as dataset_item_input,
dri.dataset_item_expected_output as dataset_item_expected_output,
dri.dataset_item_metadata as dataset_item_metadata,
dri.is_deleted as is_deleted,
dri.event_ts as event_ts`;
break;
default:
throw new Error(`Unknown select type: ${opts.select}`);
}
const { datasetRunItemsFilter } = getProjectDatasetIdDefaultFilter(
projectId,
datasetId,
);
datasetRunItemsFilter.push(
...createFilterFromFilterState(
filter,
datasetRunItemsTableUiColumnDefinitions,
),
);
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) {
if (Array.isArray(orderBy)) {
orderByArray.push(...orderBy);
} else {
orderByArray.push(orderBy);
}
}
// Add event_ts DESC for row queries (for deduplication)
if (opts.select === "rows") {
orderByArray.push({
column: "eventTs",
order: "DESC",
});
}
const orderByClause = orderByToClickhouseSql(
orderByArray,
datasetRunItemsTableUiColumnDefinitions,
);
const query = `
SELECT
${selectString}
FROM dataset_run_items dri
WHERE ${appliedFilter.query}
${orderByClause}
${opts.select === "rows" ? "LIMIT 1 BY dri.project_id, dri.dataset_id, dri.dataset_run_id, dri.dataset_item_id" : ""}
${limit !== undefined && offset !== undefined ? `LIMIT ${limit} OFFSET ${offset}` : ""};`;
const res = await queryClickhouse<T>({
query,
params: {
...appliedFilter.params,
},
tags: {
...(opts.tags ?? {}),
feature: "datasets",
type: "dataset-run-items",
projectId,
datasetId,
},
});
return res;
};
export const getDatasetRunItemsByDatasetIdCh = async (
opts: DatasetRunItemsTableQuery,
): Promise<DatasetRunItemDomain[]> => {
const rows =
await getDatasetRunItemsTableInternal<DatasetRunItemRecordReadType>({
...opts,
select: "rows",
tags: { kind: "list" },
});
return rows.map(convertDatasetRunItemClickhouseToDomain);
};
export const getDatasetRunItemsCountByDatasetIdCh = async (
opts: DatasetRunItemsTableQuery,
): Promise<number> => {
const rows = await getDatasetRunItemsTableInternal<{ count: string }>({
...opts,
select: "count",
tags: { kind: "list" },
});
return Number(rows[0]?.count);
};
export const deleteDatasetRunItemsByProjectId = async ({
projectId,
}: {
projectId: string;
}) => {
const query = `
DELETE FROM dataset_run_items
WHERE project_id = {projectId: String};
`;
await commandClickhouse({
query: query,
params: {
projectId,
},
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: {
feature: "datasets",
type: "dataset-run-items",
kind: "delete",
projectId,
},
});
};
export const deleteDatasetRunItemsByDatasetId = async ({
projectId,
datasetId,
}: {
projectId: string;
datasetId: string;
}) => {
const query = `
DELETE FROM dataset_run_items
WHERE project_id = {projectId: String}
AND dataset_id = {datasetId: String}
`;
await commandClickhouse({
query,
params: {
projectId,
datasetId,
},
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: {
feature: "datasets",
type: "dataset-run-items",
kind: "delete",
projectId,
},
});
};
export const deleteDatasetRunItemsByDatasetRunIds = async ({
projectId,
datasetRunIds,
datasetId,
}: {
projectId: string;
datasetRunIds: string[];
datasetId: string;
}) => {
const query = `
DELETE FROM dataset_run_items
WHERE project_id = {projectId: String}
AND dataset_id = {datasetId: String}
AND dataset_run_id IN ({datasetRunIds: Array(String)})
`;
await commandClickhouse({
query,
params: {
projectId,
datasetRunIds,
datasetId,
},
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: {
feature: "datasets",
type: "dataset-run-items",
kind: "delete",
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(),
@@ -355,31 +357,37 @@ export const convertPostgresDatasetRunItemToInsert = (
project_id: datasetRunItem.project_id,
dataset_run_id: datasetRunItem.dataset_run_id,
dataset_item_id: datasetRunItem.dataset_item_id,
dataset_id: datasetRunItem.dataset_id,
trace_id: datasetRunItem.trace_id,
observation_id: datasetRunItem.observation_id,
error: datasetRunItem.error,
created_at: datasetRunItem.created_at?.getTime(),
updated_at: datasetRunItem.updated_at?.getTime(),
// denormalized run data
dataset_run_created_at: datasetRunItem.dataset_run_created_at?.getTime(),
dataset_id: datasetRunItem.dataset_id,
dataset_run_name: datasetRunItem.dataset_run_name,
dataset_run_description: datasetRunItem.dataset_run_description,
dataset_run_metadata:
typeof datasetRunItem.dataset_run_metadata === "string"
? { dataset_run_metadata: datasetRunItem.dataset_run_metadata }
typeof datasetRunItem.dataset_run_metadata === "string" ||
typeof datasetRunItem.dataset_run_metadata === "number" ||
typeof datasetRunItem.dataset_run_metadata === "boolean"
? { metadata: datasetRunItem.dataset_run_metadata }
: Array.isArray(datasetRunItem.dataset_run_metadata)
? { dataset_run_metadata: datasetRunItem.dataset_run_metadata }
: datasetRunItem.dataset_run_metadata,
? { metadata: datasetRunItem.dataset_run_metadata }
: (datasetRunItem.dataset_run_metadata ?? {}),
dataset_run_created_at: datasetRunItem.dataset_run_created_at?.getTime(),
// denormalized item data
dataset_item_input: datasetRunItem.dataset_item_input,
dataset_item_expected_output: datasetRunItem.dataset_item_expected_output,
dataset_item_input: JSON.stringify(datasetRunItem.dataset_item_input),
dataset_item_expected_output: JSON.stringify(
datasetRunItem.dataset_item_expected_output,
),
dataset_item_metadata:
typeof datasetRunItem.dataset_item_metadata === "string"
? { dataset_item_metadata: datasetRunItem.dataset_item_metadata }
typeof datasetRunItem.dataset_item_metadata === "string" ||
typeof datasetRunItem.dataset_item_metadata === "number" ||
typeof datasetRunItem.dataset_item_metadata === "boolean"
? { metadata: datasetRunItem.dataset_item_metadata }
: Array.isArray(datasetRunItem.dataset_item_metadata)
? { dataset_item_metadata: datasetRunItem.dataset_item_metadata }
: datasetRunItem.dataset_item_metadata,
? { metadata: datasetRunItem.dataset_item_metadata }
: (datasetRunItem.dataset_item_metadata ?? {}),
event_ts: datasetRunItem.created_at?.getTime(),
is_deleted: 0,
};
@@ -489,9 +497,9 @@ export const convertPostgresScoreToInsert = (
};
};
export const convertTraceToTraceMt = (
export const convertTraceToTraceNull = (
traceRecord: TraceRecordInsertType,
): TraceMtRecordInsertType => {
): TraceNullRecordInsertType => {
return {
// Identifiers
project_id: traceRecord.project_id,
@@ -529,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,
@@ -573,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
@@ -14,3 +14,4 @@ export * from "./blobStorageLog";
export * from "./environments";
export * from "./automation-repository";
export * from "./dataset-run-items-converters";
export * from "./dataset-run-items";
@@ -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,
File diff suppressed because it is too large Load Diff
@@ -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),
@@ -0,0 +1,386 @@
/**
* Slack Integration Service
*
* Simplified service that properly uses the official Slack SDK libraries:
* - @slack/oauth InstallProvider for OAuth flow management
* - @slack/web-api WebClient for Slack API operations
* - Metadata-based project-to-team mapping
*/
import { WebClient } from "@slack/web-api";
import { InstallProvider } from "@slack/oauth";
import { logger } from "../logger";
import { env } from "../../env";
import { prisma } from "../../db";
import { encrypt, decrypt } from "../../encryption";
// Types for Slack integration
export interface SlackChannel {
id: string;
name: string;
isPrivate: boolean;
isMember: boolean;
}
export interface SlackMessageParams {
client: WebClient;
channelId: string;
blocks: any[];
text?: string;
}
export interface SlackMessageResponse {
messageTs: string;
channel: string;
}
// Interface for Slack installation metadata
export interface SlackInstallationMetadata {
projectId: string;
}
/**
* Type guard to validate Slack installation metadata
*/
function isSlackInstallationMetadata(
metadata: unknown,
): metadata is SlackInstallationMetadata {
return (
typeof metadata === "object" &&
metadata !== null &&
"projectId" in metadata &&
typeof metadata.projectId === "string" &&
metadata.projectId.length > 0
);
}
/**
* Helper function to safely parse and validate Slack installation metadata
*/
export function parseSlackInstallationMetadata(
metadata: unknown,
): SlackInstallationMetadata {
if (typeof metadata !== "string") {
throw new Error("Installation metadata must be a string");
}
let parsedMetadata: unknown;
try {
parsedMetadata = JSON.parse(metadata);
} catch {
throw new Error("Failed to parse installation metadata as JSON");
}
if (!isSlackInstallationMetadata(parsedMetadata)) {
throw new Error(
"Invalid installation metadata: missing or invalid projectId",
);
}
return parsedMetadata;
}
/**
* Slack Service Class
*
* Uses InstallProvider for OAuth flow and metadata-based project mapping.
* Much simpler than the previous implementation while maintaining all functionality.
*/
export class SlackService {
private static instance: SlackService | null = null;
private installer: InstallProvider;
private constructor() {
this.installer = new InstallProvider({
clientId: env.SLACK_CLIENT_ID!,
clientSecret: env.SLACK_CLIENT_SECRET!,
stateSecret: env.SLACK_STATE_SECRET!,
installUrlOptions: {
scopes: ["channels:read", "chat:write", "chat:write.public"],
},
installationStore: {
storeInstallation: async (installation) => {
try {
const metadata = parseSlackInstallationMetadata(
installation.metadata,
);
const projectId = metadata.projectId;
logger.info("Storing Slack installation for project", {
projectId,
teamId: installation.team?.id,
teamName: installation.team?.name,
});
// Store by projectId (one integration per project)
await prisma.slackIntegration.upsert({
where: { projectId },
create: {
projectId,
teamId: installation.team?.id!,
teamName: installation.team?.name!,
botToken: encrypt(installation.bot?.token!),
botUserId: installation.bot?.userId!,
},
update: {
teamId: installation.team?.id!,
teamName: installation.team?.name!,
botToken: encrypt(installation.bot?.token!),
botUserId: installation.bot?.userId!,
},
});
logger.info("Slack installation stored successfully", {
projectId,
teamId: installation.team?.id,
});
} catch (error) {
logger.error("Failed to store Slack installation", { error });
throw error;
}
},
fetchInstallation: async (installQuery) => {
try {
// Handle both teamId and projectId lookups
// When SDK calls with teamId, we treat it as projectId
const lookupId = installQuery.teamId;
if (!lookupId) {
throw new Error("No lookup ID provided");
}
const integration = await prisma.slackIntegration.findFirst({
where: {
OR: [
{ teamId: lookupId }, // Actual team ID lookup
{ projectId: lookupId }, // Project ID lookup (our custom usage)
],
},
});
if (!integration) {
throw new Error("Slack integration not found");
}
// Return full Installation interface as expected by SDK
return {
team: {
id: integration.teamId,
name: integration.teamName,
},
bot: {
id: integration.botUserId,
token: decrypt(integration.botToken),
userId: integration.botUserId,
scopes: [],
},
enterprise: undefined,
user: {
token: undefined,
refreshToken: undefined,
expiresAt: undefined,
scopes: undefined,
id: integration.botUserId,
},
};
} catch (error) {
logger.error("Failed to fetch Slack installation", { error });
throw error;
}
},
deleteInstallation: async (installQuery) => {
try {
const lookupId = installQuery.teamId;
if (!lookupId) {
throw new Error("No lookup ID provided for deletion");
}
await prisma.slackIntegration.deleteMany({
where: {
OR: [{ teamId: lookupId }, { projectId: lookupId }],
},
});
logger.info("Slack installation deleted successfully", {
lookupId,
});
} catch (error) {
logger.error("Failed to delete Slack installation", { error });
throw error;
}
},
},
});
}
/**
* Get singleton instance of SlackService
*/
static getInstance(): SlackService {
if (!SlackService.instance) {
SlackService.instance = new SlackService();
}
return SlackService.instance;
}
/**
* Get the configured InstallProvider instance for OAuth handling
*/
getInstaller(): InstallProvider {
return this.installer;
}
/**
* Reset the singleton instance (useful for testing)
*/
static resetInstance(): void {
SlackService.instance = null;
}
/**
* Delete Slack integration for a project
*/
async deleteIntegration(projectId: string): Promise<void> {
try {
if (!this.installer.installationStore?.deleteInstallation) {
throw new Error("Installation store not configured");
}
await this.installer.installationStore.deleteInstallation({
teamId: projectId,
isEnterpriseInstall: false,
enterpriseId: undefined,
});
logger.info("Slack integration deleted for project", { projectId });
} catch (error) {
logger.error("Failed to delete Slack integration", { error, projectId });
throw new Error(
`Failed to delete integration: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
/**
* Get WebClient for a specific project
*/
async getWebClientForProject(projectId: string): Promise<WebClient> {
try {
// Use projectId as the teamId parameter (handled by our fetchInstallation)
const auth = await this.installer.authorize({
teamId: projectId,
isEnterpriseInstall: false,
enterpriseId: undefined,
});
if (!auth.botToken) {
throw new Error("No bot token found for project");
}
const client = new WebClient(auth.botToken);
logger.debug("Created WebClient for project", { projectId });
return client;
} catch (error) {
logger.error("Failed to create WebClient for project", {
error,
projectId,
});
throw new Error(
`Failed to create WebClient: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
/**
* Get channels accessible to the bot
*/
async getChannels(client: WebClient): Promise<SlackChannel[]> {
try {
const result = await client.conversations.list({
exclude_archived: true,
types: "public_channel",
limit: 200,
});
if (!result.ok) {
throw new Error(`Slack API error: ${result.error}`);
}
const channels: SlackChannel[] = (result.channels || []).map(
(channel) => ({
id: channel.id!,
name: channel.name!,
isPrivate: channel.is_private || false,
isMember: channel.is_member || false,
}),
);
logger.debug("Retrieved channels from Slack", {
channelCount: channels.length,
});
return channels;
} catch (error) {
logger.error("Failed to fetch channels", { error });
throw new Error(
`Failed to fetch channels: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
/**
* Send a message to a Slack channel
*/
async sendMessage(params: SlackMessageParams): Promise<SlackMessageResponse> {
try {
const result = await params.client.chat.postMessage({
channel: params.channelId,
blocks: params.blocks,
text: params.text || "Langfuse Notification",
unfurl_links: false,
unfurl_media: false,
});
if (!result.ok) {
throw new Error(`Failed to send message: ${result.error}`);
}
const response = {
messageTs: result.ts!,
channel: result.channel!,
};
logger.info("Message sent successfully to Slack", {
channel: params.channelId,
messageTs: response.messageTs,
});
return response;
} catch (error) {
logger.error("Failed to send message", {
error,
channelId: params.channelId,
});
throw new Error(
`Failed to send message: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
/**
* Validate a WebClient instance
*/
async validateClient(client: WebClient): Promise<boolean> {
try {
const result = await client.auth.test();
return result.ok || false;
} catch (error) {
logger.warn("Client validation failed", { error });
return false;
}
}
}
@@ -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}`,
@@ -0,0 +1,134 @@
import React from "react";
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Img,
Preview,
Section,
Text,
Tailwind,
Row,
Column,
} from "@react-email/components";
interface BillingAlertEmailProps {
organizationName: string;
currentUsage: number;
threshold: number;
billingUrl: string;
receiverEmail: string;
}
export const BillingAlertEmailTemplate = ({
organizationName,
currentUsage,
threshold,
billingUrl,
receiverEmail,
}: BillingAlertEmailProps) => {
return (
<Html>
<Head />
<Preview>
Your Langfuse Cloud usage is {`${currentUsage}`} events for the current
billing period
</Preview>
<Tailwind>
<Body className="bg-background my-auto mx-auto font-sans">
<Container className="mx-auto my-10 w-[465px] rounded border border-solid border-[#eaeaea] p-5">
<Section className="mt-8">
<Img
src="https://static.langfuse.com/langfuse_logo_transactional_email.png"
width="40"
height="40"
alt="Langfuse"
className="mx-auto my-0"
/>
</Section>
<Section>
<Heading className="mx-0 my-[30px] p-0 text-center text-2xl font-normal text-black">
Usage Threshold Exceeded
</Heading>
<Text className="text-gray-700 text-sm leading-6">
Your organization &quot;{organizationName}&quot; has exceeded
the configured billing threshold
</Text>
</Section>
<Section className="mt-8">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<Row>
<Column className="text-center">
<Text className="text-gray-600 text-sm font-medium m-0 mb-1">
Current Usage (# Events)
</Text>
<Text className="text-2xl font-bold text-gray-900 m-0">
{currentUsage}
</Text>
</Column>
<Column className="text-center">
<Text className="text-gray-600 text-sm font-medium m-0 mb-1">
Alert Threshold (# Events)
</Text>
<Text className="text-2xl font-bold text-gray-900 m-0">
{threshold}
</Text>
</Column>
</Row>
</div>
</Section>
<Section className="mt-8 text-center">
<Button
className="rounded bg-black px-5 py-3 text-center text-xs font-semibold text-white no-underline"
href={billingUrl}
>
View Billing Page and Manage Alerts
</Button>
</Section>
<Section className="mt-8">
<Heading className="text-black text-[18px] font-semibold">
What happens next?
</Heading>
<Text className="text-gray-700 text-sm leading-6">
Your current billing cycle continues normally
<br />
Charges will appear on your next invoice
<br />
You can adjust usage or modify alert thresholds
<br /> Contact support if you have questions about your bill
</Text>
</Section>
<Hr className="border border-solid border-[#eaeaea] my-[26px] mx-0 w-full" />
<Section>
<Text className="text-[#666666] text-[12px] leading-[24px]">
This email was sent to {receiverEmail} regarding billing alerts
for &quot;{organizationName}&quot;.
</Text>
<Text className="text-[#666666] text-[12px] leading-[24px]">
Questions? Contact us at{" "}
<a
href="mailto:support@langfuse.com"
className="text-blue-600 no-underline"
>
support@langfuse.com
</a>
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default BillingAlertEmailTemplate;
@@ -0,0 +1,59 @@
import { createTransport } from "nodemailer";
import { parseConnectionUrl } from "nodemailer/lib/shared/index.js";
import { render } from "@react-email/render";
import { BillingAlertEmailTemplate } from "./BillingAlertEmailTemplate";
import { logger } from "../../../logger";
export interface BillingAlertEmailProps {
env: Partial<
Record<"EMAIL_FROM_ADDRESS" | "SMTP_CONNECTION_URL", string | undefined>
>;
organizationName: string;
currentUsage: number;
threshold: number;
billingUrl: string;
receiverEmail: string;
}
export const sendBillingAlertEmail = async ({
env,
organizationName,
currentUsage,
threshold,
billingUrl,
receiverEmail,
}: BillingAlertEmailProps) => {
if (!env.EMAIL_FROM_ADDRESS || !env.SMTP_CONNECTION_URL) {
logger.error(
"Missing environment variables for sending billing alert email.",
);
return;
}
try {
const mailer = createTransport(parseConnectionUrl(env.SMTP_CONNECTION_URL));
const emailSubject = `Langfuse Cloud Billing Alert: ${organizationName} usage exceeded ${threshold} events`;
const emailHtml = await render(
BillingAlertEmailTemplate({
organizationName,
currentUsage,
threshold,
billingUrl,
receiverEmail,
}),
);
await mailer.sendMail({
to: receiverEmail,
from: {
address: env.EMAIL_FROM_ADDRESS,
name: "Langfuse",
},
subject: emailSubject,
html: emailHtml,
});
} catch (error) {
logger.error(`Failed to send billing alert email`, error);
}
};
@@ -3,6 +3,7 @@ import { OrderByState } from "../../interfaces/orderBy";
import { sessionCols } from "../../tableDefinitions/mapSessionTable";
import { FilterState } from "../../types";
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
import { measureAndReturn } from "../clickhouse/measureAndReturn";
import { DateTimeFilter, FilterList, orderByToClickhouseSql } from "../queries";
import {
getProjectIdDefaultFilter,
@@ -11,6 +12,7 @@ import {
import {
TRACE_TO_OBSERVATIONS_INTERVAL,
queryClickhouse,
getTimeframesTracesAMT,
} from "../repositories";
export type SessionDataReturnType = {
@@ -22,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 & {
@@ -157,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;
@@ -165,7 +171,7 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
}
}
const { tracesFilter } = getProjectIdDefaultFilter(projectId, {
const { tracesFilter, scoresFilter } = getProjectIdDefaultFilter(projectId, {
tracesPrefix: "s",
});
@@ -174,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) =>
@@ -193,20 +200,25 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
);
}
const additionalSingleTraceFilter = tracesFilter.find(
(f) =>
f.field === "bookmarked" ||
f.field === "session_id" ||
f.field === "environment",
);
if (additionalSingleTraceFilter) {
filters.push(additionalSingleTraceFilter);
}
tracesFilter
.filter(
(f) =>
f.field === "bookmarked" ||
f.field === "session_id" ||
f.field === "environment",
)
.forEach((f) => filters.push(f));
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,115 +247,188 @@ 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 (
SELECT * EXCEPT input, output, metadata
FROM traces t
WHERE t.session_id IS NOT NULL
AND t.project_id = {projectId: String}
${singleTraceFilter?.query ? ` AND ${singleTraceFilter.query}` : ""}
ORDER BY event_ts DESC
LIMIT 1 BY id, project_id
),
deduplicated_observations AS (
SELECT *
FROM observations o
WHERE o.project_id = {projectId: String}
${traceTimestampFilter ? `AND o.start_time >= {observationsStartTime: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
AND o.trace_id IN (
SELECT id
FROM deduplicated_traces
)
ORDER BY event_ts DESC
LIMIT 1 BY id, project_id
),
observations_agg AS (
SELECT o.trace_id,
count(*) as obs_count,
min(o.start_time) as min_start_time,
max(o.end_time) as max_end_time,
sumMap(usage_details) as sum_usage_details,
sumMap(cost_details) as sum_cost_details,
anyLast(project_id) as project_id
FROM deduplicated_observations o
WHERE o.project_id = {projectId: String}
${traceTimestampFilter ? `AND o.start_time >= {observationsStartTime: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
GROUP BY o.trace_id
),
session_data AS (
SELECT
t.session_id,
anyLast(t.project_id) as project_id,
max(t.timestamp) as max_timestamp,
min(t.timestamp) as min_timestamp,
groupArray(t.id) AS trace_ids,
groupUniqArray(t.user_id) AS user_ids,
count(*) as trace_count,
groupUniqArrayArray(t.tags) as trace_tags,
anyLast(t.environment) as trace_environment
-- Aggregate observations data at session level
${
selectMetrics
? `
,
sum(o.obs_count) as total_observations,
-- Use minIf, because ClickHouse fills 1970-01-01 on left joins. We assume that no
-- LLM session started on that date so this behaviour should yield better results.
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,
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,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sumMap(o.sum_usage_details)))) as session_input_usage,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, sumMap(o.sum_usage_details)))) as session_output_usage,
sumMap(o.sum_usage_details)['total'] as session_total_usage`
: ""
}
FROM deduplicated_traces t
${
selectMetrics
? `LEFT JOIN observations_agg o
ON t.id = o.trace_id AND t.project_id = o.project_id`
: ""
}
WHERE t.session_id IS NOT NULL
WITH ${select === "metrics" || requiresScoresJoin ? `${scoresCte},` : ""}
deduplicated_traces AS (
SELECT * EXCEPT input, output, metadata
FROM __TRACE_TABLE__ t
WHERE t.session_id IS NOT NULL
AND t.project_id = {projectId: String}
${singleTraceFilter?.query ? ` AND ${singleTraceFilter.query}` : ""}
GROUP BY t.session_id
)
SELECT ${sqlSelect}
FROM session_data s
WHERE ${tracesFilterRes.query ? tracesFilterRes.query : ""}
${orderByToClickhouseSql(orderBy ?? null, sessionCols)}
${limit !== undefined && page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
`;
LIMIT 1 BY id, project_id
),
deduplicated_observations AS (
SELECT *
FROM observations o
WHERE o.project_id = {projectId: String}
${traceTimestampFilter ? `AND o.start_time >= {observationsStartTime: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
AND o.trace_id IN (
SELECT id
FROM deduplicated_traces
)
ORDER BY event_ts DESC
LIMIT 1 BY id, project_id
),
observations_agg AS (
SELECT o.trace_id,
count(*) as obs_count,
min(o.start_time) as min_start_time,
max(o.end_time) as max_end_time,
sumMap(usage_details) as sum_usage_details,
sumMap(cost_details) as sum_cost_details,
anyLast(project_id) as project_id
FROM deduplicated_observations o
WHERE o.project_id = {projectId: String}
${traceTimestampFilter ? `AND o.start_time >= {observationsStartTime: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
GROUP BY o.trace_id
),
session_data AS (
SELECT
t.session_id,
anyLast(t.project_id) as project_id,
max(t.timestamp) as max_timestamp,
min(t.timestamp) as min_timestamp,
groupArray(t.id) AS trace_ids,
groupUniqArray(t.user_id) AS user_ids,
count(*) as trace_count,
groupUniqArrayArray(t.tags) as trace_tags,
anyLast(t.environment) as trace_environment
-- Aggregate observations data at session level
${
selectMetrics
? `,
sum(o.obs_count) as total_observations,
-- Use minIf, because ClickHouse fills 1970-01-01 on left joins. We assume that no
-- LLM session started on that date so this behaviour should yield better results.
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,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sumMap(o.sum_usage_details)))) as session_input_usage,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, sumMap(o.sum_usage_details)))) as session_output_usage,
sumMap(o.sum_usage_details)['total'] as session_total_usage`
: ""
}
FROM deduplicated_traces t
${
selectMetrics
? `LEFT JOIN observations_agg o
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}` : ""}
GROUP BY t.session_id
)
SELECT ${sqlSelect}
FROM session_data s
WHERE ${tracesFilterRes.query ? tracesFilterRes.query : ""}
${orderByToClickhouseSql(orderBy ?? null, sessionCols)}
${limit !== undefined && page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
`;
const obsStartTimeValue = traceTimestampFilter
? convertDateToClickhouseDateTime(traceTimestampFilter.value)
: null;
const res = await queryClickhouse<T>({
query: query,
params: {
projectId,
limit: limit,
offset: limit && page ? limit * page : 0,
...tracesFilterRes.params,
...singleTraceFilter?.params,
...(obsStartTimeValue
? { observationsStartTime: obsStartTimeValue }
: {}),
return measureAndReturn({
operationName: "getSessionsTableGeneric",
projectId,
minStartTime: filter?.find(
(f) =>
f.column === "min_timestamp" &&
(f.operator === ">=" || f.operator === ">"),
)?.value as Date | undefined,
input: {
params: {
projectId,
limit: limit,
offset: limit && page ? limit * page : 0,
...tracesFilterRes.params,
...singleTraceFilter?.params,
...scoresFilterRes.params,
...(traceTimestampFilter
? {
observationsStartTime: convertDateToClickhouseDateTime(
traceTimestampFilter.value,
),
}
: {}),
},
tags: {
...(props.tags ?? {}),
feature: "tracing",
type: "sessions-table",
projectId,
operation_name: "getSessionsTableGeneric",
},
},
tags: {
...(props.tags ?? {}),
feature: "tracing",
type: "sessions-table",
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) => {
// Extract the timestamp from filter for AMT table selection
const fromTimestamp = filter?.find(
(f) =>
f.column === "min_timestamp" &&
(f.operator === ">=" || f.operator === ">"),
)?.value as Date | undefined;
const traceAmt = getTimeframesTracesAMT(fromTimestamp);
return queryClickhouse<T>({
query: query.replace("__TRACE_TABLE__", traceAmt),
params: input.params,
tags: { ...input.tags, experiment_amt: "new" },
clickhouseConfigs,
});
},
clickhouseConfigs,
});
return res;
};
@@ -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;
};
@@ -5,3 +5,4 @@ export * from "./mapTracesTable";
export * from "./mapDashboards";
export * from "./mapScoresTable";
export * from "./promptsTable";
export * from "./mapDatasetRunItemsTable";
@@ -0,0 +1,28 @@
import { UiColumnMappings } from "./types";
export const datasetRunItemsTableUiColumnDefinitions: UiColumnMappings = [
{
uiTableName: "Dataset Run ID",
uiTableId: "datasetRunId",
clickhouseTableName: "dataset_run_items",
clickhouseSelect: 'dri."dataset_run_id"',
},
{
uiTableName: "Created At",
uiTableId: "createdAt",
clickhouseTableName: "dataset_run_items",
clickhouseSelect: 'dri."created_at"',
},
{
uiTableName: "Event Timestamp",
uiTableId: "eventTs",
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];
+1 -1
View File
@@ -7,7 +7,7 @@ export type FilterCondition = z.infer<typeof singleFilter>;
export type FilterState = FilterCondition[];
// to be used in the client during editing
type MakeOptional<T> = {
export type MakeOptional<T> = {
[K in keyof T]?: T[K];
};
// if key is value, add string as value
+3 -3
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),
),
};
@@ -80,7 +80,7 @@ export const paginationMetaResponseZod = z.object({
totalPages: z.number().int().nonnegative(),
});
const urlRegex = /https?:\/\/[^\s/$.?#].[^\s]*/i;
export const urlRegex = /https?:\/\/[^\s/$.?#].[^\s]*/i;
export const noUrlCheck = (value: string) => !urlRegex.test(value);
export const NonEmptyString = z.string().min(1);
+377 -279
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -5,7 +5,7 @@ FROM --platform=${TARGETPLATFORM:-linux/amd64} node:20-alpine AS alpine
RUN apk update && apk upgrade --no-cache libcrypto3 libssl3 libc6-compat busybox ssl_client
FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine AS base
RUN npm install turbo@^2.5.4 --global
RUN npm install turbo@^2.5.5 --global
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
+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;` : ""}
+5 -3
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.85.2",
"version": "3.95.2",
"private": true,
"license": "MIT",
"engines": {
@@ -9,6 +9,7 @@
"scripts": {
"build": "INLINE_RUNTIME_CHUNK=false dotenv -e ../.env -- next build",
"dev": "dotenv -e ../.env -- next dev",
"dev:https": "dotenv -e ../.env -- next dev --experimental-https --experimental-https-key ./localhost+1-key.pem --experimental-https-cert ./localhost+1.pem",
"lint": "dotenv -e ../.env -- next lint --max-warnings 0",
"lint:fix": "dotenv -e ../.env -- next lint --fix",
"clean": "rm -rf node_modules",
@@ -83,6 +84,8 @@
"@radix-ui/react-tooltip": "^1.2.7",
"@remixicon/react": "^4.2.0",
"@sentry/nextjs": "^8.52.0",
"@slack/oauth": "^2.6.0",
"@slack/web-api": "^7.0.0",
"@t3-oss/env-nextjs": "^0.11.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/react-query": "^4.36.1",
@@ -125,6 +128,7 @@
"posthog-js": "^1.176.0",
"posthog-node": "^4.3.1",
"prexit": "^2.2.0",
"prism-react-renderer": "^2.4.1",
"prisma": "^6.10.1",
"protobufjs": "^7.4.0",
"rate-limiter-flexible": "^5.0.3",
@@ -137,7 +141,6 @@
"react-markdown": "^9.0.1",
"react-resizable-panels": "^2.1.1",
"react-responsive": "^10.0.0",
"react-syntax-highlighter": "^15.6.1",
"react18-json-view": "^0.2.8-canary.6",
"recharts": "^2.15.2",
"remark-gfm": "^4.0.0",
@@ -178,7 +181,6 @@
"@types/react": "~18.2.79",
"@types/react-dom": "~18.2.25",
"@types/react-grid-layout": "^1.3.5",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^7.12.0",
+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,
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

@@ -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);
});
});
});
@@ -12,6 +12,7 @@ import {
JobConfigState,
type SafeWebhookActionConfig,
type WebhookActionConfigWithSecrets,
isWebhookAction,
} from "@langfuse/shared";
import { encrypt, decrypt } from "@langfuse/shared/encryption";
import { generateWebhookSecret } from "@langfuse/shared/encryption";
@@ -204,11 +205,10 @@ describe("automations trpc", () => {
"x-api-key": encrypt("secret-api-key-123"),
authorization: encrypt("Bearer secret-token-456"),
},
secretHeaderKeys: ["x-api-key", "authorization"],
displayHeaders: {
"content-type": "application/json",
"x-api-key": "secr***123",
authorization: "Bear***456",
"content-type": { secret: false, value: "application/json" },
"x-api-key": { secret: true, value: "secr***123" },
authorization: { secret: true, value: "Bear***456" },
},
apiVersion: { prompt: "v1" },
secretKey: encrypt(secretKey),
@@ -232,13 +232,14 @@ describe("automations trpc", () => {
});
expect(response).toHaveLength(1);
const automationConfig = response[0].action.config;
const automationConfig = response[0].action
.config as SafeWebhookActionConfig;
// Should have display values, not encrypted values
expect(automationConfig.displayHeaders).toEqual({
"content-type": "application/json",
"x-api-key": "secr***123",
authorization: "Bear***456",
"content-type": { secret: false, value: "application/json" },
"x-api-key": { secret: true, value: "secr***123" },
authorization: { secret: true, value: "Bear***456" },
});
// Should NOT have the raw headers with encrypted values
@@ -321,7 +322,9 @@ describe("automations trpc", () => {
type: "WEBHOOK",
url: "https://example.com/webhook",
headers: { "Content-Type": "application/json" },
displayHeaders: { "Content-Type": "application/json" },
displayHeaders: {
"Content-Type": { secret: false, value: "application/json" },
},
apiVersion: { prompt: "v1" },
secretKey: encrypt(secretKey),
displaySecretKey,
@@ -353,20 +356,22 @@ describe("automations trpc", () => {
}),
});
const actionConfig = response.action.config as SafeWebhookActionConfig;
// check that the action does not have a secret key in the config
expect(response.action.config).not.toHaveProperty("secretKey");
expect(response.action.config).toHaveProperty("displaySecretKey");
expect(response.action.config.url).toBe("https://example.com/webhook");
expect(response.action.config).not.toHaveProperty("headers");
expect(response.action.config.displayHeaders).toEqual({
"Content-Type": "application/json",
expect(actionConfig).not.toHaveProperty("secretKey");
expect(actionConfig).toHaveProperty("displaySecretKey");
expect(actionConfig.url).toBe("https://example.com/webhook");
expect(actionConfig).not.toHaveProperty("headers");
expect(actionConfig.displayHeaders).toEqual({
"Content-Type": { secret: false, value: "application/json" },
});
expect(response.action.config.apiVersion).toEqual({ prompt: "v1" });
expect(response.action.config.type).toBe("WEBHOOK");
expect(response.action.config.displaySecretKey).toBe(displaySecretKey);
expect(response.action.config).not.toHaveProperty("headers");
expect(response.action.config).not.toHaveProperty("decryptedHeaders");
expect(response.action.config).not.toHaveProperty("requestHeaders");
expect(actionConfig.apiVersion).toEqual({ prompt: "v1" });
expect(actionConfig.type).toBe("WEBHOOK");
expect(actionConfig.displaySecretKey).toBe(displaySecretKey);
expect(actionConfig).not.toHaveProperty("headers");
expect(actionConfig).not.toHaveProperty("decryptedHeaders");
expect(actionConfig).not.toHaveProperty("requestHeaders");
});
it("should not expose secret headers in single automation response", async () => {
@@ -398,11 +403,10 @@ describe("automations trpc", () => {
"x-custom": "public-value",
"x-secret": encrypt("secret-value-789"),
},
secretHeaderKeys: ["x-secret"],
displayHeaders: {
"content-type": "application/json",
"x-custom": "public-value",
"x-secret": "secr***789",
"content-type": { secret: false, value: "application/json" },
"x-custom": { secret: false, value: "public-value" },
"x-secret": { secret: true, value: "secr***789" },
},
apiVersion: { prompt: "v1" },
secretKey: encrypt(secretKey),
@@ -425,13 +429,13 @@ describe("automations trpc", () => {
automationId: automation.id,
});
const config = response.action.config;
const config = response.action.config as SafeWebhookActionConfig;
// Should have display values
expect(config.displayHeaders).toEqual({
"content-type": "application/json",
"x-custom": "public-value",
"x-secret": "secr***789",
"content-type": { secret: false, value: "application/json" },
"x-custom": { secret: false, value: "public-value" },
"x-secret": { secret: true, value: "secr***789" },
});
// Should NOT have raw encrypted headers
@@ -488,7 +492,7 @@ describe("automations trpc", () => {
automationId: automation.id,
});
const config = response.action.config;
const config = response.action.config as SafeWebhookActionConfig;
// Should have display values with all legacy header values returned
// Legacy headers are converted to the new format with secret: false
@@ -629,6 +633,7 @@ describe("automations trpc", () => {
type: "WEBHOOK",
url: "https://example.com/mixed-headers",
});
expect(isWebhookAction(createdAction as any)).toBe(true);
// Headers should be encrypted for secret ones, plain for others
const config = createdAction?.config as WebhookActionConfigWithSecrets;
@@ -795,6 +800,85 @@ describe("automations trpc", () => {
}),
).rejects.toThrow("Name is required");
});
it("should create a new Slack automation", async () => {
const { project, caller } = await prepare();
// Create Slack integration first
await prisma.slackIntegration.create({
data: {
projectId: project.id,
teamId: "T123456",
teamName: "Test Team",
botToken: encrypt("xoxb-test-token"),
botUserId: "U123456",
},
});
const response = await caller.automations.createAutomation({
projectId: project.id,
name: "New Slack Automation",
eventSource: "prompt",
eventAction: ["created"],
filter: [],
status: JobConfigState.ACTIVE,
actionType: "SLACK",
actionConfig: {
type: "SLACK",
channelId: "C123456",
channelName: "general",
messageTemplate: JSON.stringify([
{
type: "section",
text: { type: "mrkdwn", text: "Custom template" },
},
]),
},
});
expect(response.trigger).toMatchObject({
projectId: project.id,
eventSource: "prompt",
eventActions: ["created"],
status: JobConfigState.ACTIVE,
});
expect(response.action).toMatchObject({
projectId: project.id,
type: "SLACK",
config: expect.objectContaining({
type: "SLACK",
channelId: "C123456",
channelName: "general",
}),
});
// Ensure no bot token is exposed in response
expect(JSON.stringify(response)).not.toContain("xoxb-");
});
it("should fail to create Slack automation without integration", async () => {
const { project, caller } = await prepare();
await expect(
caller.automations.createAutomation({
projectId: project.id,
name: "Invalid Slack Automation",
eventSource: "prompt",
eventAction: ["created"],
filter: [],
status: JobConfigState.ACTIVE,
actionType: "SLACK",
actionConfig: {
type: "SLACK",
channelId: "C123456",
channelName: "general",
},
}),
).rejects.toThrow(
"Slack integration not found. Please connect your Slack workspace first.",
);
});
});
describe("automations.updateAutomation", () => {
@@ -922,9 +1006,9 @@ describe("automations trpc", () => {
"x-case-key": { secret: false, value: "some-value" },
},
displayHeaders: {
"content-type": "application/json",
"x-old-header": "old-value",
"x-case-key": "some-value",
"content-type": { secret: false, value: "application/json" },
"x-old-header": { secret: false, value: "old-value" },
"x-case-key": { secret: false, value: "some-value" },
},
apiVersion: { prompt: "v1" },
secretKey: encrypt(secretKey),
@@ -963,19 +1047,20 @@ describe("automations trpc", () => {
apiVersion: { prompt: "v1" },
},
});
const actionConfig = response.action.config as SafeWebhookActionConfig;
// Verify the API response contains safe display values
expect(response.action.config.displayHeaders).toMatchObject({
expect(actionConfig.displayHeaders).toMatchObject({
"content-type": { secret: false, value: "application/json" }, // header preserved
"x-public": { secret: false, value: "new-public-value" }, // new public header
"x-secret-key": { secret: true, value: "new-...-123" }, // new secret header
"x-Case-KEY": { secret: false, value: "new-value" }, // matched existing key, but new value
});
expect(response.action.config.displayHeaders).not.toHaveProperty(
expect(actionConfig.displayHeaders).not.toHaveProperty(
"x-old-header", // header deleted
);
expect(response.action.config.displayHeaders).not.toHaveProperty(
expect(actionConfig.displayHeaders).not.toHaveProperty(
"x-case-key", // new case replaced the old header name
);
@@ -1083,9 +1168,10 @@ describe("automations trpc", () => {
apiVersion: { prompt: "v1" },
},
});
const actionConfig = response.action.config as SafeWebhookActionConfig;
// Verify the API response contains safe display values reflecting the switch
expect(response.action.config.displayHeaders).toMatchObject({
expect(actionConfig.displayHeaders).toMatchObject({
"x-currently-public": { secret: true, value: "now-...alue" },
"x-currently-secret": { secret: false, value: "now-public-value" },
});
@@ -1097,7 +1183,7 @@ describe("automations trpc", () => {
where: { id: action.id },
});
const config = updatedAction?.config as any;
const config = updatedAction?.config as WebhookActionConfigWithSecrets;
// x-currently-public should now be encrypted (was plain, now secret)
expect(config.requestHeaders["x-currently-public"].value).not.toBe(
@@ -1283,14 +1369,13 @@ describe("automations trpc", () => {
apiVersion: { prompt: "v1" },
},
});
const actionConfig = response.action.config as SafeWebhookActionConfig;
// Verify the URL was updated
expect(response.action.config.url).toBe(
"https://example.com/new-webhook-url",
);
expect(actionConfig.url).toBe("https://example.com/new-webhook-url");
// Verify secret headers were preserved
expect(response.action.config.displayHeaders).toMatchObject({
expect(actionConfig.displayHeaders).toMatchObject({
"content-type": { secret: false, value: "application/json" },
"x-api-key": { secret: true, value: "secr...-123" },
authorization: { secret: true, value: "Bear...-456" },
@@ -1388,8 +1473,10 @@ describe("automations trpc", () => {
},
});
const actionConfig = response.action.config as SafeWebhookActionConfig;
// Verify the API response
expect(response.action.config.displayHeaders).toMatchObject({
expect(actionConfig.displayHeaders).toMatchObject({
"content-type": { secret: false, value: "application/json" },
"x-api-key": { secret: true, value: "new-...-key" },
});
@@ -1780,6 +1867,199 @@ describe("automations trpc", () => {
expect(response.count).toBe(3);
});
it("should return 0 consecutive failures when lastFailingExecutionId is set", async () => {
const { project, caller } = await prepare();
// Create automation
const trigger = await prisma.trigger.create({
data: {
id: v4(),
projectId: project.id,
eventSource: "prompt",
eventActions: ["created"],
filter: [],
status: JobConfigState.INACTIVE, // Disabled due to failures
},
});
const { secretKey, displaySecretKey } = generateWebhookSecret();
const action = await prisma.action.create({
data: {
id: v4(),
projectId: project.id,
type: "WEBHOOK",
config: {
type: "WEBHOOK",
url: "https://example.com/webhook",
requestHeaders: {
"Content-Type": { secret: false, value: "application/json" },
},
apiVersion: { prompt: "v1" },
secretKey: encrypt(secretKey),
displaySecretKey,
lastFailingExecutionId: "some-failing-execution-id", // This simulates a webhook that was disabled
},
},
});
const automation = await prisma.automation.create({
data: {
projectId: project.id,
triggerId: trigger.id,
actionId: action.id,
name: "Test Automation",
},
});
// Create failed executions that occurred BEFORE the lastFailingExecutionId
await prisma.automationExecution.create({
data: {
id: "some-failing-execution-id",
automationId: automation.id,
projectId: project.id,
triggerId: trigger.id,
actionId: action.id,
status: ActionExecutionStatus.ERROR,
sourceId: v4(),
input: { iteration: 0 },
error: "Old failure",
createdAt: new Date(Date.now() - 60000), // 1 minute ago
},
});
// Create more failed executions that occurred BEFORE the lastFailingExecutionId
for (let i = 1; i < 5; i++) {
await prisma.automationExecution.create({
data: {
id: v4(),
automationId: automation.id,
projectId: project.id,
triggerId: trigger.id,
actionId: action.id,
status: ActionExecutionStatus.ERROR,
sourceId: v4(),
input: { iteration: i },
error: `Old failure ${i}`,
createdAt: new Date(Date.now() - (60000 + i * 1000)), // Before the lastFailingExecutionId
},
});
}
const response = await caller.automations.getCountOfConsecutiveFailures({
projectId: project.id,
automationId: automation.id,
});
// Should return 0 because all failures occurred before the lastFailingExecutionId
expect(response.count).toBe(0);
});
it("should count failures after lastFailingExecutionId correctly", async () => {
const { project, caller } = await prepare();
// Create automation
const trigger = await prisma.trigger.create({
data: {
id: v4(),
projectId: project.id,
eventSource: "prompt",
eventActions: ["created"],
filter: [],
status: JobConfigState.ACTIVE,
},
});
const { secretKey, displaySecretKey } = generateWebhookSecret();
const lastFailingExecutionId = v4();
const action = await prisma.action.create({
data: {
id: v4(),
projectId: project.id,
type: "WEBHOOK",
config: {
type: "WEBHOOK",
url: "https://example.com/webhook",
requestHeaders: {
"Content-Type": { secret: false, value: "application/json" },
},
apiVersion: { prompt: "v1" },
secretKey: encrypt(secretKey),
displaySecretKey,
lastFailingExecutionId,
},
},
});
const automation = await prisma.automation.create({
data: {
projectId: project.id,
triggerId: trigger.id,
actionId: action.id,
name: "Test Automation",
},
});
// Create the lastFailingExecution
await prisma.automationExecution.create({
data: {
id: lastFailingExecutionId,
automationId: automation.id,
projectId: project.id,
triggerId: trigger.id,
actionId: action.id,
status: ActionExecutionStatus.ERROR,
sourceId: v4(),
input: { iteration: 0 },
error: "Last failing execution",
createdAt: new Date(Date.now() - 60000), // 1 minute ago
},
});
// Create old failures that should be ignored
for (let i = 0; i < 3; i++) {
await prisma.automationExecution.create({
data: {
id: v4(),
automationId: automation.id,
projectId: project.id,
triggerId: trigger.id,
actionId: action.id,
status: ActionExecutionStatus.ERROR,
sourceId: v4(),
input: { iteration: i },
error: `Old failure ${i}`,
createdAt: new Date(Date.now() - (120000 + i * 1000)), // Before the lastFailingExecutionId
},
});
}
// Create new failures AFTER the lastFailingExecutionId
for (let i = 0; i < 2; i++) {
await prisma.automationExecution.create({
data: {
id: v4(),
automationId: automation.id,
projectId: project.id,
triggerId: trigger.id,
actionId: action.id,
status: ActionExecutionStatus.ERROR,
sourceId: v4(),
input: { iteration: i },
error: `New failure ${i}`,
createdAt: new Date(Date.now() - (30000 - i * 1000)), // After the lastFailingExecutionId
},
});
}
const response = await caller.automations.getCountOfConsecutiveFailures({
projectId: project.id,
automationId: automation.id,
});
// Should return 2 because only the 2 new failures after lastFailingExecutionId should be counted
expect(response.count).toBe(2);
});
});
describe("automations.regenerateWebhookSecret", () => {
@@ -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");
}
});

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