Compare commits

...
113 Commits
Author SHA1 Message Date
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
255 changed files with 19073 additions and 4101 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"
}
+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:-}
@@ -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 (upsert)
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', 'anthropic')
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', 'anthropic'). Must be unique in project.
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.92.0",
"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);
@@ -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);
+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",
+25
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 = {
@@ -359,6 +361,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 +628,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 +724,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>;
@@ -813,6 +836,7 @@ export type DB = {
observations: LegacyPrismaObservation;
organization_memberships: OrganizationMembership;
organizations: Organization;
pending_deletions: PendingDeletion;
posthog_integrations: PosthogIntegration;
prices: Price;
project_memberships: ProjectMembership;
@@ -823,6 +847,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;
+54 -10
View File
@@ -163,6 +163,8 @@ model Project {
Automation Automation[]
DefaultLlmModel DefaultLlmModel[]
Price Price[]
SlackIntegration SlackIntegration?
PendingDeletion PendingDeletion[]
@@index([orgId])
@@map("projects")
@@ -539,6 +541,7 @@ enum AnnotationQueueStatus {
enum AnnotationQueueObjectType {
TRACE
OBSERVATION
SESSION
}
model CronJobs {
@@ -551,16 +554,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 +1300,7 @@ model Automation {
enum ActionType {
WEBHOOK
SLACK
// More action types can be added as needed
}
@@ -1335,3 +1341,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,
};
}
+44
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"])
@@ -140,6 +145,45 @@ const EnvSchema = z.object({
LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT: 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(),
});
export const env: z.infer<typeof EnvSchema> =
@@ -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";
@@ -39,7 +39,9 @@ export const measureAndReturn = async <T, Y>(args: {
"true"
) {
currentSpan.setAttribute(`langfuse.experiment.amts.run`, "disabled");
return existingExecution(input);
return env.LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT === "true"
? newExecution(input)
: existingExecution(input);
}
// If not whitelisted, apply sampling logic
@@ -68,6 +70,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,
@@ -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,
});
}
};
+6
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";
@@ -63,13 +65,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;
+10 -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",
@@ -368,8 +377,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 +386,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",
},
@@ -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,
},
});
};
@@ -355,31 +355,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,
};
@@ -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";
@@ -612,12 +613,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 +630,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 +751,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 +761,51 @@ 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,
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 (
@@ -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 (
+344 -156
View File
@@ -15,10 +15,13 @@ import {
FilterList,
StringFilter,
} from "../queries/clickhouse-sql/clickhouse-filter";
import { TraceRecordReadType } from "./definitions";
import { TraceRecordReadType, convertTraceToTraceMt } from "./definitions";
import { tracesTableUiColumnDefinitions } from "../../tableDefinitions/mapTracesTable";
import { UiColumnMappings } from "../../tableDefinitions";
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
import {
convertDateToClickhouseDateTime,
clickhouseClient,
} from "../clickhouse/client";
import { convertClickhouseToDomain } from "./traces_converters";
import { clickhouseSearchCondition } from "../queries/clickhouse-sql/search";
import {
@@ -163,6 +166,7 @@ export const checkTraceExists = async ({
type: "trace",
kind: "exists",
projectId,
operation_name: "checkTraceExists",
},
timestamp: timestamp ?? exactTimestamp,
},
@@ -186,7 +190,7 @@ export const checkTraceExists = async ({
const rows = await queryClickhouse<{ id: string; project_id: string }>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
});
return rows.length > 0;
@@ -209,7 +213,7 @@ export const checkTraceExists = async ({
const rows = await queryClickhouse<{ id: string; project_id: string }>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
});
return rows.length > 0;
@@ -236,6 +240,38 @@ export const upsertTrace = async (trace: Partial<TraceRecordReadType>) => {
projectId: trace.project_id ?? "",
},
});
// Also insert into traces_mt if experiment flag is enabled
if (env.LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES === "true") {
// Convert trace to insert format first (since we have read format)
const traceRecord = trace as TraceRecordReadType;
const traceInsert = {
...traceRecord,
timestamp: new Date(traceRecord.timestamp).getTime(),
created_at: new Date(traceRecord.created_at).getTime(),
updated_at: new Date(traceRecord.updated_at).getTime(),
event_ts: new Date(traceRecord.event_ts).getTime(),
is_deleted: 0,
};
// Convert to traces_mt format
const traceMt = convertTraceToTraceMt(traceInsert);
// Insert directly into traces_mt using clickhouse client
await clickhouseClient().insert({
table: "traces_mt",
format: "JSONEachRow",
values: [traceMt],
clickhouse_settings: {
log_comment: JSON.stringify({
feature: "tracing",
type: "trace_mt",
kind: "upsert",
experiment: "insert_into_aggregating_merge_trees",
}),
},
});
}
};
export const getTracesByIds = async (
@@ -260,6 +296,7 @@ export const getTracesByIds = async (
type: "trace",
kind: "byId",
projectId,
operation_name: "getTracesByIds",
},
clickhouseConfigs,
},
@@ -276,7 +313,7 @@ export const getTracesByIds = async (
return queryClickhouse<TraceRecordReadType>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
clickhouseConfigs: input.clickhouseConfigs,
});
},
@@ -311,7 +348,7 @@ export const getTracesByIds = async (
return queryClickhouse<TraceRecordReadType>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
clickhouseConfigs: input.clickhouseConfigs,
});
},
@@ -341,6 +378,7 @@ export const getTracesBySessionId = async (
type: "trace",
kind: "list",
projectId,
operation_name: "getTracesBySessionId",
},
timestamp,
},
@@ -357,7 +395,7 @@ export const getTracesBySessionId = async (
return queryClickhouse<TraceRecordReadType>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
});
},
newExecution: (input) => {
@@ -388,7 +426,7 @@ export const getTracesBySessionId = async (
return queryClickhouse<TraceRecordReadType>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
});
},
});
@@ -416,6 +454,7 @@ export const hasAnyTrace = async (projectId: string) => {
type: "trace",
kind: "hasAny",
projectId,
operation_name: "hasAnyTrace",
},
},
existingExecution: async (input) => {
@@ -431,7 +470,7 @@ export const hasAnyTrace = async (projectId: string) => {
params: {
projectId: input.projectId,
},
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
});
return rows.length > 0;
@@ -449,7 +488,7 @@ export const hasAnyTrace = async (projectId: string) => {
params: {
projectId: input.projectId,
},
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
});
return rows.length > 0;
@@ -464,33 +503,72 @@ export const getTraceCountsByProjectInCreationInterval = async ({
start: Date;
end: Date;
}) => {
const query = `
SELECT
project_id,
count(*) as count
FROM traces
WHERE created_at >= {start: DateTime64(3)}
AND created_at < {end: DateTime64(3)}
GROUP BY project_id
`;
const rows = await queryClickhouse<{ project_id: string; count: string }>({
query,
params: {
start: convertDateToClickhouseDateTime(start),
end: convertDateToClickhouseDateTime(end),
return measureAndReturn({
operationName: "getTraceCountsByProjectInCreationInterval",
projectId: "__CROSS_PROJECT__",
input: {
params: {
start: convertDateToClickhouseDateTime(start),
end: convertDateToClickhouseDateTime(end),
},
tags: {
feature: "tracing",
type: "trace",
kind: "analytic",
operation_name: "getTraceCountsByProjectInCreationInterval",
},
timestamp: start,
},
tags: {
feature: "tracing",
type: "trace",
kind: "analytic",
existingExecution: async (input) => {
const query = `
SELECT
project_id,
count(*) as count
FROM traces
WHERE created_at >= {start: DateTime64(3)}
AND created_at < {end: DateTime64(3)}
GROUP BY project_id
`;
const rows = await queryClickhouse<{ project_id: string; count: string }>(
{
query,
params: input.params,
tags: { ...input.tags, experiment_amt: "original" },
},
);
return rows.map((row) => ({
projectId: row.project_id,
count: Number(row.count),
}));
},
newExecution: async (input) => {
const traceAmt = getTimeframesTracesAMT(input.timestamp);
const query = `
SELECT
project_id,
count(*) as count
FROM ${traceAmt}
WHERE created_at >= {start: DateTime64(3)}
AND created_at < {end: DateTime64(3)}
GROUP BY project_id
`;
const rows = await queryClickhouse<{ project_id: string; count: string }>(
{
query,
params: input.params,
tags: { ...input.tags, experiment_amt: "new" },
},
);
return rows.map((row) => ({
projectId: row.project_id,
count: Number(row.count),
}));
},
});
return rows.map((row) => ({
projectId: row.project_id,
count: Number(row.count),
}));
};
export const getTraceCountOfProjectsSinceCreationDate = async ({
@@ -500,28 +578,58 @@ export const getTraceCountOfProjectsSinceCreationDate = async ({
projectIds: string[];
start: Date;
}) => {
const query = `
SELECT
count(*) as count
FROM traces
WHERE project_id IN ({projectIds: Array(String)})
AND created_at >= {start: DateTime64(3)}
`;
const rows = await queryClickhouse<{ count: string }>({
query,
params: {
projectIds,
start: convertDateToClickhouseDateTime(start),
return measureAndReturn({
operationName: "getTraceCountOfProjectsSinceCreationDate",
projectId: "__CROSS_PROJECT__",
input: {
params: {
projectIds,
start: convertDateToClickhouseDateTime(start),
},
tags: {
feature: "tracing",
type: "trace",
kind: "analytic",
operation_name: "getTraceCountOfProjectsSinceCreationDate",
},
timestamp: start,
},
tags: {
feature: "tracing",
type: "trace",
kind: "analytic",
existingExecution: async (input) => {
const query = `
SELECT
count(*) as count
FROM traces
WHERE project_id IN ({projectIds: Array(String)})
AND created_at >= {start: DateTime64(3)}
`;
const rows = await queryClickhouse<{ count: string }>({
query,
params: input.params,
tags: { ...input.tags, experiment_amt: "original" },
});
return Number(rows[0]?.count ?? 0);
},
newExecution: async (input) => {
const traceAmt = getTimeframesTracesAMT(input.timestamp);
const query = `
SELECT
count(*) as count
FROM ${traceAmt}
WHERE project_id IN ({projectIds: Array(String)})
AND created_at >= {start: DateTime64(3)}
`;
const rows = await queryClickhouse<{ count: string }>({
query,
params: input.params,
tags: { ...input.tags, experiment_amt: "new" },
});
return Number(rows[0]?.count ?? 0);
},
});
return Number(rows[0]?.count ?? 0);
};
/**
@@ -561,6 +669,7 @@ export const getTraceById = async ({
type: "trace",
kind: "byId",
projectId,
operation_name: "getTraceById",
},
},
existingExecution: (input) => {
@@ -578,7 +687,7 @@ export const getTraceById = async ({
return queryClickhouse<TraceRecordReadType>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
});
},
newExecution: (input) => {
@@ -612,7 +721,7 @@ export const getTraceById = async ({
return queryClickhouse<TraceRecordReadType>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
});
},
});
@@ -658,6 +767,7 @@ export const getTracesGroupedByName = async (
type: "trace",
kind: "analytic",
projectId,
operation_name: "getTracesGroupedByName",
},
},
existingExecution: async (input) => {
@@ -682,7 +792,7 @@ export const getTracesGroupedByName = async (
}>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
});
},
newExecution: async (input) => {
@@ -711,7 +821,7 @@ export const getTracesGroupedByName = async (
}>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
});
},
});
@@ -755,6 +865,7 @@ export const getTracesGroupedByUsers = async (
type: "trace",
kind: "analytic",
projectId,
operation_name: "getTracesGroupedByUsers",
},
},
existingExecution: async (input) => {
@@ -781,7 +892,7 @@ export const getTracesGroupedByUsers = async (
}>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
});
},
newExecution: async (input) => {
@@ -813,7 +924,7 @@ export const getTracesGroupedByUsers = async (
}>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
});
},
});
@@ -848,6 +959,7 @@ export const getTracesGroupedByTags = async (props: GroupedTracesQueryProp) => {
type: "trace",
kind: "analytic",
projectId,
operation_name: "getTracesGroupedByTags",
},
},
existingExecution: async (input) => {
@@ -864,7 +976,7 @@ export const getTracesGroupedByTags = async (props: GroupedTracesQueryProp) => {
}>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
});
},
newExecution: async (input) => {
@@ -888,7 +1000,7 @@ export const getTracesGroupedByTags = async (props: GroupedTracesQueryProp) => {
}>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
});
},
});
@@ -911,6 +1023,7 @@ export const getTracesIdentifierForSession = async (
type: "trace",
kind: "list",
projectId,
operation_name: "getTracesIdentifierForSession",
},
},
existingExecution: (input) => {
@@ -938,7 +1051,7 @@ export const getTracesIdentifierForSession = async (
}>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
});
},
newExecution: (input) => {
@@ -950,10 +1063,11 @@ export const getTracesIdentifierForSession = async (
start_time as timestamp,
project_id,
environment
FROM traces_all_amt FINAL
FROM traces_all_amt
WHERE (project_id = {projectId: String})
AND (session_id = {sessionId: String})
ORDER BY start_time ASC;
ORDER BY start_time ASC
LIMIT 1 BY id, project_id;
`;
return queryClickhouse<{
@@ -965,7 +1079,7 @@ export const getTracesIdentifierForSession = async (
}>({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
});
},
});
@@ -1063,6 +1177,7 @@ export const hasAnyUser = async (projectId: string) => {
type: "user",
kind: "hasAny",
projectId,
operation_name: "hasAnyUser",
},
},
existingExecution: async (input) => {
@@ -1080,7 +1195,7 @@ export const hasAnyUser = async (projectId: string) => {
params: {
projectId: input.projectId,
},
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
});
return rows.length > 0;
@@ -1100,7 +1215,7 @@ export const hasAnyUser = async (projectId: string) => {
params: {
projectId: input.projectId,
},
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
});
return rows.length > 0;
@@ -1137,6 +1252,7 @@ export const getTotalUserCount = async (
type: "trace",
kind: "analytic",
projectId,
operation_name: "getTotalUserCount",
},
},
existingExecution: async (input) => {
@@ -1152,7 +1268,7 @@ export const getTotalUserCount = async (
return queryClickhouse({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "original" },
});
},
newExecution: async (input) => {
@@ -1175,7 +1291,7 @@ export const getTotalUserCount = async (
return queryClickhouse({
query,
params: input.params,
tags: input.tags,
tags: { ...input.tags, experiment_amt: "new" },
});
},
});
@@ -1232,10 +1348,8 @@ export const getUserMetrics = async (
o.project_id = {projectId: String }
${timestampFilter ? `AND o.start_time >= {traceTimestamp: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}` : ""}
AND o.trace_id in (
SELECT
distinct id
from
traces
SELECT distinct id
from __TRACE_TABLE__
where
user_id IN ({userIds: Array(String) })
AND project_id = {projectId: String }
@@ -1249,25 +1363,17 @@ export const getUserMetrics = async (
t.user_id,
t.project_id,
t.timestamp,
t.environment,
ROW_NUMBER() OVER (
PARTITION BY id
ORDER BY
event_ts DESC
) AS rn
t.environment
FROM
traces t
__TRACE_TABLE__ t FINAL
WHERE
t.user_id IN ({userIds: Array(String) })
AND t.project_id = {projectId: String }
${filter.length > 0 ? `AND ${chFilterRes.query}` : ""}
) as t on t.id = o.trace_id
and t.project_id = o.project_id
WHERE
o.rn = 1
and t.rn = 1
group by
t.user_id
WHERE o.rn = 1
group by t.user_id
)
SELECT
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sum_usage_details))) as input_usage,
@@ -1280,56 +1386,102 @@ export const getUserMetrics = async (
sum_total_cost,
max_timestamp,
min_timestamp
FROM
stats
FROM stats`;
`;
const rows = await queryClickhouse<{
user_id: string;
environment: string;
max_timestamp: string;
min_timestamp: string;
input_usage: string;
output_usage: string;
total_usage: string;
obs_count: string;
trace_count: string;
sum_total_cost: string;
}>({
query,
params: {
projectId,
userIds,
...chFilterRes.params,
...(timestampFilter
? {
traceTimestamp: convertDateToClickhouseDateTime(
(timestampFilter as DateTimeFilter).value,
),
}
: {}),
return measureAndReturn({
operationName: "getUserMetrics",
projectId,
input: {
params: {
projectId,
userIds,
...chFilterRes.params,
...(timestampFilter
? {
traceTimestamp: convertDateToClickhouseDateTime(
(timestampFilter as DateTimeFilter).value,
),
}
: {}),
},
tags: {
feature: "tracing",
type: "trace",
kind: "analytic",
projectId,
operation_name: "getUserMetrics",
},
},
tags: {
feature: "tracing",
type: "trace",
kind: "analytic",
projectId,
existingExecution: async (input) => {
const rows = await queryClickhouse<{
user_id: string;
environment: string;
max_timestamp: string;
min_timestamp: string;
input_usage: string;
output_usage: string;
total_usage: string;
obs_count: string;
trace_count: string;
sum_total_cost: string;
}>({
query: query.replaceAll("__TRACE_TABLE__", "traces"),
params: input.params,
tags: { ...input.tags, experiment_amt: "original" },
});
return rows.map((row) => ({
userId: row.user_id,
environment: row.environment,
maxTimestamp: parseClickhouseUTCDateTimeFormat(row.max_timestamp),
minTimestamp: parseClickhouseUTCDateTimeFormat(row.min_timestamp),
inputUsage: Number(row.input_usage),
outputUsage: Number(row.output_usage),
totalUsage: Number(row.total_usage),
observationCount: Number(row.obs_count),
traceCount: Number(row.trace_count),
totalCost: Number(row.sum_total_cost),
}));
},
newExecution: async (input) => {
// Extract the timestamp from filter for AMT table selection
const fromTimestamp = filter?.find(
(f) =>
f.column === "timestamp" &&
(f.operator === ">=" || f.operator === ">"),
)?.value as Date | undefined;
const traceAmt = getTimeframesTracesAMT(fromTimestamp);
const rows = await queryClickhouse<{
user_id: string;
environment: string;
max_timestamp: string;
min_timestamp: string;
input_usage: string;
output_usage: string;
total_usage: string;
obs_count: string;
trace_count: string;
sum_total_cost: string;
}>({
query: query.replaceAll("__TRACE_TABLE__", traceAmt),
params: input.params,
tags: { ...input.tags, experiment_amt: "new" },
});
return rows.map((row) => ({
userId: row.user_id,
environment: row.environment,
maxTimestamp: parseClickhouseUTCDateTimeFormat(row.max_timestamp),
minTimestamp: parseClickhouseUTCDateTimeFormat(row.min_timestamp),
inputUsage: Number(row.input_usage),
outputUsage: Number(row.output_usage),
totalUsage: Number(row.total_usage),
observationCount: Number(row.obs_count),
traceCount: Number(row.trace_count),
totalCost: Number(row.sum_total_cost),
}));
},
});
return rows.map((row) => ({
userId: row.user_id,
environment: row.environment,
maxTimestamp: parseClickhouseUTCDateTimeFormat(row.max_timestamp),
minTimestamp: parseClickhouseUTCDateTimeFormat(row.min_timestamp),
inputUsage: Number(row.input_usage),
outputUsage: Number(row.output_usage),
totalUsage: Number(row.total_usage),
observationCount: Number(row.obs_count),
traceCount: Number(row.trace_count),
totalCost: Number(row.sum_total_cost),
}));
};
export const getTracesForBlobStorageExport = function (
@@ -1360,7 +1512,7 @@ export const getTracesForBlobStorageExport = function (
AND timestamp <= {maxTimestamp: DateTime64(3)}
`;
const records = queryClickhouseStream<Record<string, unknown>>({
return queryClickhouseStream<Record<string, unknown>>({
query,
params: {
projectId,
@@ -1374,8 +1526,6 @@ export const getTracesForBlobStorageExport = function (
projectId,
},
});
return records;
};
export const getTracesForPostHog = async function* (
@@ -1440,6 +1590,7 @@ export const getTracesForPostHog = async function* (
});
const baseUrl = env.NEXTAUTH_URL?.replace("/api/auth", "");
for await (const record of records) {
yield {
timestamp: record.timestamp,
@@ -1467,32 +1618,69 @@ export const getTracesForPostHog = async function* (
}
};
/**
* This query is used only for legacy support of redirects without a projectId.
* We don't have an index on the traceId so it will be a full table scan.
* We expect at most 10s of calls per day, so this is acceptable.
*/
export const getTracesByIdsForAnyProject = async (traceIds: string[]) => {
const query = `
SELECT id, project_id
FROM traces
WHERE id IN ({traceIds: Array(String)})
ORDER BY event_ts DESC
LIMIT 1 by id, project_id;`;
const records = await queryClickhouse<{
id: string;
project_id: string;
}>({
query,
params: {
traceIds,
return measureAndReturn({
operationName: "getTracesByIdsForAnyProject",
projectId: "__CROSS_PROJECT__",
input: {
params: {
traceIds,
},
tags: {
feature: "tracing",
type: "trace",
kind: "list",
operation_name: "getTracesByIdsForAnyProject",
},
},
tags: {
feature: "tracing",
type: "trace",
kind: "list",
existingExecution: async (input) => {
const query = `
SELECT id, project_id
FROM traces
WHERE id IN ({traceIds: Array(String)})
ORDER BY event_ts DESC
LIMIT 1 by id, project_id;`;
const records = await queryClickhouse<{
id: string;
project_id: string;
}>({
query,
params: input.params,
tags: { ...input.tags, experiment_amt: "original" },
});
return records.map((record) => ({
id: record.id,
projectId: record.project_id,
}));
},
newExecution: async (input) => {
// For this query, we need to query all AMT tables as we don't have a specific timestamp
// We'll use the all AMT table as it contains all data
const query = `
SELECT DISTINCT id, project_id
FROM traces_all_amt
WHERE id IN ({traceIds: Array(String)})`;
const records = await queryClickhouse<{
id: string;
project_id: string;
}>({
query,
params: input.params,
tags: { ...input.tags, experiment_amt: "new" },
});
return records.map((record) => ({
id: record.id,
projectId: record.project_id,
}));
},
});
return records.map((record) => ({
id: record.id,
projectId: record.project_id,
}));
};
export async function getAgentGraphData(params: {
@@ -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;
}
}
}
@@ -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,183 @@ 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,
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;
};
@@ -479,6 +479,8 @@ async function getTracesTableGeneric(props: FetchTracesTableProps) {
feature: "tracing",
type: "traces-table",
projectId,
experiment_amt: "original",
operation_name: "getTracesTableGeneric",
},
clickhouseConfigs,
});
@@ -588,6 +590,8 @@ async function getTracesTableGeneric(props: FetchTracesTableProps) {
feature: "tracing",
type: "traces-table",
projectId,
experiment_amt: "new",
operation_name: "getTracesTableGeneric",
},
clickhouseConfigs,
});
@@ -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;
}
}
@@ -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;
});
}
+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().positive().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.92.0",
"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",
+216
View File
@@ -1421,6 +1421,107 @@ 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 (upsert)
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
@@ -6225,6 +6326,121 @@ 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', 'anthropic')
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', 'anthropic')
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
extraHeaders:
type: object
additionalProperties:
type: string
nullable: true
description: Extra headers to send with requests
config:
nullable: true
description: Additional configuration specific to the adapter
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
@@ -1129,6 +1129,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 (upsert)",
"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 \"config\": \"UNKNOWN\"\n}",
"options": {
"raw": {
"language": "json"
}
}
}
},
"response": []
}
]
},
{
"_type": "container",
"description": null,
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

@@ -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", () => {
File diff suppressed because it is too large Load Diff
+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
@@ -137,6 +137,8 @@ describe("traces trpc", () => {
// When
const sessions = await caller.sessions.all({
projectId,
limit: 50,
page: 0,
orderBy: {
column: "createdAt",
order: "DESC",
@@ -154,6 +156,55 @@ describe("traces trpc", () => {
// Then
expect(sessions.sessions).toBeDefined();
});
it("should return empty when filtering by session_id and mismatched environment", async () => {
// Setup - create a session with non-default environment
const sessionId = randomUUID();
const testEnvironment = "staging";
await prisma.traceSession.create({
data: {
id: sessionId,
projectId,
environment: testEnvironment,
},
});
const trace = createTrace({
project_id: projectId,
session_id: sessionId,
environment: testEnvironment,
});
await createTracesCh([trace]);
// When - filter by correct session_id but wrong environment
const sessions = await caller.sessions.all({
projectId,
limit: 50,
page: 0,
orderBy: {
column: "createdAt",
order: "DESC",
},
filter: [
{
column: "ID",
operator: "=",
value: sessionId,
type: "string",
},
{
column: "environment",
operator: "=",
value: "production",
type: "string",
},
],
});
// Then - should return empty result
expect(sessions.sessions).toEqual([]);
});
});
describe("sessions.countAll", () => {
@@ -177,6 +228,8 @@ describe("traces trpc", () => {
// When
const sessions = await caller.sessions.countAll({
projectId,
limit: 50,
page: 0,
filter: null,
orderBy: null,
});
@@ -4,10 +4,13 @@ import {
createObservation,
createObservationsCh,
createOrgProjectAndApiKey,
createScoresCh,
createSessionScore,
createTracesCh,
getSessionsWithMetrics,
} from "@langfuse/shared/src/server";
import { createTrace, getSessionsTable } from "@langfuse/shared/src/server";
import { type FilterState } from "@langfuse/shared";
describe("trpc.sessions", () => {
describe("GET sessions.all", () => {
@@ -385,4 +388,52 @@ describe("trpc.sessions", () => {
expect(sessions[0]?.trace_count).toBe(2);
expect(parseInt(sessions[0]?.duration as any)).toBe(1);
});
it("should GET correct session data with filters", async () => {
const project_id = v4();
const trace_id_with_score = v4();
const session_id_with_score = v4();
const trace_id_without_score = v4();
const session_id_without_score = v4();
const filterState: FilterState = [
{
type: "numberObject",
column: "Scores (numeric)",
key: "test",
operator: ">",
value: 0,
},
];
const trace_with_score = createTrace({
id: trace_id_with_score,
project_id,
session_id: session_id_with_score,
});
const trace_without_score = createTrace({
id: trace_id_without_score,
project_id,
session_id: session_id_without_score,
});
await createTracesCh([trace_with_score, trace_without_score]);
const score = createSessionScore({
project_id,
session_id: session_id_with_score,
name: "test",
value: 1,
data_type: "NUMERIC",
});
await createScoresCh([score]);
const tableRows = await getSessionsTable({
projectId: project_id,
filter: filterState,
limit: 10,
page: 0,
});
expect(tableRows).toHaveLength(1);
expect(tableRows[0].session_id).toEqual(session_id_with_score);
});
});
@@ -0,0 +1,554 @@
import { prisma } from "@langfuse/shared/src/db";
import type { Session } from "next-auth";
import { encrypt } from "@langfuse/shared/encryption";
import { createInnerTRPCContext } from "@/src/server/api/trpc";
import { appRouter } from "@/src/server/api/root";
import { createOrgProjectAndApiKey } from "@langfuse/shared/src/server";
import { TRPCError } from "@trpc/server";
// Mock SlackService
jest.mock("@langfuse/shared/src/server", () => {
const actual = jest.requireActual("@langfuse/shared/src/server");
return {
...actual,
SlackService: {
getInstance: jest.fn(),
},
};
});
const __orgIds: string[] = [];
let mockSlackService: any;
const prepare = async () => {
const { project, org } = await createOrgProjectAndApiKey();
const session: Session = {
expires: "1",
user: {
id: "user-1",
canCreateOrganizations: true,
name: "Demo User",
organizations: [
{
id: org.id,
name: org.name,
role: "OWNER",
plan: "cloud:hobby",
cloudConfig: undefined,
metadata: {},
projects: [
{
id: project.id,
role: "ADMIN",
retentionDays: 30,
deletedAt: null,
name: project.name,
metadata: {},
},
],
},
],
featureFlags: {
excludeClickhouseRead: false,
templateFlag: true,
},
admin: true,
},
environment: {
enableExperimentalFeatures: false,
selfHostedInstancePlan: "cloud:hobby",
},
};
const ctx = createInnerTRPCContext({ session, headers: {} });
const caller = appRouter.createCaller({ ...ctx, prisma });
__orgIds.push(org.id);
return { project, org, session, ctx, caller };
};
describe("Slack Integration", () => {
beforeAll(async () => {
// Import mocked SlackService
const { SlackService } = await import("@langfuse/shared/src/server");
// Create mock service instance
mockSlackService = {
getWebClientForProject: jest.fn(),
sendMessage: jest.fn(),
getChannels: jest.fn(),
validateClient: jest.fn(),
deleteIntegration: jest.fn(),
};
// Setup the getInstance mock to return our mock service
(SlackService.getInstance as jest.Mock).mockReturnValue(mockSlackService);
});
afterAll(async () => {
await prisma.organization.deleteMany({
where: {
id: { in: __orgIds },
},
});
});
describe("Slack tRPC Router", () => {
describe("getIntegrationStatus", () => {
it("should return connected status for valid integration", async () => {
mockSlackService.validateClient.mockResolvedValue(true);
const { caller, project } = await prepare();
// Create Slack integration
await prisma.slackIntegration.create({
data: {
projectId: project.id,
teamId: "T123456",
teamName: "Test Team",
botToken: encrypt("xoxb-test-token-secret"),
botUserId: "U123456",
},
});
const result = await caller.slack.getIntegrationStatus({
projectId: project.id,
});
expect(result).toMatchObject({
isConnected: true,
teamId: "T123456",
teamName: "Test Team",
botUserId: "U123456",
installUrl: null,
});
// 🔒 CRITICAL: Ensure no bot token is exposed
expect(JSON.stringify(result)).not.toContain("xoxb-test-token-secret");
expect(result).not.toHaveProperty("botToken");
});
it("should return disconnected status when no integration exists", async () => {
const { caller, project } = await prepare();
const result = await caller.slack.getIntegrationStatus({
projectId: project.id,
});
expect(result).toMatchObject({
isConnected: false,
teamId: null,
teamName: null,
installUrl: expect.stringContaining(
`/api/public/slack/install?projectId=${project.id}`,
),
});
// 🔒 Ensure no sensitive data is present
expect(JSON.stringify(result)).not.toContain("xoxb-");
});
it("should return disconnected status for invalid integration", async () => {
mockSlackService.validateClient.mockResolvedValue(false);
const { caller, project } = await prepare();
// Create Slack integration with invalid token
await prisma.slackIntegration.create({
data: {
projectId: project.id,
teamId: "T123456",
teamName: "Test Team",
botToken: encrypt("xoxb-invalid-token"),
botUserId: "U123456",
},
});
const result = await caller.slack.getIntegrationStatus({
projectId: project.id,
});
expect(result).toMatchObject({
isConnected: false,
teamId: "T123456",
teamName: "Test Team",
error:
"Integration is invalid. Please reconnect your Slack workspace.",
});
// 🔒 CRITICAL: Ensure no bot token is exposed even for invalid integrations
expect(JSON.stringify(result)).not.toContain("xoxb-invalid-token");
});
});
describe("getChannels", () => {
it("should fetch channels for valid integration", async () => {
const mockChannels = [
{ id: "C123456", name: "general", isPrivate: false, isMember: true },
{ id: "C789012", name: "random", isPrivate: false, isMember: true },
{
id: "C345678",
name: "private-channel",
isPrivate: true,
isMember: true,
},
];
mockSlackService.getChannels.mockResolvedValue(mockChannels);
const { caller, project } = await prepare();
// Create Slack integration
await prisma.slackIntegration.create({
data: {
projectId: project.id,
teamId: "T123456",
teamName: "Test Team",
botToken: encrypt("xoxb-valid-token"),
botUserId: "U123456",
},
});
const result = await caller.slack.getChannels({
projectId: project.id,
});
expect(result).toMatchObject({
channels: mockChannels,
teamId: "T123456",
teamName: "Test Team",
});
// 🔒 CRITICAL: Ensure no bot token is exposed in channel data
expect(JSON.stringify(result)).not.toContain("xoxb-valid-token");
expect(result).not.toHaveProperty("botToken");
});
it("should throw NOT_FOUND for missing integration", async () => {
const { caller, project } = await prepare();
await expect(
caller.slack.getChannels({ projectId: project.id }),
).rejects.toThrow("Slack integration not found");
});
it("should handle Slack API failures gracefully", async () => {
mockSlackService.getChannels.mockRejectedValue(
new Error("Slack API error"),
);
const { caller, project } = await prepare();
// Create Slack integration
await prisma.slackIntegration.create({
data: {
projectId: project.id,
teamId: "T123456",
teamName: "Test Team",
botToken: encrypt("xoxb-test-token"),
botUserId: "U123456",
},
});
await expect(
caller.slack.getChannels({ projectId: project.id }),
).rejects.toThrow(
"Failed to fetch channels. Please check your Slack connection and try again.",
);
});
});
describe("sendTestMessage", () => {
it("should send test message successfully", async () => {
const mockClient = { auth: { test: jest.fn() } };
mockSlackService.getWebClientForProject.mockResolvedValue(mockClient);
mockSlackService.sendMessage.mockResolvedValue({
messageTs: "1234567890.123456",
channel: "C123456",
});
const { caller, project } = await prepare();
// Create Slack integration
await prisma.slackIntegration.create({
data: {
projectId: project.id,
teamId: "T123456",
teamName: "Test Team",
botToken: encrypt("xoxb-test-token"),
botUserId: "U123456",
},
});
const result = await caller.slack.sendTestMessage({
projectId: project.id,
channelId: "C123456",
channelName: "general",
});
expect(result).toMatchObject({
success: true,
messageTs: "1234567890.123456",
channel: "C123456",
});
// Verify SlackService was called with proper parameters
expect(mockSlackService.sendMessage).toHaveBeenCalledWith({
client: expect.any(Object),
channelId: "C123456",
blocks: expect.any(Array),
text: "Test message from Langfuse",
});
// 🔒 CRITICAL: Ensure no bot token is exposed in test results
expect(JSON.stringify(result)).not.toContain("xoxb-test-token");
});
it("should create audit log entry", async () => {
const mockClient = { auth: { test: jest.fn() } };
mockSlackService.getWebClientForProject.mockResolvedValue(mockClient);
mockSlackService.sendMessage.mockResolvedValue({
messageTs: "1234567890.123456",
channel: "C123456",
});
const { caller, project } = await prepare();
// Create Slack integration
const integration = await prisma.slackIntegration.create({
data: {
projectId: project.id,
teamId: "T123456",
teamName: "Test Team",
botToken: encrypt("xoxb-test-token"),
botUserId: "U123456",
},
});
await caller.slack.sendTestMessage({
projectId: project.id,
channelId: "C123456",
channelName: "general",
});
// Verify audit log was created
const auditLog = await prisma.auditLog.findFirst({
where: {
projectId: project.id,
resourceType: "slackIntegration",
resourceId: integration.id,
action: "create",
},
});
expect(auditLog).toBeDefined();
const afterData = auditLog?.after ? JSON.parse(auditLog.after) : null;
expect(afterData).toMatchObject({
action: "test_message_sent",
channelId: "C123456",
channelName: "general",
messageTs: "1234567890.123456",
});
// 🔒 Ensure audit log doesn't contain tokens
expect(JSON.stringify(afterData)).not.toContain("xoxb-");
});
});
describe("disconnect", () => {
it("should remove integration and audit log it", async () => {
mockSlackService.deleteIntegration.mockResolvedValue(undefined);
const { caller, project } = await prepare();
// Create Slack integration
const integration = await prisma.slackIntegration.create({
data: {
projectId: project.id,
teamId: "T123456",
teamName: "Test Team",
botToken: encrypt("xoxb-test-token"),
botUserId: "U123456",
},
});
const result = await caller.slack.disconnect({
projectId: project.id,
});
expect(result).toMatchObject({
success: true,
});
// Verify SlackService was called
expect(mockSlackService.deleteIntegration).toHaveBeenCalledWith(
project.id,
);
// Verify audit log was created
const auditLog = await prisma.auditLog.findFirst({
where: {
projectId: project.id,
resourceType: "slackIntegration",
resourceId: integration.id,
action: "delete",
},
});
expect(auditLog).toBeDefined();
const beforeData = auditLog?.before
? JSON.parse(auditLog.before)
: null;
expect(beforeData).toMatchObject({
projectId: project.id,
teamId: "T123456",
teamName: "Test Team",
});
// 🔒 CRITICAL: Ensure audit log doesn't expose encrypted bot token
expect(JSON.stringify(beforeData)).not.toContain("xoxb-");
// But encrypted token should be in the audit log for recovery purposes
expect(beforeData).toHaveProperty("botToken");
});
it("should handle missing integration gracefully", async () => {
const { caller, project } = await prepare();
await expect(
caller.slack.disconnect({ projectId: project.id }),
).rejects.toThrow("Slack integration not found");
});
});
});
describe("Slack Security", () => {
it("should encrypt bot tokens in database", async () => {
const { project } = await prepare();
const originalToken = "xoxb-secret-bot-token-12345";
// Create Slack integration
await prisma.slackIntegration.create({
data: {
projectId: project.id,
teamId: "T123456",
teamName: "Test Team",
botToken: encrypt(originalToken),
botUserId: "U123456",
},
});
// Verify token is encrypted in database
const rawIntegration = await prisma.slackIntegration.findUnique({
where: { projectId: project.id },
});
expect(rawIntegration?.botToken).toBeDefined();
expect(rawIntegration?.botToken).not.toBe(originalToken);
expect(rawIntegration?.botToken).not.toContain("xoxb-secret-bot-token");
// Verify the encrypted token can be decrypted back to original
const { decrypt } = await import("@langfuse/shared/encryption");
const decryptedToken = decrypt(rawIntegration!.botToken);
expect(decryptedToken).toBe(originalToken);
});
it("should NEVER expose raw bot tokens in any API response", async () => {
mockSlackService.validateClient.mockResolvedValue(true);
mockSlackService.getChannels.mockResolvedValue([
{ id: "C123456", name: "general", isPrivate: false, isMember: true },
]);
mockSlackService.sendMessage.mockResolvedValue({
messageTs: "1234567890.123456",
channel: "C123456",
});
const { caller, project } = await prepare();
const secretToken = "xoxb-extremely-secret-token-abcdef123456";
// Create Slack integration
await prisma.slackIntegration.create({
data: {
projectId: project.id,
teamId: "T123456",
teamName: "Test Team",
botToken: encrypt(secretToken),
botUserId: "U123456",
},
});
// Test all tRPC endpoints
const results = await Promise.all([
caller.slack.getIntegrationStatus({ projectId: project.id }),
caller.slack.getChannels({ projectId: project.id }),
caller.slack.sendTestMessage({
projectId: project.id,
channelId: "C123456",
channelName: "general",
}),
]);
results.forEach((result) => {
const resultText = JSON.stringify(result);
// 🔒 CRITICAL: Ensure no raw token appears anywhere in response
expect(resultText).not.toContain(secretToken);
expect(resultText).not.toContain("xoxb-extremely-secret-token");
});
});
it("should NEVER set unencrypted tokens in cookies", async () => {
const { caller, project } = await prepare();
// Test tRPC calls (cookies would be set at HTTP layer, not tRPC layer)
const result = await caller.slack.getIntegrationStatus({
projectId: project.id,
});
// 🔒 Ensure tRPC result doesn't contain any token fields that could leak to cookies
expect(JSON.stringify(result)).not.toContain("xoxb-");
expect(result).not.toHaveProperty("botToken");
expect(result).not.toHaveProperty("token");
});
it("should sanitize tokens from error messages", async () => {
const secretToken = "xoxb-secret-error-token-999";
// Mock SlackService to throw error containing token
mockSlackService.getChannels.mockRejectedValue(
new Error(`Authentication failed for token ${secretToken}`),
);
const { caller, project } = await prepare();
// Create Slack integration
await prisma.slackIntegration.create({
data: {
projectId: project.id,
teamId: "T123456",
teamName: "Test Team",
botToken: encrypt(secretToken),
botUserId: "U123456",
},
});
try {
await caller.slack.getChannels({ projectId: project.id });
throw new Error("Expected error to be thrown");
} catch (error) {
const errorMessage =
error instanceof TRPCError ? error.message : String(error);
// 🔒 CRITICAL: Error messages should not contain raw tokens
expect(errorMessage).not.toContain(secretToken);
expect(errorMessage).not.toContain("xoxb-secret-error-token");
// Should still indicate there was an error, just sanitized
expect(errorMessage).toContain("Failed to fetch channels.");
}
});
});
});
@@ -156,6 +156,12 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
role: nextRole,
type: ChatMessageType.System,
});
} else if (nextRole === ChatMessageRole.Model) {
replaceMessage(message.id, {
content: message.content,
role: nextRole,
type: ChatMessageType.ModelText,
});
} else {
const exhaustiveCheck: never = nextRole;
console.error(`Unhandled role: ${exhaustiveCheck}`);
+6 -2
View File
@@ -99,12 +99,16 @@ export function ItemBadge({
variant="outline"
title={label}
className={cn(
"flex max-w-fit items-center gap-1 border-2 bg-background px-1",
"flex max-w-fit items-center gap-1 overflow-hidden whitespace-nowrap border-2 bg-background px-1",
isSmall && "h-4",
)}
>
<Icon className={iconClass} />
{showLabel && <span>{label.replace(/_/g, " ")}</span>}
{showLabel && (
<span className="truncate" title={label.replace(/_/g, " ")}>
{label.replace(/_/g, " ")}
</span>
)}
</Badge>
);
}
@@ -46,7 +46,7 @@ export const LLMApiKeyComponent = (p: {
<Label className="text-xs font-semibold">API key</Label>
<div>
{apiKey ? (
<Link href={`/project/${p.projectId}/settings/api-keys`}>
<Link href={`/project/${p.projectId}/settings/llm-connections`}>
<span className="mr-2 rounded-sm bg-input p-1 text-xs">
{apiKey.displaySecretKey}
</span>
+4 -8
View File
@@ -33,6 +33,7 @@ export type ModelParamsContext = {
modelParams: UIModelParams;
availableProviders: string[];
availableModels: string[];
providerModelCombinations: string[];
updateModelParamValue: <Key extends keyof UIModelParams>(
key: Key,
value: UIModelParams[Key]["value"],
@@ -41,13 +42,14 @@ export type ModelParamsContext = {
formDisabled?: boolean;
modelParamsDescription?: string;
customHeader?: React.ReactNode;
layout?: "vertical" | "compact";
layout?: "compact" | "vertical";
};
export const ModelParameters: React.FC<ModelParamsContext> = ({
modelParams,
availableProviders,
availableModels,
providerModelCombinations,
updateModelParamValue,
setModelParamEnabled,
formDisabled = false,
@@ -168,12 +170,6 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
if (layout === "compact") {
// Create combined options in "Provider: model" format
// We create combinations of all available providers with all available models
const combinedOptions: string[] = [];
availableProviders.forEach((provider) => {
availableModels.forEach((model) => {
combinedOptions.push(`${provider}: ${model}`);
});
});
// Current combined value in "Provider: model" format
const currentCombinedValue = `${modelParams.provider.value}: ${modelParams.model.value}`;
@@ -202,7 +198,7 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
<SelectValue />
</SelectTrigger>
<SelectContent>
{combinedOptions.map((option) => (
{(providerModelCombinations ?? []).map((option) => (
<SelectItem value={option} key={option}>
{option}
</SelectItem>
+14 -1
View File
@@ -119,6 +119,19 @@ export default function Layout(props: PropsWithChildren) {
// project info based on projectId in the URL
const { project, organization } = useQueryProjectOrOrganization();
// Helper function for precise path matching
const isPathActive = (routePath: string, currentPath: string): boolean => {
// Exact match
if (currentPath === routePath) return true;
// Only allow prefix matching if the route ends with a specific page (not just project root)
// This prevents /project/123 from matching /project/123/datasets
const isRoot = routePath.split("/").length <= 3;
if (isRoot) return false;
return currentPath.startsWith(routePath + "/");
};
const mapNavigation = (route: Route): NavigationItem | null => {
// Project-level routes
if (!routerProjectId && route.pathname.includes("[projectId]")) return null;
@@ -196,7 +209,7 @@ export default function Layout(props: PropsWithChildren) {
return {
...route,
url: url,
isActive: router.pathname === route.pathname,
isActive: isPathActive(route.pathname, router.pathname),
items:
items.length > 0
? (items as NavigationItem[]) // does not include null due to filter
+23 -13
View File
@@ -25,7 +25,8 @@ import {
} from "@/src/components/ui/popover";
import { ScrollArea } from "@/src/components/ui/scroll-area";
import { Label } from "@/src/components/ui/label";
import { type APIScoreV2 } from "@langfuse/shared";
import { AnnotationQueueObjectType, type APIScoreV2 } from "@langfuse/shared";
import { CreateNewAnnotationQueueItem } from "@/src/features/annotation-queues/components/CreateNewAnnotationQueueItem";
// some projects have thousands of traces in a sessions, paginate to avoid rendering all at once
const PAGE_SIZE = 50;
@@ -247,17 +248,26 @@ export const SessionPage: React.FC<{
objectType="SESSION"
count={sessionCommentCounts.data?.get(sessionId)}
/>
<AnnotateDrawer
projectId={projectId}
scoreTarget={{
type: "session",
sessionId,
}}
scores={session.data?.scores ?? []}
emptySelectedConfigIds={emptySelectedConfigIds}
setEmptySelectedConfigIds={setEmptySelectedConfigIds}
buttonVariant="outline"
/>
<div className="flex items-start">
<AnnotateDrawer
projectId={projectId}
scoreTarget={{
type: "session",
sessionId,
}}
scores={session.data?.scores ?? []}
emptySelectedConfigIds={emptySelectedConfigIds}
setEmptySelectedConfigIds={setEmptySelectedConfigIds}
buttonVariant="outline"
hasGroupedButton={true}
/>
<CreateNewAnnotationQueueItem
projectId={projectId}
objectId={sessionId}
objectType={AnnotationQueueObjectType.SESSION}
variant="outline"
/>
</div>
</>
),
}}
@@ -341,7 +351,7 @@ export const SessionPage: React.FC<{
);
};
const SessionIO = ({
export const SessionIO = ({
traceId,
projectId,
timestamp,
+271
View File
@@ -0,0 +1,271 @@
import { memo } from "react";
import { type Row } from "@tanstack/react-table";
import { urlRegex } from "@langfuse/shared";
import { type JsonTableRow } from "@/src/components/table/utils/jsonExpansionUtils";
const MAX_STRING_LENGTH_FOR_LINK_DETECTION = 1500;
const MAX_CELL_DISPLAY_CHARS = 2000;
const SMALL_ARRAY_THRESHOLD = 5;
const ARRAY_PREVIEW_ITEMS = 3;
const OBJECT_PREVIEW_KEYS = 2;
const MONO_TEXT_CLASSES = "font-mono text-xs break-words";
const PREVIEW_TEXT_CLASSES = "italic text-gray-500 dark:text-gray-400";
function renderStringWithLinks(text: string): React.ReactNode {
if (text.length >= MAX_STRING_LENGTH_FOR_LINK_DETECTION) {
return text;
}
const localUrlRegex = new RegExp(urlRegex.source, "gi");
const parts = text.split(localUrlRegex);
const matches = text.match(localUrlRegex) || [];
const result: React.ReactNode[] = [];
let matchIndex = 0;
for (let i = 0; i < parts.length; i++) {
if (parts[i]) {
result.push(parts[i]);
}
if (matchIndex < matches.length) {
const url = matches[matchIndex];
result.push(
<a
key={`link-${matchIndex}`}
href={url}
target="_blank"
rel="noopener noreferrer"
className="hover:opacity-80"
onClick={(e) => e.stopPropagation()} // no row expansion when clicking links
>
{url}
</a>,
);
matchIndex++;
}
}
return result;
}
function getValueType(value: unknown): JsonTableRow["type"] {
if (value === null) return "null";
if (value === undefined) return "undefined";
if (Array.isArray(value)) return "array";
return typeof value as JsonTableRow["type"];
}
function renderArrayValue(arr: unknown[]): JSX.Element {
if (arr.length === 0) {
return <span className={PREVIEW_TEXT_CLASSES}>empty list</span>;
}
if (arr.length <= SMALL_ARRAY_THRESHOLD) {
// Show inline values for small arrays
const displayItems = arr
.map((item) => {
const itemType = getValueType(item);
if (itemType === "string") return `"${String(item)}"`;
if (itemType === "object" && item !== null) {
const obj = item as Record<string, unknown>;
const keys = Object.keys(obj);
if (keys.length === 0) return "{}";
if (keys.length <= OBJECT_PREVIEW_KEYS) {
const keyPreview = keys.map((k) => `"${k}": ...`).join(", ");
return `{${keyPreview}}`;
} else {
return `{"${keys[0]}": ...}`;
}
}
if (itemType === "array") return "...";
return String(item);
})
.join(", ");
return <span className={PREVIEW_TEXT_CLASSES}>[{displayItems}]</span>;
} else {
// Show truncated values for large arrays
const preview = arr
.slice(0, ARRAY_PREVIEW_ITEMS)
.map((item) => {
const itemType = getValueType(item);
if (itemType === "string") return `"${String(item)}"`;
if (itemType === "object" || itemType === "array") return "...";
return String(item);
})
.join(", ");
return (
<span className={PREVIEW_TEXT_CLASSES}>
[{preview}, ...{arr.length - ARRAY_PREVIEW_ITEMS} more]
</span>
);
}
}
function renderObjectValue(obj: Record<string, unknown>): JSX.Element {
const keys = Object.keys(obj);
if (keys.length === 0) {
return <span className={PREVIEW_TEXT_CLASSES}>empty object</span>;
}
return <span className={PREVIEW_TEXT_CLASSES}>{keys.length} items</span>;
}
function getValueStringLength(value: unknown): number {
if (typeof value === "string") {
return value.length;
}
try {
return JSON.stringify(value).length;
} catch {
return String(value).length;
}
}
function getTruncatedValue(value: string, maxChars: number): string {
if (value.length <= maxChars) {
return value;
}
const truncated = value.substring(0, maxChars);
const lastSpaceIndex = truncated.lastIndexOf(" ");
// Try to truncate at word boundary if possible
if (lastSpaceIndex > maxChars * 0.8) {
return truncated.substring(0, lastSpaceIndex) + "...";
}
return truncated + "...";
}
export const ValueCell = memo(
({
row,
expandedCells,
toggleCellExpansion,
}: {
row: Row<JsonTableRow>;
expandedCells: Set<string>;
toggleCellExpansion: (cellId: string) => void;
}) => {
const { value, type } = row.original;
const cellId = `${row.id}-value`;
const isCellExpanded = expandedCells.has(cellId);
const getDisplayValue = () => {
switch (type) {
case "string": {
const stringValue = String(value);
const needsTruncation = stringValue.length > MAX_CELL_DISPLAY_CHARS;
const displayValue =
needsTruncation && !isCellExpanded
? getTruncatedValue(stringValue, MAX_CELL_DISPLAY_CHARS)
: stringValue;
return {
content: (
<span className="whitespace-pre-line text-green-600 dark:text-green-400">
&quot;{renderStringWithLinks(displayValue)}&quot;
</span>
),
needsTruncation,
};
}
case "number":
return {
content: (
<span className="text-blue-600 dark:text-blue-400">
{String(value)}
</span>
),
needsTruncation: false,
};
case "boolean":
return {
content: (
<span className="text-orange-600 dark:text-orange-400">
{String(value)}
</span>
),
needsTruncation: false,
};
case "null":
return {
content: (
<span className="italic text-gray-500 dark:text-gray-400">
null
</span>
),
needsTruncation: false,
};
case "undefined":
return {
content: (
<span className="text-gray-500 dark:text-gray-400">
undefined
</span>
),
needsTruncation: false,
};
case "array": {
const arrayValue = value as unknown[];
// Arrays always show previews, never truncate
return {
content: renderArrayValue(arrayValue),
needsTruncation: false,
};
}
case "object": {
const objectValue = value as Record<string, unknown>;
// Objects always show previews, never truncate
return {
content: renderObjectValue(objectValue),
needsTruncation: false,
};
}
default: {
const stringValue = String(value);
const needsTruncation = stringValue.length > MAX_CELL_DISPLAY_CHARS;
const displayValue =
needsTruncation && !isCellExpanded
? getTruncatedValue(stringValue, MAX_CELL_DISPLAY_CHARS)
: stringValue;
return {
content: (
<span className="text-gray-600 dark:text-gray-400">
{displayValue}
</span>
),
needsTruncation,
};
}
}
};
const { content, needsTruncation } = getDisplayValue();
return (
<div className={`${MONO_TEXT_CLASSES} max-w-full`}>
{content}
{needsTruncation && !row.original.hasChildren && (
<div
className="inline cursor-pointer opacity-50"
onClick={(e) => {
e.stopPropagation();
toggleCellExpansion(cellId);
}}
>
{isCellExpanded
? "\n...collapse"
: `\n...expand (${getValueStringLength(value) - MAX_CELL_DISPLAY_CHARS} more characters)`}
</div>
)}
</div>
);
},
);
ValueCell.displayName = "ValueCell";
// Export utilities that might be needed elsewhere
export { getValueStringLength };
+7
View File
@@ -38,6 +38,7 @@ import { type PeekViewProps } from "@/src/components/table/peek/hooks/usePeekVie
import { usePeekView } from "@/src/components/table/peek/hooks/usePeekView";
import { isEqual } from "lodash";
import { useRouter } from "next/router";
import { useColumnSizing } from "@/src/components/table/hooks/useColumnSizing";
interface DataTableProps<TData, TValue> {
columns: LangfuseColumnDef<TData, TValue>[];
@@ -65,6 +66,7 @@ interface DataTableProps<TData, TValue> {
peekView?: PeekViewProps<TData>;
pinFirstColumn?: boolean;
hidePagination?: boolean;
tableName: string;
}
export interface AsyncTableData<T> {
@@ -120,6 +122,7 @@ export function DataTable<TData extends object, TValue>({
peekView,
pinFirstColumn = false,
hidePagination = false,
tableName,
}: DataTableProps<TData, TValue>) {
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const rowheighttw = getRowHeightTailwindClass(rowHeight, customRowHeights);
@@ -136,6 +139,8 @@ export function DataTable<TData extends object, TValue>({
return flatColumnsByGroup;
}, [columns]);
const { columnSizing, setColumnSizing } = useColumnSizing(tableName);
const table = useReactTable({
data: data.data ?? [],
columns,
@@ -169,7 +174,9 @@ export function DataTable<TData extends object, TValue>({
? insertArrayAfterKey(columnOrder, flattedColumnsByGroup)
: undefined,
rowSelection,
columnSizing,
},
onColumnSizingChange: setColumnSizing,
manualFiltering: true,
defaultColumn: {
minSize: 20,
@@ -0,0 +1,44 @@
import useLocalStorage from "@/src/components/useLocalStorage";
import { type ColumnSizingState } from "@tanstack/react-table";
import { debounce } from "lodash";
import { useEffect, useMemo, useState } from "react";
/**
* Persists table column widths to localStorage with automatic debounced saving.
*
* @param tableId - Unique identifier for the table to scope column settings
* @returns Column sizing state, setter, and utility functions for TanStack Table integration
*
* @note Limitation: Multi-tab sync occurs only on page refresh/navigation, not in real-time.
* @note Column state is initialized once on mount from localStorage.
*/
export const useColumnSizing = (tableId: string) => {
const [storedSizing, setStoredSizing] = useLocalStorage<ColumnSizingState>(
`table-columns-${tableId}`,
{},
);
const [columnSizing, setColumnSizing] =
useState<ColumnSizingState>(storedSizing);
// Debounced storage update
const debouncedSave = useMemo(
() =>
debounce((sizing: ColumnSizingState) => {
if (tableId && Object.keys(sizing).length > 0) {
setStoredSizing(sizing);
}
}, 500),
[tableId, setStoredSizing],
);
// Save to storage when state changes
useEffect(() => {
debouncedSave(columnSizing);
}, [columnSizing, debouncedSave]);
return {
columnSizing,
setColumnSizing,
};
};
@@ -14,6 +14,12 @@ import { LangfuseIcon } from "@/src/components/LangfuseLogo";
import { UserCircle2Icon } from "lucide-react";
import { StatusBadge } from "@/src/components/layouts/status-badge";
import { DeactivateEvalConfig } from "@/src/features/evals/components/deactivate-config";
import { Switch } from "@/src/components/ui/switch";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { useState } from "react";
import { cn } from "@/src/utils/tailwind";
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
import { api } from "@/src/utils/api";
export const PeekViewEvaluatorConfigDetail = ({
projectId,
@@ -23,12 +29,16 @@ export const PeekViewEvaluatorConfigDetail = ({
row?: EvaluatorDataRow;
}) => {
const { peekId } = usePeekState();
const [isEditMode, setIsEditMode] = useState(false);
const utils = api.useUtils();
const { data: evalConfig } = usePeekEvalConfigData({
jobConfigurationId: peekId,
projectId,
});
const hasAccess = useHasProjectAccess({ projectId, scope: "evalJob:CUD" });
if (!evalConfig) {
return <Skeleton className="h-full w-full" />;
}
@@ -36,8 +46,8 @@ export const PeekViewEvaluatorConfigDetail = ({
return (
<div className="grid h-full flex-1 grid-rows-[auto,auto,1fr] gap-2 overflow-hidden p-3 contain-layout">
<div className="flex items-center justify-between">
<span className="max-h-fit text-lg font-medium">Configuration</span>
<div className="flex items-center gap-2">
<div className="flex flex-row items-center gap-2">
<span className="max-h-fit text-lg font-medium">Configuration</span>
<div className="flex items-center gap-2">
<StatusBadge
type={evalConfig.status.toLowerCase()}
@@ -49,7 +59,22 @@ export const PeekViewEvaluatorConfigDetail = ({
evalConfig={evalConfig}
/>
</div>
<span className="text-sm text-muted-foreground">View Only</span>
</div>
<div className="flex items-center gap-2">
<span
className={cn("text-sm", isEditMode ? "" : "text-muted-foreground")}
>
Edit Mode
</span>
<Switch
disabled={
!hasAccess ||
(evalConfig?.timeScope?.length === 1 &&
evalConfig.timeScope[0] === "EXISTING")
}
checked={isEditMode}
onCheckedChange={setIsEditMode}
/>
</div>
</div>
<CardDescription className="flex items-center text-sm">
@@ -76,7 +101,7 @@ export const PeekViewEvaluatorConfigDetail = ({
</CardDescription>
<div className="flex max-h-full w-full flex-col items-start justify-between space-y-2 overflow-y-auto pb-4">
<EvaluatorForm
key={evalConfig?.id}
key={`${evalConfig?.id}-${evalConfig?.updatedAt}-${isEditMode}`}
projectId={projectId}
evalTemplates={
evalConfig?.evalTemplate ? [evalConfig.evalTemplate] : []
@@ -89,9 +114,18 @@ export const PeekViewEvaluatorConfigDetail = ({
}
: undefined
}
disabled={true}
mode="edit"
disabled={!isEditMode}
shouldWrapVariables={true}
useDialog={false}
onFormSuccess={() => {
setIsEditMode(false);
utils.evals.invalidate();
showSuccessToast({
title: "Running Evaluator updated",
description: "The evaluator configuration has been updated.",
});
}}
/>
</div>
</div>
@@ -295,6 +295,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
/>
<SettingsTableCard>
<DataTable
tableName={"models"}
columns={columns}
data={
models.isLoading
@@ -1,7 +1,7 @@
import { api } from "@/src/utils/api";
import { DataTable } from "@/src/components/table/data-table";
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { TokenUsageBadge } from "@/src/components/token-usage-badge";
import { NumberParam, useQueryParams, withDefault } from "use-query-params";
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
@@ -15,6 +15,8 @@ import {
BatchExportTableName,
type ObservationType,
TableViewPresetTableName,
AnnotationQueueObjectType,
BatchActionType,
} from "@langfuse/shared";
import { cn } from "@/src/utils/tailwind";
import { LevelColors } from "@/src/components/level-colors";
@@ -43,7 +45,7 @@ import {
convertSelectedEnvironmentsToFilter,
} from "@/src/hooks/use-environment-filter";
import { Badge } from "@/src/components/ui/badge";
import { type Row } from "@tanstack/react-table";
import { type RowSelectionState, type Row } from "@tanstack/react-table";
import TableIdOrName from "@/src/components/table/table-id";
import { ItemBadge } from "@/src/components/ItemBadge";
import { Skeleton } from "@/src/components/ui/skeleton";
@@ -55,6 +57,11 @@ import { useTableViewManager } from "@/src/components/table/table-view-presets/h
import { useRouter } from "next/router";
import { useFullTextSearch } from "@/src/components/table/use-cases/useFullTextSearch";
import { type PeekViewProps } from "@/src/components/table/peek/hooks/usePeekView";
import { TableSelectionManager } from "@/src/features/table/components/TableSelectionManager";
import { useSelectAll } from "@/src/features/table/hooks/useSelectAll";
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
import { TableActionMenu } from "@/src/features/table/components/TableActionMenu";
import { type TableAction } from "@/src/features/table/types";
export type ObservationsTableRow = {
// Shown by default
@@ -117,10 +124,12 @@ export default function ObservationsTable({
const { viewId } = router.query;
const { setDetailPageList } = useDetailPageLists();
const [selectedRows, setSelectedRows] = useState<RowSelectionState>({});
const { searchQuery, searchType, setSearchQuery, setSearchType } =
useFullTextSearch();
const { selectAll, setSelectAll } = useSelectAll(projectId, "observations");
const [paginationState, setPaginationState] = useQueryParams({
pageIndex: withDefault(NumberParam, 0),
pageSize: withDefault(NumberParam, 50),
@@ -272,6 +281,19 @@ export default function ObservationsTable({
},
);
const addToQueueMutation = api.annotationQueueItems.createMany.useMutation({
onSuccess: (data) => {
showSuccessToast({
title: "Observations added to queue",
description: `Selected observations will be added to queue "${data.queueName}". This may take a minute.`,
link: {
href: `/project/${projectId}/annotation-queues/${data.queueId}`,
text: `View queue "${data.queueName}"`,
},
});
},
});
useEffect(() => {
if (generations.isSuccess) {
setDetailPageList(
@@ -302,7 +324,54 @@ export default function ObservationsTable({
);
};
const { selectActionColumn } = TableSelectionManager<ObservationsTableRow>({
projectId,
tableName: "observations",
setSelectedRows,
});
const handleAddToAnnotationQueue = async ({
projectId,
targetId,
}: {
projectId: string;
targetId: string;
}) => {
const selectedGenerationIds = Object.keys(selectedRows).filter(
(generationId) =>
generations.data?.generations.map((g) => g.id).includes(generationId),
);
await addToQueueMutation.mutateAsync({
projectId,
objectIds: selectedGenerationIds,
objectType: AnnotationQueueObjectType.OBSERVATION,
queueId: targetId,
isBatchAction: selectAll,
query: {
filter: filterState,
orderBy: orderByState,
},
});
setSelectedRows({});
};
const tableActions: TableAction[] = [
{
id: "observation-add-to-annotation-queue",
type: BatchActionType.Create,
label: "Add to Annotation Queue",
description: "Add selected observations to an annotation queue.",
targetLabel: "Annotation Queue",
execute: handleAddToAnnotationQueue,
accessCheck: {
scope: "annotationQueues:CUD",
},
},
];
const columns: LangfuseColumnDef<ObservationsTableRow>[] = [
selectActionColumn,
{
accessorKey: "startTime",
id: "startTime",
@@ -975,7 +1044,7 @@ export default function ObservationsTable({
setRowHeight={setRowHeight}
selectedOption={selectedOption}
setDateRangeAndOption={setDateRangeAndOption}
actionButtons={
actionButtons={[
<BatchExportTableButton
{...{
projectId,
@@ -986,15 +1055,40 @@ export default function ObservationsTable({
}}
tableName={BatchExportTableName.Observations}
key="batchExport"
/>
}
/>,
Object.keys(selectedRows).filter((generationId) =>
generations.data?.generations
.map((g) => g.id)
.includes(generationId),
).length > 0 ? (
<TableActionMenu
key="observations-multi-select-actions"
projectId={projectId}
actions={tableActions}
tableName={BatchExportTableName.Observations}
/>
) : null,
]}
environmentFilter={{
values: selectedEnvironments,
onValueChange: setSelectedEnvironments,
options: environmentOptions.map((env) => ({ value: env })),
}}
multiSelect={{
selectAll,
setSelectAll,
selectedRowIds: Object.keys(selectedRows).filter((generationId) =>
generations.data?.generations
.map((g) => g.id)
.includes(generationId),
),
setRowSelection: setSelectedRows,
totalCount,
...paginationState,
}}
/>
<DataTable
tableName={"observations"}
columns={columns}
peekView={peekConfig}
data={
@@ -1017,6 +1111,8 @@ export default function ObservationsTable({
onChange: setPaginationState,
state: paginationState,
}}
rowSelection={selectedRows}
setRowSelection={setSelectedRows}
setOrderBy={setOrderByState}
orderBy={orderByState}
columnOrder={columnOrder}
@@ -254,6 +254,7 @@ export function ScoreConfigsTable({ projectId }: { projectId: string }) {
/>
<SettingsTableCard>
<DataTable
tableName={"scoreConfigs"}
columns={columns}
data={
configs.isLoading
@@ -678,6 +678,7 @@ export default function ScoresTable({
}}
/>
<DataTable
tableName={"scores"}
columns={columns}
data={
scores.isLoading || isViewLoading
@@ -11,6 +11,8 @@ import {
sessionsTableColsWithOptions,
BatchExportTableName,
TableViewPresetTableName,
AnnotationQueueObjectType,
BatchActionType,
} from "@langfuse/shared";
import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context";
import { useOrderByState } from "@/src/features/orderBy/hooks/useOrderByState";
@@ -19,9 +21,8 @@ import { formatIntervalSeconds } from "@/src/utils/dates";
import { numberFormatter, usdFormatter } from "@/src/utils/numbers";
import { type RouterOutput } from "@/src/utils/types";
import type Decimal from "decimal.js";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { NumberParam, useQueryParams, withDefault } from "use-query-params";
import { BatchExportTableButton } from "@/src/components/BatchExportTableButton";
import { useTableDateRange } from "@/src/hooks/useTableDateRange";
import { useDebounce } from "@/src/hooks/useDebounce";
import { joinTableCoreAndMetrics } from "@/src/components/table/utils/joinTableCoreAndMetrics";
@@ -43,6 +44,12 @@ import {
getScoreGroupColumnProps,
verifyAndPrefixScoreDataAgainstKeys,
} from "@/src/features/scores/components/ScoreDetailColumnHelpers";
import { useSelectAll } from "@/src/features/table/hooks/useSelectAll";
import { type TableAction } from "@/src/features/table/types";
import { TableActionMenu } from "@/src/features/table/components/TableActionMenu";
import { type RowSelectionState } from "@tanstack/react-table";
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
import { TableSelectionManager } from "@/src/features/table/components/TableSelectionManager";
export type SessionTableRow = {
id: string;
@@ -76,6 +83,7 @@ export default function SessionsTable({
const { setDetailPageList } = useDetailPageLists();
const { selectedOption, dateRange, setDateRangeAndOption } =
useTableDateRange(projectId);
const [selectedRows, setSelectedRows] = useState<RowSelectionState>({});
const [userFilterState, setUserFilterState] = useQueryFilterState(
[],
@@ -134,6 +142,8 @@ export default function SessionsTable({
environmentFilter,
);
const { selectAll, setSelectAll } = useSelectAll(projectId, "sessions");
const [paginationState, setPaginationState] = useQueryParams({
pageIndex: withDefault(NumberParam, 0),
pageSize: withDefault(NumberParam, 50),
@@ -164,6 +174,19 @@ export default function SessionsTable({
const sessions = api.sessions.all.useQuery(payloadGetAll);
const sessionCountQuery = api.sessions.countAll.useQuery(payloadCount);
const addToQueueMutation = api.annotationQueueItems.createMany.useMutation({
onSuccess: (data) => {
showSuccessToast({
title: "Sessions added to queue",
description: `Selected sessions will be added to queue "${data.queueName}". This may take a minute.`,
link: {
href: `/project/${projectId}/annotation-queues/${data.queueId}`,
text: `View queue "${data.queueName}"`,
},
});
},
});
const { scoreColumns, scoreKeysAndProps, isColumnLoading } =
useIndividualScoreColumns<SessionTableRow>({
projectId,
@@ -222,7 +245,53 @@ export default function SessionsTable({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessions.isSuccess, sessions.data]);
const { selectActionColumn } = TableSelectionManager<SessionTableRow>({
projectId,
tableName: "sessions",
setSelectedRows,
});
const handleAddToAnnotationQueue = async ({
projectId,
targetId,
}: {
projectId: string;
targetId: string;
}) => {
const selectedSessionIds = Object.keys(selectedRows).filter((sessionId) =>
sessions.data?.sessions.map((t) => t.id).includes(sessionId),
);
await addToQueueMutation.mutateAsync({
projectId,
objectIds: selectedSessionIds,
objectType: AnnotationQueueObjectType.SESSION,
queueId: targetId,
isBatchAction: selectAll,
query: {
filter: filterState,
orderBy: orderByState,
},
});
setSelectedRows({});
};
const tableActions: TableAction[] = [
{
id: "session-add-to-annotation-queue",
type: BatchActionType.Create,
label: "Add to Annotation Queue",
description: "Add selected sessions to an annotation queue.",
targetLabel: "Annotation Queue",
execute: handleAddToAnnotationQueue,
accessCheck: {
scope: "annotationQueues:CUD",
},
},
];
const columns: LangfuseColumnDef<SessionTableRow>[] = [
selectActionColumn,
{
accessorKey: "bookmarked",
id: "bookmarked",
@@ -561,6 +630,18 @@ export default function SessionsTable({
filterColumnDefinition={transformFilterOptions()}
filterState={userFilterState}
setFilterState={useDebounce(setUserFilterState)}
actionButtons={[
Object.keys(selectedRows).filter((sessionId) =>
sessions.data?.sessions.map((s) => s.id).includes(sessionId),
).length > 0 ? (
<TableActionMenu
key="sessions-multi-select-actions"
projectId={projectId}
actions={tableActions}
tableName={BatchExportTableName.Sessions}
/>
) : null,
]}
columns={columns}
columnVisibility={columnVisibility}
setColumnVisibility={setColumnVisibility}
@@ -571,18 +652,21 @@ export default function SessionsTable({
projectId,
controllers: viewControllers,
}}
actionButtons={[
<BatchExportTableButton
{...{ projectId, filterState, orderByState }}
tableName={BatchExportTableName.Sessions}
key="batchExport"
/>,
]}
selectedOption={selectedOption}
setDateRangeAndOption={setDateRangeAndOption}
columnsWithCustomSelect={["userIds"]}
rowHeight={rowHeight}
setRowHeight={setRowHeight}
multiSelect={{
selectAll,
setSelectAll,
selectedRowIds: Object.keys(selectedRows).filter((sessionId) =>
sessions.data?.sessions.map((s) => s.id).includes(sessionId),
),
setRowSelection: setSelectedRows,
totalCount,
...paginationState,
}}
environmentFilter={{
values: selectedEnvironments,
onValueChange: setSelectedEnvironments,
@@ -590,6 +674,7 @@ export default function SessionsTable({
}}
/>
<DataTable
tableName={"sessions"}
columns={columns}
data={
sessions.isLoading || isViewLoading
@@ -640,6 +725,8 @@ export default function SessionsTable({
onColumnVisibilityChange={setColumnVisibility}
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
rowSelection={selectedRows}
setRowSelection={setSelectedRows}
help={{
description:
"A session is a collection of related traces, such as a conversation or thread. To begin, add a sessionId to the trace.",
+33 -24
View File
@@ -134,6 +134,7 @@ export type TracesTableProps = {
hideControls?: boolean;
externalFilterState?: FilterState;
externalDateRange?: TableDateRange;
limitRows?: number;
};
export default function TracesTable({
@@ -143,6 +144,7 @@ export default function TracesTable({
hideControls = false,
externalFilterState,
externalDateRange,
limitRows,
}: TracesTableProps) {
const utils = api.useUtils();
const [selectedRows, setSelectedRows] = useState<RowSelectionState>({});
@@ -246,8 +248,8 @@ export default function TracesTable({
...tracesAllCountFilter,
searchQuery: searchQuery,
searchType: searchType,
page: paginationState.pageIndex,
limit: paginationState.pageSize,
page: limitRows ? 0 : paginationState.pageIndex,
limit: limitRows ?? paginationState.pageSize,
orderBy: orderByState,
};
@@ -448,6 +450,8 @@ export default function TracesTable({
},
];
const enableSorting = !hideControls;
const columns: LangfuseColumnDef<TracesTableRow>[] = [
selectActionColumn,
...(hideControls
@@ -474,7 +478,7 @@ export default function TracesTable({
/>
) : undefined;
},
enableSorting: true,
enableSorting,
},
]),
{
@@ -483,7 +487,7 @@ export default function TracesTable({
id: "timestamp",
size: 150,
enableHiding: true,
enableSorting: true,
enableSorting,
cell: ({ row }) => {
const value: TracesTableRow["timestamp"] = row.getValue("timestamp");
return value ? <LocalIsoDate date={value} /> : undefined;
@@ -495,7 +499,7 @@ export default function TracesTable({
id: "name",
size: 150,
enableHiding: true,
enableSorting: true,
enableSorting,
cell: ({ row }) => {
const value: TracesTableRow["name"] = row.getValue("name");
return value ? (
@@ -583,7 +587,7 @@ export default function TracesTable({
) : undefined;
},
enableHiding: true,
enableSorting: true,
enableSorting,
},
{
@@ -612,7 +616,7 @@ export default function TracesTable({
</BreakdownTooltip>
);
},
enableSorting: true,
enableSorting,
enableHiding: true,
},
{
@@ -637,7 +641,7 @@ export default function TracesTable({
) : null;
},
enableHiding: true,
enableSorting: true,
enableSorting,
},
{
accessorKey: "environment",
@@ -736,7 +740,7 @@ export default function TracesTable({
},
defaultHidden: true,
enableHiding: true,
enableSorting: true,
enableSorting,
},
{
accessorKey: "userId",
@@ -755,7 +759,7 @@ export default function TracesTable({
},
defaultHidden: true,
enableHiding: true,
enableSorting: true,
enableSorting,
},
{
accessorKey: "observationCount",
@@ -798,7 +802,7 @@ export default function TracesTable({
},
defaultHidden: true,
enableHiding: true,
enableSorting: true,
enableSorting,
},
{
accessorKey: "version",
@@ -811,7 +815,7 @@ export default function TracesTable({
},
defaultHidden: true,
enableHiding: true,
enableSorting: true,
enableSorting,
},
{
accessorKey: "release",
@@ -824,7 +828,7 @@ export default function TracesTable({
},
defaultHidden: true,
enableHiding: true,
enableSorting: true,
enableSorting,
},
{
accessorKey: "id",
@@ -840,7 +844,7 @@ export default function TracesTable({
},
defaultHidden: true,
enableHiding: true,
enableSorting: true,
enableSorting,
},
{
accessorKey: "cost",
@@ -874,7 +878,7 @@ export default function TracesTable({
},
defaultHidden: true,
enableHiding: true,
enableSorting: true,
enableSorting,
},
{
accessorKey: "outputCost",
@@ -896,7 +900,7 @@ export default function TracesTable({
},
enableHiding: true,
defaultHidden: true,
enableSorting: true,
enableSorting,
},
],
},
@@ -924,7 +928,7 @@ export default function TracesTable({
},
enableHiding: true,
defaultHidden: true,
enableSorting: true,
enableSorting,
},
{
accessorKey: "outputTokens",
@@ -938,7 +942,7 @@ export default function TracesTable({
},
enableHiding: true,
defaultHidden: true,
enableSorting: true,
enableSorting,
},
{
accessorKey: "totalTokens",
@@ -952,7 +956,7 @@ export default function TracesTable({
},
enableHiding: true,
defaultHidden: true,
enableSorting: true,
enableSorting,
},
],
},
@@ -1191,11 +1195,15 @@ export default function TracesTable({
data: rows,
}
}
pagination={{
totalCount,
onChange: setPaginationState,
state: paginationState,
}}
pagination={
limitRows
? undefined
: {
totalCount,
onChange: setPaginationState,
state: paginationState,
}
}
setOrderBy={setOrderByState}
orderBy={orderByState}
rowSelection={selectedRows}
@@ -1207,6 +1215,7 @@ export default function TracesTable({
rowHeight={rowHeight}
pinFirstColumn={!hideControls}
peekView={peekConfig}
tableName={"traces"}
/>
</>
);
@@ -0,0 +1,131 @@
/**
* Utility functions for JSON expansion state management across traces
*/
// Convert row ID (e.g., "metadata-settings-theme") to key path (e.g., "metadata.settings.theme")
export function convertRowIdToKeyPath(rowId: string): string {
return rowId.replace(/-/g, ".");
}
// Utility function to get children from lazy-loaded rows
export function getRowChildren(row: JsonTableRow): JsonTableRow[] {
if (row.subRows && row.subRows.length > 0) {
return row.subRows;
}
if (row.rawChildData) {
// Prevent infinite recursion by limiting depth; 25 levels of nesting should make a reasonable assumption
if (row.level > 25) {
return [];
}
return transformJsonToTableData(
row.rawChildData,
row.key,
row.level + 1,
row.id,
false, // Don't lazy load for child generation
);
}
return [];
}
// Types for JSON table rows
export interface JsonTableRow {
id: string;
key: string;
value: unknown;
type:
| "string"
| "number"
| "boolean"
| "object"
| "array"
| "null"
| "undefined";
hasChildren: boolean;
level: number;
subRows?: JsonTableRow[];
// For lazy loading of sub-row table data
rawChildData?: unknown;
childrenGenerated?: boolean;
}
function getValueType(value: unknown): JsonTableRow["type"] {
if (value === null) return "null";
if (value === undefined) return "undefined";
if (Array.isArray(value)) return "array";
return typeof value as JsonTableRow["type"];
}
function hasChildren(value: unknown, valueType: JsonTableRow["type"]): boolean {
return (
(valueType === "object" &&
Object.keys(value as Record<string, unknown>).length > 0) ||
(valueType === "array" && Array.isArray(value) && value.length > 0)
);
}
export function transformJsonToTableData(
json: unknown,
parentKey = "",
level = 0,
parentId = "",
lazy = false,
): JsonTableRow[] {
const rows: JsonTableRow[] = [];
if (typeof json !== "object" || json === null) {
return [
{
id: parentId || "0",
key: parentKey || "root",
value: json,
type: getValueType(json),
hasChildren: false,
level,
},
];
}
const entries = Array.isArray(json)
? json.map((item, index) => [index.toString(), item])
: Object.entries(json);
entries.forEach(([key, value]) => {
const id = parentId ? `${parentId}-${key}` : key;
const valueType = getValueType(value);
const childrenExist = hasChildren(value, valueType);
const row: JsonTableRow = {
id,
key,
value,
type: valueType,
hasChildren: childrenExist,
level,
childrenGenerated: false,
};
if (childrenExist) {
if (lazy && level === 0) {
// For lazy loading, store raw data instead of processing children
row.rawChildData = value;
row.subRows = []; // Empty initially
} else {
// Normal processing or nested children
const children = transformJsonToTableData(
value,
key,
level + 1,
id,
lazy,
);
row.subRows = children;
row.childrenGenerated = true;
}
}
rows.push(row);
});
return rows;
}
+181 -102
View File
@@ -15,6 +15,7 @@ import { MarkdownJsonView } from "@/src/components/ui/MarkdownJsonView";
import { SubHeaderLabel } from "@/src/components/layouts/header";
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import useLocalStorage from "@/src/components/useLocalStorage";
export const IOPreview: React.FC<{
input?: Prisma.JsonValue;
@@ -26,6 +27,14 @@ export const IOPreview: React.FC<{
hideInput?: boolean;
currentView?: "pretty" | "json";
setIsPrettyViewAvailable?: (value: boolean) => void;
inputExpansionState?: Record<string, boolean> | boolean;
outputExpansionState?: Record<string, boolean> | boolean;
onInputExpansionChange?: (
expansion: Record<string, boolean> | boolean,
) => void;
onOutputExpansionChange?: (
expansion: Record<string, boolean> | boolean,
) => void;
}> = ({
isLoading = false,
hideIfNull = false,
@@ -33,11 +42,15 @@ export const IOPreview: React.FC<{
hideInput = false,
media,
currentView,
inputExpansionState,
outputExpansionState,
onInputExpansionChange,
onOutputExpansionChange,
...props
}) => {
const [localCurrentView, setLocalCurrentView] = useState<"pretty" | "json">(
"pretty",
);
const [localCurrentView, setLocalCurrentView] = useLocalStorage<
"pretty" | "json"
>("jsonViewPreference", "pretty");
const selectedView = currentView ?? localCurrentView;
const capture = usePostHogClientCapture();
const input = deepParseJson(props.input);
@@ -123,62 +136,99 @@ export const IOPreview: React.FC<{
</Tabs>
</div>
) : null}
{isPrettyViewAvailable && selectedView === "pretty" ? (
{/* Always render components to preserve state, just hide via CSS*/}
{isPrettyViewAvailable ? (
<>
{inChatMlArray.success ? (
<OpenAiMessageView
messages={[
...inChatMlArray.data,
...(outChatMlArray.success
? outChatMlArray.data.map((m) => ({
...m,
role: m.role ?? "assistant",
}))
: [
{
role: "assistant",
...(typeof outputClean === "string"
? { content: outputClean }
: { json: outputClean }),
} as ChatMlMessageSchema,
]),
]}
shouldRenderMarkdown
additionalInput={
Object.keys(additionalInput ?? {}).length > 0
? additionalInput
: undefined
}
media={media ?? []}
currentView={selectedView}
/>
) : (
<>
{!(hideIfNull && !input) && !hideInput ? (
<PrettyJsonView
title="Input"
className="ph-no-capture"
json={input ?? null}
isLoading={isLoading}
media={media?.filter((m) => m.field === "input") ?? []}
currentView={selectedView}
/>
) : null}
{!(hideIfNull && !output) && !hideOutput ? (
<PrettyJsonView
title="Output"
className="ph-no-capture"
json={outputClean}
isLoading={isLoading}
media={media?.filter((m) => m.field === "output") ?? []}
currentView={selectedView}
/>
) : null}
</>
)}
{/* Pretty view content */}
<div
style={{ display: selectedView === "pretty" ? "block" : "none" }}
>
{inChatMlArray.success ? (
<OpenAiMessageView
messages={[
...inChatMlArray.data,
...(outChatMlArray.success
? outChatMlArray.data.map((m) => ({
...m,
role: m.role ?? "assistant",
}))
: [
{
role: "assistant",
...(typeof outputClean === "string"
? { content: outputClean }
: { json: outputClean }),
} as ChatMlMessageSchema,
]),
]}
shouldRenderMarkdown
additionalInput={
Object.keys(additionalInput ?? {}).length > 0
? additionalInput
: undefined
}
media={media ?? []}
currentView={selectedView}
/>
) : (
<>
{!(hideIfNull && !input) && !hideInput ? (
<PrettyJsonView
title="Input"
className="ph-no-capture"
json={input ?? null}
isLoading={isLoading}
media={media?.filter((m) => m.field === "input") ?? []}
currentView={selectedView}
externalExpansionState={inputExpansionState}
onExternalExpansionChange={onInputExpansionChange}
/>
) : null}
{!(hideIfNull && !output) && !hideOutput ? (
<PrettyJsonView
title="Output"
className="ph-no-capture"
json={outputClean}
isLoading={isLoading}
media={media?.filter((m) => m.field === "output") ?? []}
currentView={selectedView}
externalExpansionState={outputExpansionState}
onExternalExpansionChange={onOutputExpansionChange}
/>
) : null}
</>
)}
</div>
{/* JSON view content */}
<div style={{ display: selectedView === "json" ? "block" : "none" }}>
{!(hideIfNull && !input) && !hideInput ? (
<PrettyJsonView
title="Input"
className="ph-no-capture"
json={input ?? null}
isLoading={isLoading}
media={media?.filter((m) => m.field === "input") ?? []}
currentView={selectedView}
externalExpansionState={inputExpansionState}
onExternalExpansionChange={onInputExpansionChange}
/>
) : null}
{!(hideIfNull && !output) && !hideOutput ? (
<PrettyJsonView
title="Output"
className="ph-no-capture"
json={outputClean}
isLoading={isLoading}
media={media?.filter((m) => m.field === "output") ?? []}
currentView={selectedView}
externalExpansionState={outputExpansionState}
onExternalExpansionChange={onOutputExpansionChange}
/>
) : null}
</div>
</>
) : null}
{selectedView === "json" || !isPrettyViewAvailable ? (
) : (
<>
{!(hideIfNull && !input) && !hideInput ? (
<PrettyJsonView
@@ -188,6 +238,8 @@ export const IOPreview: React.FC<{
isLoading={isLoading}
media={media?.filter((m) => m.field === "input") ?? []}
currentView={selectedView}
externalExpansionState={inputExpansionState}
onExternalExpansionChange={onInputExpansionChange}
/>
) : null}
{!(hideIfNull && !output) && !hideOutput ? (
@@ -198,10 +250,12 @@ export const IOPreview: React.FC<{
isLoading={isLoading}
media={media?.filter((m) => m.field === "output") ?? []}
currentView={selectedView}
externalExpansionState={outputExpansionState}
onExternalExpansionChange={onOutputExpansionChange}
/>
) : null}
</>
) : null}
)}
</>
);
};
@@ -269,52 +323,77 @@ export const OpenAiMessageView: React.FC<{
.map((message, index) => (
<Fragment key={index}>
{isPlaceholderMessage(message) ? (
shouldRenderMarkdown ? (
<MarkdownJsonView
title="Placeholder"
content={message.name || "Unnamed placeholder"}
customCodeHeaderClassName={cn("bg-primary-foreground")}
/>
) : (
<PrettyJsonView
title="Placeholder"
json={message.name || "Unnamed placeholder"}
projectIdForPromptButtons={projectIdForPromptButtons}
currentView={currentView}
/>
)
<>
<div
style={{
display: shouldRenderMarkdown ? "block" : "none",
}}
>
<MarkdownJsonView
title="Placeholder"
content={message.name || "Unnamed placeholder"}
customCodeHeaderClassName={cn("bg-primary-foreground")}
/>
</div>
<div
style={{
display: shouldRenderMarkdown ? "none" : "block",
}}
>
<PrettyJsonView
title="Placeholder"
json={message.name || "Unnamed placeholder"}
projectIdForPromptButtons={projectIdForPromptButtons}
currentView={currentView}
/>
</div>
</>
) : (
<>
{shouldRenderContent(message) &&
(shouldRenderMarkdown ? (
<MarkdownJsonView
title={message.name ?? message.role}
content={message.content || '""'}
className={cn(
!!message.json &&
!isPlaceholderMessage(message) &&
"rounded-b-none",
)}
customCodeHeaderClassName={cn(
message.role === "assistant" && "bg-secondary",
message.role === "system" &&
"bg-primary-foreground",
)}
audio={message.audio}
/>
) : (
<PrettyJsonView
title={message.name ?? message.role}
json={message.content}
projectIdForPromptButtons={projectIdForPromptButtons}
className={cn(
!!message.json &&
!isPlaceholderMessage(message) &&
"rounded-b-none",
)}
currentView={currentView}
/>
))}
{shouldRenderContent(message) && (
<>
<div
style={{
display: shouldRenderMarkdown ? "block" : "none",
}}
>
<MarkdownJsonView
title={message.name ?? message.role}
content={message.content || '""'}
className={cn(
!!message.json &&
!isPlaceholderMessage(message) &&
"rounded-b-none",
)}
customCodeHeaderClassName={cn(
message.role === "assistant" && "bg-secondary",
message.role === "system" &&
"bg-primary-foreground",
)}
audio={message.audio}
/>
</div>
<div
style={{
display: shouldRenderMarkdown ? "none" : "block",
}}
>
<PrettyJsonView
title={message.name ?? message.role}
json={message.content}
projectIdForPromptButtons={
projectIdForPromptButtons
}
className={cn(
!!message.json &&
!isPlaceholderMessage(message) &&
"rounded-b-none",
)}
currentView={currentView}
/>
</div>
</>
)}
{shouldRenderJson(message) &&
!isPlaceholderMessage(message) && (
<PrettyJsonView

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