Compare commits

...
165 Commits
Author SHA1 Message Date
steffen911 02449cbe0a chore: release v3.66.0 2025-06-04 09:24:50 +02:00
Steffen SchmitzandGitHub 616c68a87b feat: add support for new OTEL semantic event types (#7135) 2025-06-04 07:06:07 +00:00
Steffen SchmitzandGitHub 319b22ae78 chore: disentangle batch actions from batch exports (#7092)
* chore: disentangle batch actions from batch exports

* chore: revert

* chore: revert

* chore: revert
2025-06-04 06:53:52 +00:00
Max Deichmann b919562eed chore: release v3.65.3 2025-06-04 01:17:23 +02:00
Max DeichmannandGitHub 91c01e7363 chore: allow annotation queues on FOSS (#7130)
chore: move features
2025-06-03 22:17:02 +00:00
Max DeichmannandGitHub 48ec1bccae fix: fix full text search selector (#7129)
* push

* fix: annotation queue count

* chore: move features

* chore: move features

* chore: move features
2025-06-03 22:04:49 +00:00
steffen911 66aa2fd48e chore: release v3.65.2 2025-06-03 22:02:11 +02:00
Steffen SchmitzandGitHub f822bf1049 chore: add opt-out flag from queue length metric checks (#7123) 2025-06-03 19:21:35 +00:00
Hassieb PakzadGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
96ec24aad8 fix(otel): set trace io from root span (#7127)
* fix(otel): set trace io from root span

* Update web/src/features/otel/server/index.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-06-03 16:48:58 +00:00
steffen911 068fbd0cc6 chore: release v3.65.1 2025-06-03 11:33:30 +02:00
Steffen SchmitzandGitHub 7c8b76d573 security: bump tar-fs, rollup version (#7116)
* security: bump tar-fs version

* chore: bump rollup version
2025-06-03 08:13:54 +00:00
Max Deichmann e4e93d64bb chore: release v3.65.0 2025-06-03 10:09:46 +02:00
Max DeichmannandGitHub f5cf8693e9 chore: move core features to OSS (#7091)
* chore: move features

* chore: move features

* chore: move features

* chore: move features

* chore: move features

* chore: move features

* chore: move features

* chore: move features

* chore: move features

* chore: move features

* chore: move features

* chore: move features

* chore: move features

* chore: move features

* chore: move features

* changes to pro plan

* chore: move features
2025-06-03 08:07:45 +00:00
marliessophieandGitHub 5beef28214 style(scores): limit height of grouped scores hovercard (#7106) 2025-06-02 10:59:46 +00:00
marliessophieandGitHub d93712c6ab chore: sort dataset (run) items by created_at and id (#7105)
* `created_at` is not unique, we require a deterministic order of items to ensure data consistency
2025-06-02 10:46:46 +00:00
Hassieb PakzadandGitHub b1b0a966be fix(projects-api-docs): retentionDays to optional (#7103) 2025-06-02 11:49:25 +02:00
b5edadb203 fix: update Google AI Studio model name to preview version (#7086) (#7102)
Co-authored-by: Faris Ibrahim <faris@traba.work>
2025-06-02 11:23:51 +02:00
marliessophieandGitHub 3090882590 fix(default-model): allow set up flow to add LLM API key (#7101) 2025-06-02 07:58:37 +00:00
Max DeichmannandGitHub 787f5d8d10 chore: remove popup from count for historic eval elements (#7098)
* chore: move features

* chore: move features

* chore: move features
2025-05-30 21:25:50 +00:00
Jannik MaierhöferandGitHub 5fa142eda4 chore(ui): changed wording on checkout page (#7045) 2025-05-30 10:05:15 +00:00
steffen911 d96865b019 chore: release v3.64.0 2025-05-30 11:25:55 +02:00
Steffen SchmitzandGitHub 8d808923a1 feat: allow host credentials for blob storage S3 integration (#7072)
* feat: allow host credentials for blob storage S3 integration

* chore: compilation errors
2025-05-30 09:07:52 +00:00
Max DeichmannandGitHub 0f056c1c2d fix: add error message for missing encryption key for self-hosters (#7090)
push
2025-05-30 08:40:50 +00:00
Max DeichmannandGitHub 094a523795 perf: use trace identifier stream to batch delete traces (#7081) 2025-05-29 20:08:27 +02:00
Marc KlingenandGitHub c60e58a568 feat(ui): misc improvements to dashboards including mobile view, big number chart, truncate title/description, enforce min height/width of widgets (#7082)
* truncate title and description to prevent wraps

* remove unnecessary padding from charts

* improve bignumber chart

* add min h/w

* mobile resizing

* fix
2025-05-29 15:58:38 +00:00
Max DeichmannGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
6147e9c518 perf: only read trace identifiers for historic eval read stream (#7080)
* push

* Update packages/shared/src/server/services/traces-ui-table-service.ts

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

* perf: add exact timestamp match for eval batch actions

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-05-29 14:35:39 +00:00
Max DeichmannandGitHub f0408b208e perf: add exact timestamp match for eval batch actions (#7077)
* perf: add exact timestamp match for eval batch actions

* perf: add exact timestamp match for eval batch actions

* perf: add exact timestamp match for eval batch actions
2025-05-29 10:54:13 +00:00
Steffen SchmitzandGitHub 55e0bdcac0 feat: allow validation and run now for blob storage integrations (#7064)
* feat: allow validation and run now for blob storage integrations

* chore: error handling and formatting

* lint

* chore: linting
2025-05-28 16:47:23 +00:00
Hassieb PakzadandGitHub 05e15c9f53 fix(otel): update trace name only for root spans (#7061)
* fix(otel): update trace name only for root spans

* push
2025-05-28 15:30:37 +00:00
Max DeichmannandGitHub ce6249b266 perf: data retention concurrency (#7065) 2025-05-28 17:36:20 +02:00
Max DeichmannandGitHub 83e91e5bf5 chore: add logging to retry queue (#7062)
fix: do not wrap traces toolbar
2025-05-28 13:58:46 +00:00
Hassieb PakzadandGitHub edf073cc18 feat(api-models): return model prices on public API (#7046)
* feat(api-models): return model prices on public API

* add tests

* add openapi spec

* return map instead of list
2025-05-28 13:24:20 +00:00
Hassieb Pakzad 65343a8e9b chore: release v3.63.1 2025-05-28 14:17:40 +02:00
Max DeichmannandGitHub 0978055bdc chore: rename dead letter retry queue (#7058)
* push

* push

* push
2025-05-28 11:54:11 +00:00
Max DeichmannandGitHub 76aaf2a3d0 chore: upgrade clickhouse node client (#6806)
* push

* fix: do not wrap traces toolbar

* fix: do not wrap traces toolbar

* fix: do not wrap traces toolbar
2025-05-28 07:57:14 +00:00
Max DeichmannandGitHub 8ed61bf705 perf: increase retry batch action queue (#7049)
push
2025-05-27 19:59:47 +00:00
Steffen SchmitzandGitHub 45436530b4 chore: add analytic views in ClickHouse (#7034)
* chore: add analytic views in ClickHouse

* chore: add additional properties

* chore: add filtering and order changes
2025-05-27 15:59:12 +00:00
Hassieb PakzadandGitHub 7a3f01fc0a fix(playground): resolve prompts when jumping to playground (#7041)
* fix(playground): resolve prompts when jumping to playground

* push
2025-05-27 14:36:40 +00:00
marliessophieandGitHub 468fc4d079 chore(evals): display default model on peek (#7042) 2025-05-27 14:19:11 +00:00
Hassieb PakzadandGitHub 945d13dac0 fix(playground): update tools and schemas if name already exists (#7039)
* fix(playground): update tools and schemas if name already exists

* push
2025-05-27 13:38:59 +00:00
Marc KlingenandGitHub 3646eaa5a8 feat(model): add regex for claude-sonnet-4@20250514 (#7035) 2025-05-27 00:50:10 +00:00
marliessophieandGitHub feb5af11b8 style: default evaluation model management components (#7028) 2025-05-26 16:57:08 +00:00
marliessophieandGitHub 3711af5663 fix: prompt variables of SQL Semantic Equivalence evaluator (#7029) 2025-05-26 14:56:35 +00:00
marliessophieandGitHub 9ec798f393 feat(evaluation): allow filtering by log status (#7018) 2025-05-26 14:43:57 +00:00
Hassieb PakzadandGitHub f71fd43d91 fix(llm-tool-dialog): do not propagate textarea keystrokes up (#7023)
* fix(llm-tool-dialog): do not propagate textarea keystrokes up

* push
2025-05-26 13:51:51 +00:00
marliessophieandGitHub 0303596420 fix(scores): conditionally render metadata in score badges (#7020) 2025-05-26 11:31:50 +00:00
marliessophieandGitHub cfaccc46d3 chore: ensure eval config modal closes after successful delete (#7015)
* chore: ensure eval config modal closes after successful delete

* chore: eslint
2025-05-26 10:21:47 +00:00
Max DeichmannandGitHub 907f764adc fix: do not wrap traces toolbar (#7019)
* fix: do not wrap traces toolbar

* fix: do not wrap traces toolbar
2025-05-26 10:18:23 +00:00
Hassieb PakzadandGitHub 652e9e3fe5 fix(otel): set trace name only if root span or attr provided (#7017) 2025-05-26 09:59:59 +00:00
Steffen SchmitzGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
48673e43d7 feat: allow propagation of request headers through logs (#6979)
* feat: allow propagation of request headers through logs

* chore: propagate under correct name

* chore: take all header values

* chore: allow more properties in context propagation

* chore: update trpc propagation

* Update packages/shared/src/env.ts

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

* chore: trace header props for ingestion api

* chore: rename env variable

* chore: remove unused comments

* chore: remove outdated addUserToSpan method

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-05-26 07:33:02 +00:00
Marlies Mayerhofer a136ac8184 chore: release v3.63.0 2025-05-24 14:05:07 +02:00
marliessophieandGitHub 9ebf62fc9c chore: do not alert on missing API keys or model (#7007)
* chore: change error string for LLM API key not found

* chore: push

* chore: do not alert on missing default model
2025-05-24 07:16:17 +00:00
Max DeichmannGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
cf52dce773 chore: fix dataset eval executions for historic evals (#7001)
* push

* fix

* push

* fix

* fix

* fix

* fix

* fix

* fix

* Update worker/src/ee/evaluation/evalService.ts

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

* fix

* fix

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-05-23 17:44:21 +00:00
marliessophieandGitHub 0a6ecf1cba feat: evaluator library (#6956)
* chore: make `project_id` nullable on `eval_templates`

* feat: add script for managed langfuse evaluators

* feat: restyle running evaluator configs table

* feat: add peek view for running evals

* fixup: add peek view for evaluator library

* fixup: add configure button to table

* feat: rework eval creation flow

* feat: add option to edit eval from table

* fixup: configure evaluator flow with traces data preview

* style: eval configuration UI elements

* chore: add support for current langfuse templates

* chore: reference templateId on jobExecution

* style: add maintainer icon across

* chore: extract WorkerEvaluationVariableService

* fixup: support live data mapping interface

* fixup: revamp datasets eval ui

* fixup: add clone functionality

* chore: rename online evaluation -> llm-as-a-judge

* feat: add evaluator selection in experiment creation flow

* chore: refactor into hooks in experiment form

* feat: add ragas evaluator prompt templates

* chore: show evaluator prompt preview by default

* fix: eval table filtering

* feat: add input box to slider

* fix: clean up running eval peek view and logs

* fix: ensure preview variables are not wrapped in quotes in evaluation prompt preview

* feat: support navigating through traces table

* feat: support default model workflow

* fix: typos in managed evaluators

* fix: do not allow selection of evaluators requiring default to run

* fix: type and build errors

* chore: rename table to default_llm_model

* chore: rm empty file

* fixup: complete rename

* chore: push

* style: eval set up form

* style: separate new evaluator form out into two steps

* chore: ensure we only run evals if default model is set

* chore: add `partner` col to `eval_templates`

* style: new eval template creation

* chore: add proper mapping for dataset evals

* feat: also put evaluation selection on datasets page

* feat: datasets also run on historic experiments after creating mapping

* revert: 09ab242dc12c8647bc716c84d180bc8e631d4919

* chore: fix eslint and ts

* chore: fix warnings

* fix: ensure we inactivate all job configs that rely on default eval model

* fix: tests

* chore: fix eslint

* chore: wrap in tx

* chore: fix default model checkbox logic

* chore: improve wording on delete model

* fix: eslint

* chore: adjust migration order

* style: push ui improvements

* style: sort templates by partner

* fix: eslint
2025-05-23 17:10:47 +00:00
Steffen SchmitzandGitHub c359900b59 perf: skip S3 list calls for OTEL observations (#6995)
* perf: skip S3 list calls for OTEL observations

* chore: reuse prefix definition
2025-05-23 14:04:40 +00:00
Steffen SchmitzandGitHub 720ed7a36f feat: add new /api/public/organizations/projects route to list projects (#6997)
* feat: add new /api/public/organizations/projects route to list projects

* chore: address typos
2025-05-23 13:56:07 +00:00
Marc KlingenandGitHub 1dbefb0994 feat(cloud): push orgs as tenants to plain (#6986)
* fix: plain initialization issues via updatequeue

* apply to the show cb

* chore

* catch errors

* feat(cloud): push orgs as tenants to plain

* add PLAIN_CARDS_API_TOKEN

* fix

* fix

* limit to cloud

* fix
2025-05-23 13:17:45 +00:00
Jannik MaierhöferandGitHub abfdd2366a chore: lw3-5 notification (#6992) 2025-05-23 10:06:02 +00:00
Marc KlingenandGitHub 1f9bb37f3b fix(cloud): plain.com initialization issues via update queue (#6866)
* fix: plain initialization issues via updatequeue

* apply to the show cb

* chore

* catch errors
2025-05-22 23:00:52 +00:00
Marc Klingen 00afab9725 chore: release v3.62.1 2025-05-22 20:24:52 +02:00
Hassieb PakzadandGitHub d04e808515 feat(cost-tracking+llm-keys): add claude 4 sonnet and opus (#6982) 2025-05-22 17:56:45 +00:00
Jannik MaierhöferandGitHub ee04912f0e chore: lw3-4 notification (#6977) 2025-05-22 14:34:55 +00:00
steffen911 7f04a54c0c chore: release v3.62.0 2025-05-22 16:31:39 +02:00
Steffen SchmitzandGitHub cce11ef6b5 perf: drop unused observation_media index (#6978) 2025-05-22 14:27:21 +00:00
Steffen SchmitzGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
1b03f537e2 feat: allow AWS S3 SSE configuration for S3 interactions (#6965)
* feat: allow AWS S3 SSE configuration for S3 interactions

* Update packages/shared/src/env.ts

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

* chore: whitelist allowed SSE settings

* dummy

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-05-22 12:32:12 +00:00
Hassieb PakzadandGitHub dba114e4a3 feat(otel): add support for observations of type 'event' (#6972) 2025-05-22 12:27:14 +00:00
Steffen SchmitzandGitHub fefe3bc3bd fix: dynamically calc max value for home line charts (#6966)
* fix: dynamically calc max value for home line charts

* chore: wrap in useMemo

* dummy
2025-05-22 12:21:18 +00:00
Steffen SchmitzandGitHub 800cc299cb fix: use quorum writes for temporary dataset tables (#6958) 2025-05-21 15:21:58 +00:00
Hassieb PakzadandGitHub 1bdaed4314 fix(ui-jump-to-playground): look up llm api keys when model parsing (#6957)
* fix(ui-jump-to-playground): look up llm api keys when model parsing

* push

* push
2025-05-21 14:18:37 +00:00
Hassieb PakzadandGitHub 71dde233c5 fix(ui-jump-to-playground): allow response format in modelParams (#6955) 2025-05-21 14:13:05 +02:00
Steffen SchmitzGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
3b80afdf2d chore: reduce minimum project retention to 3 days (#6954)
* chore: reduce minimum project retention to 3 days

* Update web/src/features/projects/components/ConfigureRetention.tsx

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

* chore: update test cases

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-05-21 12:08:52 +00:00
Steffen SchmitzandGitHub b69f0bda9d chore: bump metrics-api rate limits and deprecate daily metrics (#6950)
* chore: bump metrics-api rate limits

* chore: remove docs for metrics/daily

* chore: remove flaky assertion
2025-05-21 10:26:36 +00:00
Hassieb PakzadandGitHub 9b9a58ae45 fix(prompt-experiments): use resolved prompts (#6940)
* fix(prompt-experiments): use resolved prompts

* fix test
2025-05-21 09:39:37 +00:00
Marc Klingen 775e6a8bfc chore: release v3.61.0 2025-05-21 11:12:29 +02:00
5253014fa6 chore: lw3-3 product hunt notification (#6942)
* chore: lw3-3 product hunt notification

* reverse order

* add created date

---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2025-05-21 09:37:55 +02:00
Marc KlingenandGitHub 1571de133b chore: update pipcat otel attribute mapping (#6944)
based on this change: https://github.com/pipecat-ai/pipecat/pull/1859
2025-05-20 23:50:45 +00:00
Marc KlingenandGitHub 9202f4ec79 docs: add note that docker ENTRYPOINT is covered by semver (#6941) 2025-05-20 23:27:18 +00:00
Steffen SchmitzandGitHub 949e3d1181 chore(dashboards): add more langfuse managed templates (#6939)
* chore(dashboards): add more langfuse managed templates

* chore: update descriptions

* chore: dashboards

* chore: remove wrong dashboard

* chore: commit

* chore: push

* chore: typo

* chore: drop projectId etc from default

* chore: remove beta label

* chore: fix number chart type naming
2025-05-20 17:32:08 +00:00
Jannik MaierhöferGitHubMarc Klingenellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2d280329c5 chore: update product notification - lw3-2 (#6929)
* chore: update product notification: launch week day 2

* update

* Update web/src/components/nav/sidebar-notifications.tsx

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

* update

* push

---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-05-20 17:24:29 +00:00
Steffen SchmitzandGitHub 6e1886055a feat(dashboards): add langfuse managed dashboards (#6877)
* feat(dashboards): add langfuse managed dashboard templates

* chore: typing

* chore: update dashboards
2025-05-20 14:49:21 +00:00
Marc KlingenandGitHub 39e4a7533a feat(dashboards): add big number chart type (#6933) 2025-05-20 15:46:20 +02:00
Steffen SchmitzandGitHub e8787e3f38 chore(dashboards): expand measures for dashboards (#6932)
* chore(dashboards): expand measures for dashboards

* chore: add testcase

* chore: define inputTokens and outputTokens

* chore: add timePerOutputToken and costs

* chore: fix definition and handling of tag based breakdowns
2025-05-20 13:43:20 +00:00
Max DeichmannandGitHub 59055cd311 chore: add metrics to measure by-id recency (#6930)
push
2025-05-20 14:59:55 +02:00
Marc KlingenandGitHub 7712b63b95 fix(auth): only apply login domain enforcement to multi-tenant sso (#6928) 2025-05-20 10:33:35 +02:00
marliessophieandGitHub f25d466388 fix(tables-ui): truncate name columns in traces and observations table (#6925) 2025-05-20 07:55:46 +00:00
Max DeichmannandGitHub b9e18f59d8 chore: upgrade zod (#6921)
push
2025-05-19 20:59:03 +00:00
Steffen SchmitzandGitHub 09f3343db2 fix(dashboards): update resize migration to handle empty widget list correctly (#6918)
fix(dashboards): update migration to handle empty widget list correctly
2025-05-19 15:15:32 +00:00
Steffen SchmitzandGitHub 5ab8e4219f feat(dashboard): make dashboard widgets draggable (#6891)
* feat(dashboard): make dashboard widgets draggable

* chore: do not update on layout change

* chore: fix size

* chore: remove the preventCollision flag

* chore: linting

* chore: get row height dynamically

* chore: make chart resizable

* chore: styling for resizes

* chore: add migration to align y values
2025-05-19 14:48:39 +00:00
Steffen SchmitzandGitHub 40359f0e3f chore(dashboards): order metrics and dimensions in widgetform (#6917)
* chore(dashboards): make metrics and dimensions in widgetform searchable

* chore: only sort without search
2025-05-19 14:18:48 +00:00
Marc KlingenandGitHub 817b6d37bc feat(ui): implement optional sidebar notification expiry (#6916)
* feat(web): add expiry for sidebar notifications

* Update web/src/components/nav/sidebar-notifications.tsx

* Update web/src/components/nav/sidebar-notifications.tsx
2025-05-19 13:57:04 +00:00
Hassieb Pakzad 8bb73822d3 chore: release v3.60.1 2025-05-19 14:50:33 +02:00
Jannik MaierhöferGitHubMarc Klingenellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
fa53ba3c1a chore: add lw3-1 notification (#6912)
* chore: add lw3-1 notification

* Update web/src/components/nav/sidebar-notifications.tsx

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

---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-05-19 09:56:02 +00:00
Hassieb PakzadandGitHub 086a764083 chore(media): make ID generation fully deterministic based on hash (#6900) 2025-05-19 11:52:55 +02:00
Steffen SchmitzandGitHub 79a9f3c3e3 perf: add mediaId index to efficiently delete media items (#6909)
* perf: add mediaId index to efficiently delete media items

* chore: only add index if they do not exist yet
2025-05-19 09:30:16 +00:00
Max Deichmann abd46f2d4d chore: release v3.60.0 2025-05-19 11:16:27 +02:00
marliessophieandGitHub dfd9565a3f chore(experiments): improve delete experiment confirm message (#6908) 2025-05-19 07:41:27 +00:00
Marc KlingenandGitHub ebb8a5a13f chore(ui): trim password reset otp (#6905) 2025-05-18 20:01:26 +00:00
Marc KlingenandGitHub 44104596f6 fix(auth): add domain check for multi-tenant sso providers (#6904) 2025-05-18 19:54:40 +00:00
Marc KlingenandGitHub 17f6260671 feat: otel span mapping for pipecat traces (#6903) 2025-05-18 17:41:49 +00:00
Marc KlingenandGitHub 0a2bfe5ab5 fix(ui): peek view header should stay single line on mobile screens (#6902) 2025-05-18 17:23:32 +00:00
Marc KlingenandGitHub 8bd1a4da3e chore(ui): hide thread refs in support chat (#6901)
feat(web): hide thread refs in support chat
2025-05-18 13:28:49 +00:00
Marc KlingenGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
31b230d3e0 feat(auth): switch to OTP based password reset instead of magic link (#6898)
* feat(auth): add otp-based password reset

* fix

* fix lint and margins

* Update web/src/features/auth-credentials/components/ResetPasswordButton.tsx

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

* Update web/src/server/auth.ts

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

* simplify

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-05-18 13:04:40 +00:00
Marc KlingenandGitHub dbe71249e7 chore: ignore HEAD request for auth api endpoints (#6899) 2025-05-18 12:04:20 +00:00
Marc KlingenandGitHub a9d0486edd chore: Update AGENTS guide (#6897)
docs: mention conventional commits
2025-05-18 13:45:29 +02:00
Marc KlingenandGitHub 2bf506a436 fix(ui): hover state color of chat message types (#6895) 2025-05-18 10:38:17 +00:00
Steffen SchmitzandGitHub edca91c12a chore(dashboard): auto-generate widget name from selected props (#6890)
* chore(dashboard): auto-generate widget name from selected props

* chore: lint
2025-05-17 13:52:52 +00:00
Steffen SchmitzandGitHub a11e7ddfee feat(dashboard): include dashboards in cmdk menu (#6889) 2025-05-17 12:28:26 +00:00
Steffen SchmitzandGitHub cb9eb23f36 chore(dashboard): show description on hover in selection tables (#6888) 2025-05-17 12:25:43 +00:00
Steffen SchmitzandGitHub 5e942f0208 feat(dashboards): add langfuse managed read-only dashboard support (#6873)
* chore: add owner to project and return langfuse dashboards

* chore: show owner for dashborads in table

* chore: snapshot

* chore: snapshot

* chore: allow widget clone

* chore: snapshot

* chore: linting

* chore: use project instead of user

* chore: show error message on clone failure

* chore: indicate existing dashboard widget relationship

* chore: show only project dashboards in selection for adding widgets

* chore: make full dashboard row clickable

* chore: add clone button to langfuse dashboards
2025-05-17 12:02:50 +00:00
Max DeichmannandGitHub 8e0e139848 chore: remove clickhouse session id (#6849)
* push

* push
2025-05-16 15:09:08 +00:00
Hassieb Pakzad acc1227962 chore: release v3.59.1 2025-05-16 16:57:30 +02:00
Hassieb PakzadandGitHub d9682511c1 perf(otel): do not pass deep parsed metadata (#6878)
* revert previous commit

* perf(otel): do not pass deep parsed metadata
2025-05-16 14:55:16 +00:00
Hassieb Pakzad 00e0b51bc4 chore: release v3.59.0 2025-05-16 14:06:50 +02:00
Hassieb PakzadGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
e63cf84e4f feat(otel): add Langfuse attribute parsing (#6692)
* feat(otel): add Langfuse attribute parsing

* fix

* push

* add safeguard for cross project spans

* fix test

* add extract completionStartTime

* fix type issues

* handle trace io

* fix

* Update web/src/features/otel/server/index.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-05-16 12:03:01 +00:00
Steffen SchmitzandGitHub 63d1051ac2 perf: increase socket lifetime and connections for clickhouse client (#6870) 2025-05-16 08:54:38 +00:00
Steffen SchmitzandGitHub 0e056eb072 chore: increase default timeframe in custom dashboards to 7d (#6871) 2025-05-16 05:45:37 +00:00
Steffen SchmitzandGitHub 87f7095966 chore: reduce title size in widget item doc hover (#6869) 2025-05-16 05:26:57 +00:00
steffen911 5eefe997ae chore: release v3.58.0 2025-05-16 06:39:23 +02:00
Marc KlingenandGitHub e251e60b53 fix: plain chat availability check should not rely on window.Plain but only on env (#6867) 2025-05-16 05:36:07 +02:00
Marc KlingenandGitHub adc05eeef4 feat(ui-ee): show/hide product modules in main menu via LANGFUSE_UI_VISIBLE/HIDDEN_PRODUCT_MODULES (#6841)
* feat(ui): show/hide product modules in main menu

* prod example

* add dashboards

* improve comment
2025-05-16 00:20:17 +00:00
Steffen SchmitzandGitHub 702143fbe2 perf: skip zod validation for otel event batches (#6861)
* perf: skip zod validation for otel event batches

* chore: remove unnecessary casting
2025-05-15 16:47:30 +00:00
Steffen SchmitzandGitHub 06643f30e5 feat(dashboard): add documentation hovers onto widget properties (#6856)
* feat(dashboard): add documentation hovers onto widget properties

* chore: fix typing

* chore: remove open and close delays of hovercards
2025-05-15 14:31:20 +00:00
Steffen SchmitzandGitHub 2e496d5429 chore(dashboard): make hovers in dark mode more readable (#6858) 2025-05-15 13:54:44 +00:00
Steffen SchmitzandGitHub 5b62df9122 feat(dashboard): add new widget to dashboard on save (#6854)
* feat(dashboard): add new widget to dashboard on save

* chore: linting
2025-05-15 13:15:08 +00:00
Steffen SchmitzandGitHub fd189dc66b fix(dashboard): map traceRelease and traceVersion correctly on breakdown (#6853) 2025-05-15 11:39:51 +00:00
Steffen SchmitzandGitHub 10a3644dcf chore(dashboard): auto-save dashboard on any changes (#6820)
* chore(dashboard): auto-save dashboard on any changes

* chore: linting

* chore: switch timeouts to useDebounce

* chore: linting
2025-05-15 09:48:02 +00:00
Max DeichmannandGitHub 8ca000b225 chore: reduce worker logs (#6851) 2025-05-15 11:33:33 +02:00
Steffen SchmitzandGitHub 47d41b6a42 chore: change logging pattern around redis retries (#6843) 2025-05-15 07:34:00 +00:00
Steffen SchmitzandGitHub 1d66408c18 fix(dashboard): avoid invalid filter name on view switches (#6829)
* fix(dashboard): avoid invalid filter name on view switches

* chore: add test case
2025-05-15 07:18:41 +00:00
Max DeichmannandGitHub 44d1727dd0 chore: reduce error logs for llm 429 errors (#6836) 2025-05-15 00:37:58 +02:00
Max DeichmannandGitHub bfb2878719 chore: reduce log level for prompt service (#6835)
fix
2025-05-14 22:19:00 +00:00
Marc KlingenandGitHub 67e6b4ce87 chore(cloud): increases api rate limits (#6783) 2025-05-14 23:03:49 +02:00
Max DeichmannandGitHub dcc5bf9c6e fix: fix traceid filter for observations by id clickhouse query (#6828) 2025-05-14 16:10:23 +02:00
Steffen SchmitzandGitHub db357101b7 feat(dashboards): add support for cloning existing dashboards (#6824)
* feat(dashboards): add support for cloning existing dashboards

* chore: move buttons into a dedicated dropdown menu

* chore: allow updates to dashboard name and description

* chore: lint

* chore: removal double capturing of dashboard delete

* chore: track deletions
2025-05-14 12:50:26 +00:00
Steffen SchmitzandGitHub afbd45b9c3 chore(metrics-api): return bad request errors for invalid query (#6826) 2025-05-14 11:01:10 +00:00
Max DeichmannGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
b6b37212ce perf: reduce project deletion concurrency (#6823)
* push

* Update worker/src/app.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-05-14 10:30:46 +00:00
Steffen SchmitzandGitHub 76a090241e feat(dashboard): add newly created widget to dashboard on save (#6819)
* feat(dashboard): add newly created widget to dashboard on save

* chore: linting
2025-05-14 09:25:23 +00:00
Max DeichmannandGitHub c53abf6aeb perf: also add trace_id to observations search (#6822)
push
2025-05-14 09:16:49 +00:00
Hassieb Pakzad 5cbfd922f9 chore: release v3.57.2 2025-05-14 09:55:07 +02:00
Hassieb PakzadandGitHub 0be269d519 fix(ui-tool-calls): handle parsing errors gracefully (#6817) 2025-05-14 07:52:47 +00:00
Max DeichmannandGitHub d0eeb44b90 fix: fail gracefully for batch actions in case eval config does not exist (#6809)
* push

* fix
2025-05-13 15:03:57 +00:00
Marlies Mayerhofer 30ced3ab42 chore: release v3.57.1 2025-05-13 16:52:57 +02:00
Max DeichmannandGitHub 2ed6b5a40f chore: parameterize clickhouse deletion timeouts (#6801) 2025-05-13 16:20:13 +02:00
Steffen SchmitzandGitHub 6eaef75f20 fix(dashboard): render localized time strings for LineCharts (#6799)
* fix(dashboard): render localized time strings for LineCharts

* chore: use memo
2025-05-13 13:57:07 +00:00
Max DeichmannandGitHub 31c36565c3 chore: fix clickhouse timeout settings (#6798) 2025-05-13 15:30:04 +02:00
Steffen SchmitzandGitHub d1a1ca7744 fix(dashboard): increase precision of numbers on hover cards (#6797) 2025-05-13 12:54:52 +00:00
Steffen SchmitzandGitHub 2dac75d203 chore(dashboard): use more granular time buckets on home dashboard (#6795) 2025-05-13 12:25:39 +00:00
Steffen SchmitzandGitHub fab90b36a6 chore: remove queryPlayground page (#6796) 2025-05-13 12:20:12 +00:00
Steffen SchmitzandGitHub 597bd6c96b fix(dashboard): warn on slow chart render and ask users to confirm (#6794) 2025-05-13 12:03:02 +00:00
Max DeichmannandGitHub ba3bcc9295 fix: Increase memory usage for deploy (#6793)
Increase memory usage for deploy
2025-05-13 10:32:29 +00:00
marliessophieandGitHub 5f65929a5e fix(session-ui): handling mine type as enum string equivalent (#6792) 2025-05-13 09:40:01 +00:00
Steffen SchmitzandGitHub f1344a4c1a chore: remove unused env variable (#6789) 2025-05-13 08:44:02 +00:00
Steffen SchmitzandGitHub 4e3b9a82f7 fix: do not refresh blobstorage form on browser navigation (#6788) 2025-05-13 08:12:28 +00:00
Max DeichmannandGitHub 7b803619fd fix: fix metadata json selector (#6784) 2025-05-12 21:11:21 +02:00
marliessophieandGitHub 1ef51b1644 feat(datasets): support GET dataset-run-items (#6780)
* feat(datasets): support `GET` dataset run items

* chore: add fern api types

* chore: eslint
2025-05-12 17:55:26 +00:00
Hassieb PakzadandGitHub fee9e34b7e chore(ui-graph-view): allow graph view in impersonation mode (#6782) 2025-05-12 17:37:32 +00:00
Hassieb PakzadandGitHub 1354db489f fix(ui-graph-view): fetch graph data separately (#6781)
* fix(ui-graph-view): fetch graph data separately

* fix

* push

* refactor

* fix

* fix lint
2025-05-12 16:33:33 +00:00
steffen911 740a25150c chore: release v3.57.0 2025-05-12 17:49:49 +02:00
Marc KlingenandGitHub 47c2d65d12 chore(ui): improve ux of full-text search input (#6772)
* width

* change to dropdown

* fix
2025-05-12 15:08:12 +00:00
Steffen SchmitzandGitHub d94e7c1c06 feat: add new metrics api endpoint (#6778)
* feat: add metric api endpoint

* feat: add implementation and documentation

* chore: add defaults for timeDimension and ordering
2025-05-12 14:17:40 +00:00
Steffen SchmitzandGitHub 38ed98a76d feat: add org API keys to organization settings (#6729)
* feat: add org API keys to organization settings

* chore: linting suggestions

* chore: fix typing

* feedback: do not call hooks conditionally
2025-05-12 11:25:01 +00:00
Max DeichmannandGitHub 92567b765b chore: reduce amount of ingested dd spans (#6775)
* push

* fix
2025-05-12 09:47:37 +00:00
Marc KlingenandGitHub b37396893a chore: improve auth error message (#6773) 2025-05-12 04:02:11 +00:00
Steffen SchmitzandGitHub 0fd1eebbff fix: align signed download URL with hostname (#6764) 2025-05-11 12:24:49 +00:00
Max DeichmannandGitHub f478bb5763 refactor: refactor observations by id repo function (#6768)
fix
2025-05-10 20:12:13 +00:00
423 changed files with 18894 additions and 6952 deletions
+4 -4
View File
@@ -157,10 +157,6 @@ OTEL_SERVICE_NAME="langfuse"
# LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=
# LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=
# Exports are streamed to S3 in pages to avoid memory issues
# The page size can be adjusted if needed to optimize performance
# DB_EXPORT_PAGE_SIZE=1000
# Automated provisioning of default resources
# LANGFUSE_INIT_ORG_ID=org-id
# LANGFUSE_INIT_ORG_NAME=org-name
@@ -215,6 +211,8 @@ OTEL_SERVICE_NAME="langfuse"
# LANGFUSE_UI_DEFAULT_BASE_URL_OPENAI=https://api.openai.com/v1
# LANGFUSE_UI_DEFAULT_BASE_URL_ANTHROPIC=https://api.anthropic.com
# LANGFUSE_UI_DEFAULT_BASE_URL_AZURE_OPENAI=https://{instanceName}.openai.azure.com/openai/deployments
# LANGFUSE_UI_VISIBLE_PRODUCT_MODULES=
# LANGFUSE_UI_HIDDEN_PRODUCT_MODULES=
### END Enterprise Edition Configuration
@@ -253,6 +251,8 @@ OTEL_SERVICE_NAME="langfuse"
# Plain Chat
# NEXT_PUBLIC_PLAIN_APP_ID=
# PLAIN_AUTHENTICATION_SECRET=
# PLAIN_API_KEY=
# PLAIN_CARDS_API_TOKEN=
# Admin API
# ADMIN_API_KEY=
@@ -15,6 +15,10 @@ jobs:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- name: Set Swap Space
uses: pierotofy/set-swap-space@master
with:
swap-size-gb: 10
- name: Get app name
uses: winterjung/split@v2
id: split
+16
View File
@@ -0,0 +1,16 @@
# Codex Guidelines for Langfuse
Langfuse is an **open source LLM engineering** platform for developing, monitoring, evaluating and debugging AI applications. See the README for more details.
## Linting
- Run `pnpm run lint` to lint all packages.
- Fix issues automatically with `pnpm run lint:fix`.
## Tests
- Codex cannot run the test suite because it depends on Docker-based infrastructure that is unavailable in this environment.
## Cursor Rules
- Additional folder-specific rules live in `.cursor/rules/`.
## Commits
- Follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) when crafting commit messages.
+1 -1
View File
@@ -30,7 +30,7 @@
"https-proxy-agent": "^7.0.6",
"next": "^14.2.26",
"next-auth": "^4.24.11",
"zod": "^3.23.8"
"zod": "^3.24.4"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
+10 -3
View File
@@ -457,13 +457,13 @@ types:
docs: Unit used by this model.
type: optional<ModelUsageUnit>
inputPrice:
docs: Price (USD) per input unit
docs: Deprecated. See 'prices' instead. Price (USD) per input unit
type: optional<double>
outputPrice:
docs: Price (USD) per output unit
docs: Deprecated. See 'prices' instead. Price (USD) per output unit
type: optional<double>
totalPrice:
docs: Price (USD) per total unit. Cannot be set if input or output price is set.
docs: Deprecated. See 'prices' instead. Price (USD) per total unit. Cannot be set if input or output price is set.
type: optional<double>
tokenizerId:
docs: Optional. Tokenizer to be applied to observations which match to this model. See docs for more details.
@@ -473,6 +473,13 @@ types:
type: optional<unknown>
isLangfuseManaged:
type: boolean
prices:
docs: Price (USD) by usage type
type: map<string, ModelPrice>
ModelPrice:
properties:
price: double
# Utilities
ModelUsageUnit:
@@ -1,6 +1,7 @@
# 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
@@ -11,6 +12,23 @@ service:
path: /dataset-run-items
request: CreateDatasetRunItemRequest
response: commons.DatasetRunItem
list:
method: GET
docs: List dataset run items
path: /dataset-run-items
request:
name: ListDatasetRunItemsRequest
query-parameters:
datasetId: string
runName: string
page:
type: optional<integer>
docs: page number, starts at 1
limit:
type: optional<integer>
docs: limit of items per page
response: PaginatedDatasetRunItems
types:
CreateDatasetRunItemRequest:
properties:
@@ -26,3 +44,7 @@ types:
traceId:
type: optional<string>
docs: traceId should always be provided. For compatibility with older SDK versions it can also be inferred from the provided observationId.
PaginatedDatasetRunItems:
properties:
data: list<commons.DatasetRunItem>
meta: pagination.MetaResponse
+50 -62
View File
@@ -6,71 +6,59 @@ service:
auth: true
base-path: /api/public
endpoints:
daily:
docs: Get daily metrics of the Langfuse project
metrics:
docs: Get metrics from the Langfuse project using a query object
method: GET
path: /metrics/daily
path: /metrics
request:
name: GetDailyMetricsRequest
name: GetMetricsRequest
query-parameters:
page:
type: optional<integer>
docs: page number, starts at 1
limit:
type: optional<integer>
docs: limit of items per page
traceName:
type: optional<string>
docs: Optional filter by the name of the trace
userId:
type: optional<string>
docs: Optional filter by the userId associated with the trace
tags:
type: optional<string>
allow-multiple: true
docs: Optional filter for metrics where traces include all of these tags
environment:
type: optional<string>
allow-multiple: true
docs: Optional filter for metrics where events include any of these environments
fromTimestamp:
type: optional<datetime>
docs: Optional filter to only include traces and observations on or after a certain datetime (ISO 8601)
toTimestamp:
type: optional<datetime>
docs: Optional filter to only include traces and observations before a certain datetime (ISO 8601)
response: DailyMetrics
query:
type: string
docs: |
JSON string containing the query parameters with the following structure:
```json
{
"view": string, // Required. One of "traces", "observations", "scores-numeric", "scores-categorical"
"dimensions": [ // Optional. Default: []
{
"field": string // Field to group by, e.g. "name", "userId", "sessionId"
}
],
"metrics": [ // Required. At least one metric must be provided
{
"measure": string, // What to measure, e.g. "count", "latency", "value"
"aggregation": string // How to aggregate, e.g. "count", "sum", "avg", "p95"
}
],
"filters": [ // Optional. Default: []
{
"column": string, // Column to filter on
"operator": string, // Operator, e.g. "=", ">", "<", "contains"
"value": any, // Value to compare against
"type": string, // Data type, e.g. "string", "number", "stringObject"
"key": string // Required only when filtering on metadata
}
],
"timeDimension": { // Optional. Default: null. If provided, results will be grouped by time
"granularity": string // One of "minute", "hour", "day", "week", "month", "auto"
},
"fromTimestamp": string, // Required. ISO datetime string for start of time range
"toTimestamp": string, // Required. ISO datetime string for end of time range
"orderBy": [ // Optional. Default: null
{
"field": string, // Field to order by
"direction": string // "asc" or "desc"
}
]
}
```
response: MetricsResponse
types:
DailyMetrics:
MetricsResponse:
properties:
data:
type: list<DailyMetricsDetails>
docs: A list of daily metrics, only days with ingested data are included.
meta: pagination.MetaResponse
DailyMetricsDetails:
properties:
date: date
countTraces: integer
countObservations: integer
totalCost:
type: double
docs: Total model cost in USD
usage: list<UsageByModel>
UsageByModel:
docs: Daily usage of a given model. Usage corresponds to the unit set for the specific model (e.g. tokens).
properties:
model: optional<string>
inputUsage:
type: integer
docs: Total number of generation input units (e.g. tokens)
outputUsage:
type: integer
docs: Total number of generation output units (e.g. tokens)
totalUsage:
type: integer
docs: Total number of generation total units (e.g. tokens)
countTraces: integer
countObservations: integer
totalCost:
type: double
docs: Total model cost in USD
type: list<map<string, unknown>>
docs: |
The metrics data. Each item in the list contains the metric values and dimensions requested in the query.
Format varies based on the query parameters.
+1 -1
View File
@@ -39,7 +39,7 @@ service:
docs: Optional filter for observations where the environment is one of the provided values.
fromStartTime:
type: optional<datetime>
docs: Retrieve only observations with a start_time or or after this datetime (ISO 8601).
docs: Retrieve only observations with a start_time on or after this datetime (ISO 8601).
toStartTime:
type: optional<datetime>
docs: Retrieve only observations with a start_time before this datetime (ISO 8601).
@@ -37,6 +37,12 @@ service:
request: MembershipRequest
response: MembershipResponse
getOrganizationProjects:
docs: Get all projects for the organization associated with the API key (requires organization-scoped API key)
method: GET
path: /organizations/projects
response: OrganizationProjectsResponse
types:
MembershipRole:
enum:
@@ -59,4 +65,16 @@ types:
MembershipsResponse:
properties:
memberships: list<MembershipResponse>
memberships: list<MembershipResponse>
OrganizationProject:
properties:
id: string
name: string
metadata: optional<map<string, unknown>>
createdAt: datetime
updatedAt: datetime
OrganizationProjectsResponse:
properties:
projects: list<OrganizationProject>
+9 -9
View File
@@ -11,7 +11,7 @@ service:
method: GET
path: /projects
response: Projects
create:
docs: Create a new project (requires organization-scoped API key)
method: POST
@@ -26,9 +26,9 @@ service:
docs: Optional metadata for the project
retention:
type: integer
docs: Number of days to retain data. Must be 0 or at least 7 days. Requires data-retention entitlement for non-zero values. Optional.
docs: Number of days to retain data. Must be 0 or at least 3 days. Requires data-retention entitlement for non-zero values. Optional.
response: Project
update:
docs: Update a project by ID (requires organization-scoped API key).
method: PUT
@@ -45,9 +45,9 @@ service:
docs: Optional metadata for the project
retention:
type: integer
docs: Number of days to retain data. Must be 0 or at least 7 days. Requires data-retention entitlement for non-zero values. Optional.
docs: Number of days to retain data. Must be 0 or at least 3 days. Requires data-retention entitlement for non-zero values. Optional.
response: Project
delete:
docs: Delete a project by ID (requires organization-scoped API key). Project deletion is processed asynchronously.
method: DELETE
@@ -57,7 +57,7 @@ service:
response:
status-code: 202
type: ProjectDeletionResponse
# API Key endpoints
getApiKeys:
docs: Get all API keys for a project (requires organization-scoped API key)
@@ -104,14 +104,14 @@ types:
type: map<string, unknown>
docs: Metadata for the project
retentionDays:
type: integer
type: optional<integer>
docs: Number of days to retain data. Null or 0 means no retention. Omitted if no retention is configured.
ProjectDeletionResponse:
properties:
success: boolean
message: string
ApiKeyList:
docs: List of API keys for a project
properties:
@@ -137,7 +137,7 @@ types:
secretKey: string
displaySecretKey: string
note: optional<string>
ApiKeyDeletionResponse:
docs: Response for API key deletion
properties:
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.56.0",
"version": "3.66.0",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
@@ -86,7 +86,9 @@
"pnpm": {
"overrides": {
"nanoid": "^3.3.8",
"katex": "^0.16.21"
"katex": "^0.16.21",
"tar-fs": "^2.1.2",
"rollup@^4.0.0": "^4.22.4"
},
"patchedDependencies": {
"next-auth@4.24.11": "patches/next-auth@4.24.11.patch"
@@ -0,0 +1 @@
DROP VIEW IF EXISTS analytics_traces ON CLUSTER default;
@@ -0,0 +1,15 @@
CREATE VIEW analytics_traces ON CLUSTER default AS
SELECT
project_id,
toStartOfHour(timestamp) AS hour,
uniq(id) AS countTraces,
max(user_id IS NOT NULL) AS hasUsers,
max(session_id IS NOT NULL) AS hasSessions,
max(if(environment != 'default', 1, 0)) AS hasEnvironments,
max(length(tags) > 0) AS hasTags
FROM
traces
WHERE toStartOfHour(timestamp) <= toStartOfHour(subtractHours(now(), 1))
GROUP BY
project_id,
hour;
@@ -0,0 +1 @@
DROP VIEW IF EXISTS analytics_observations ON CLUSTER default;
@@ -0,0 +1,18 @@
CREATE VIEW analytics_observations ON CLUSTER default AS
SELECT
project_id,
type,
toStartOfHour(start_time) AS hour,
uniq(id) AS countObservations,
max(level != 'DEFAULT') AS hasLevel,
max(provided_model_name IS NOT NULL) AS hasProvidedModelName,
max(length(provided_usage_details) > 0) AS hasProvidedUsageDetails,
max(length(provided_cost_details) > 0) AS hasProvidedCostDetails,
max(prompt_name IS NOT NULL) AS hasPromptName
FROM
observations
WHERE toStartOfHour(start_time) <= toStartOfHour(subtractHours(now(), 1))
GROUP BY
project_id,
type,
hour;
@@ -0,0 +1 @@
DROP VIEW IF EXISTS analytics_scores ON CLUSTER default;
@@ -0,0 +1,21 @@
CREATE VIEW analytics_scores ON CLUSTER default AS
SELECT
project_id,
toStartOfHour(timestamp) AS hour,
uniq(id) AS countScores,
max(source = 'ANNOTATION') AS hasAnnotation,
max(source = 'API') AS hasApi,
max(source = 'EVAL') AS hasEval,
max(observation_id IS NOT NULL) AS hasObservationScore,
max(session_id IS NOT NULL) AS hasSessionScore,
max(dataset_run_id IS NOT NULL) AS hasDatasetRunScore,
max(data_type = 'BOOLEAN') AS hasBoolScore,
max(data_type = 'NUMERIC') AS hasNumericScore,
max(data_type = 'CATEGORICAL') AS hasCategoricalScore,
max(comment IS NOT NULL) AS hasComment
FROM
scores
WHERE toStartOfHour(timestamp) <= toStartOfHour(subtractHours(now(), 1))
GROUP BY
project_id,
hour;
@@ -0,0 +1 @@
DROP VIEW IF EXISTS analytics_traces;
@@ -0,0 +1,15 @@
CREATE VIEW analytics_traces AS
SELECT
project_id,
toStartOfHour(timestamp) AS hour,
uniq(id) AS countTraces,
max(user_id IS NOT NULL) AS hasUsers,
max(session_id IS NOT NULL) AS hasSessions,
max(if(environment != 'default', 1, 0)) AS hasEnvironments,
max(length(tags) > 0) AS hasTags
FROM
traces
WHERE toStartOfHour(timestamp) <= toStartOfHour(subtractHours(now(), 1))
GROUP BY
project_id,
hour;
@@ -0,0 +1 @@
DROP VIEW IF EXISTS analytics_observations;
@@ -0,0 +1,18 @@
CREATE VIEW analytics_observations AS
SELECT
project_id,
type,
toStartOfHour(start_time) AS hour,
uniq(id) AS countObservations,
max(level != 'DEFAULT') AS hasLevel,
max(provided_model_name IS NOT NULL) AS hasProvidedModelName,
max(length(provided_usage_details) > 0) AS hasProvidedUsageDetails,
max(length(provided_cost_details) > 0) AS hasProvidedCostDetails,
max(prompt_name IS NOT NULL) AS hasPromptName
FROM
observations
WHERE toStartOfHour(start_time) <= toStartOfHour(subtractHours(now(), 1))
GROUP BY
project_id,
type,
hour;
@@ -0,0 +1 @@
DROP VIEW IF EXISTS analytics_scores;
@@ -0,0 +1,21 @@
CREATE VIEW analytics_scores AS
SELECT
project_id,
toStartOfHour(timestamp) AS hour,
uniq(id) AS countScores,
max(source = 'ANNOTATION') AS hasAnnotation,
max(source = 'API') AS hasApi,
max(source = 'EVAL') AS hasEval,
max(observation_id IS NOT NULL) AS hasObservationScore,
max(session_id IS NOT NULL) AS hasSessionScore,
max(dataset_run_id IS NOT NULL) AS hasDatasetRunScore,
max(data_type = 'BOOLEAN') AS hasBoolScore,
max(data_type = 'NUMERIC') AS hasNumericScore,
max(data_type = 'CATEGORICAL') AS hasCategoricalScore,
max(comment IS NOT NULL) AS hasComment
FROM
scores
WHERE toStartOfHour(timestamp) <= toStartOfHour(subtractHours(now(), 1))
GROUP BY
project_id,
hour;
+3 -3
View File
@@ -33,7 +33,7 @@
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 65",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 70",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
"db:migrate": "DISABLE_ERD=false dotenv -e ../../.env -- npx prisma migrate dev",
"db:push": "DISABLE_ERD=false dotenv -e ../../.env -- npx prisma db push",
@@ -60,7 +60,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.4.0",
"@clickhouse/client": "^1.11.1",
"@google-cloud/storage": "^7.15.2",
"@langchain/anthropic": "^0.3.12",
"@langchain/aws": "^0.1.3",
@@ -91,7 +91,7 @@
"prisma-extension-kysely": "^2.1.0",
"uuid": "^9.0.1",
"winston": "^3.15.0",
"zod": "^3.23.8",
"zod": "^3.24.4",
"zod-to-json-schema": "^3.23.5"
},
"devDependencies": {
+22 -7
View File
@@ -109,7 +109,8 @@ export const DashboardWidgetChartType = {
BAR_TIME_SERIES: "BAR_TIME_SERIES",
HORIZONTAL_BAR: "HORIZONTAL_BAR",
VERTICAL_BAR: "VERTICAL_BAR",
PIE: "PIE"
PIE: "PIE",
NUMBER: "NUMBER"
} as const;
export type DashboardWidgetChartType = (typeof DashboardWidgetChartType)[keyof typeof DashboardWidgetChartType];
export type Account = {
@@ -227,8 +228,8 @@ export type BlobStorageIntegration = {
type: BlobStorageIntegrationType;
bucket_name: string;
prefix: string;
access_key_id: string;
secret_access_key: string;
access_key_id: string | null;
secret_access_key: string | null;
region: string;
endpoint: string | null;
force_path_style: boolean;
@@ -325,17 +326,29 @@ export type DatasetRuns = {
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
};
export type EvalTemplate = {
export type DefaultLlmModel = {
id: string;
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
project_id: string;
llm_api_key_id: string;
provider: string;
adapter: string;
model: string;
model_params: unknown | null;
};
export type EvalTemplate = {
id: string;
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
project_id: string | null;
name: string;
version: number;
prompt: string;
model: string;
provider: string;
model_params: unknown;
partner: string | null;
model: string | null;
provider: string | null;
model_params: unknown | null;
vars: Generated<string[]>;
output_schema: unknown;
};
@@ -361,6 +374,7 @@ export type JobExecution = {
updated_at: Generated<Timestamp>;
project_id: string;
job_configuration_id: string;
job_template_id: string | null;
status: JobExecutionStatus;
start_time: Timestamp | null;
end_time: Timestamp | null;
@@ -702,6 +716,7 @@ export type DB = {
dataset_run_items: DatasetRunItems;
dataset_runs: DatasetRuns;
datasets: Dataset;
default_llm_models: DefaultLlmModel;
eval_templates: EvalTemplate;
job_configurations: JobConfiguration;
job_executions: JobExecution;
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX CONCURRENTLY IF NOT EXISTS "trace_media_project_id_media_id_idx" ON "trace_media"("project_id", "media_id");
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX CONCURRENTLY IF NOT EXISTS "observation_media_project_id_media_id_idx" ON "observation_media"("project_id", "media_id");
@@ -0,0 +1,3 @@
-- Index cannot be created concurrently in a transaction, so we need to create it separately
-- Prerequisite for migration 20250518075613_media_relax_id_uniqueness_to_project_only
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "media_project_id_id_key" ON "media"("project_id", "id");
@@ -0,0 +1,21 @@
/*
Warnings:
- The primary key for the `media` table will be changed. If it partially fails, the table could be left without primary key constraint.
- A unique constraint covering the columns `[project_id,id]` on the table `media` will be added. If there are existing duplicate values, this will fail.
*/
-- DropForeignKey
ALTER TABLE "observation_media" DROP CONSTRAINT "observation_media_media_id_fkey";
-- DropForeignKey
ALTER TABLE "trace_media" DROP CONSTRAINT "trace_media_media_id_fkey";
-- AlterTable
ALTER TABLE "media" DROP CONSTRAINT "media_pkey";
-- AddForeignKey
ALTER TABLE "trace_media" ADD CONSTRAINT "trace_media_media_id_project_id_fkey" FOREIGN KEY ("media_id", "project_id") REFERENCES "media"("id", "project_id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "observation_media" ADD CONSTRAINT "observation_media_media_id_project_id_fkey" FOREIGN KEY ("media_id", "project_id") REFERENCES "media"("id", "project_id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,33 @@
/* Triple y and y_size in dashboards.definition->'widgets' */
WITH updated AS (
SELECT
id,
CASE
WHEN jsonb_array_length(definition->'widgets') = 0 THEN definition
ELSE jsonb_set(
definition, -- original JSON
'{widgets}', -- path to replace
(
SELECT jsonb_agg( -- rebuild widgets array
/* 1) y * 3 2) y_size * 3 */
jsonb_set(
jsonb_set(widget, '{y}',
to_jsonb( ((widget->>'y')::int * 3) ), /* y */
true
),
'{y_size}',
to_jsonb( ((widget->>'y_size')::int * 3) ),/* y_size */
true
)
)
FROM jsonb_array_elements(definition->'widgets') AS widget
),
true -- create path if absent
)
END AS new_def
FROM dashboards
)
UPDATE dashboards d
SET definition = u.new_def
FROM updated u
WHERE d.id = u.id;
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "DashboardWidgetChartType" ADD VALUE 'NUMBER';
@@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX CONCURRENTLY IF EXISTS "observation_media_project_id_observation_id_idx";
@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "default_llm_models" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"project_id" TEXT NOT NULL,
"llm_api_key_id" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"adapter" TEXT NOT NULL,
"model" TEXT NOT NULL,
"model_params" JSONB,
CONSTRAINT "default_llm_models_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "default_llm_models" ADD CONSTRAINT "default_llm_models_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "default_llm_models" ADD CONSTRAINT "default_llm_models_llm_api_key_id_fkey" FOREIGN KEY ("llm_api_key_id") REFERENCES "llm_api_keys"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddUniqueConstraint
ALTER TABLE "default_llm_models" ADD CONSTRAINT "default_llm_models_project_id_key" UNIQUE ("project_id");
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "eval_templates" ALTER COLUMN "project_id" DROP NOT NULL;
ALTER TABLE "eval_templates" ALTER COLUMN "model" DROP NOT NULL;
ALTER TABLE "eval_templates" ALTER COLUMN "provider" DROP NOT NULL;
ALTER TABLE "eval_templates" ALTER COLUMN "model_params" DROP NOT NULL;
ALTER TABLE "eval_templates" ADD COLUMN "partner" TEXT;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "job_executions" ADD COLUMN "job_template_id" TEXT;
ALTER TABLE "job_executions" ADD CONSTRAINT "job_executions_job_template_id_fkey" FOREIGN KEY ("job_template_id") REFERENCES "eval_templates"("id") ON DELETE SET NULL;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "blob_storage_integrations" ALTER COLUMN "access_key_id" DROP NOT NULL,
ALTER COLUMN "secret_access_key" DROP NOT NULL;
+48 -16
View File
@@ -157,6 +157,7 @@ model Project {
Dashboard Dashboard[]
DashboardWidget DashboardWidget[]
TableViewPreset TableViewPreset[]
DefaultLlmModel DefaultLlmModel[]
@@index([orgId])
@@map("projects")
@@ -222,8 +223,9 @@ model LlmApiKeys {
extraHeaderKeys String[] @default([]) @map("extra_header_keys")
config Json?
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
DefaultLlmModel DefaultLlmModel[] @relation("LlmApiKeyId")
@@unique([projectId, provider])
@@map("llm_api_keys")
@@ -793,18 +795,22 @@ model EvalTemplate {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String? @map("project_id")
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
name String
version Int
prompt String
model String
provider String
modelParams Json @map("model_params")
name String
version Int
prompt String
partner String? // e.g. "ragas", describes the partner that created the template
model String?
provider String?
modelParams Json? @map("model_params")
vars String[] @default([])
outputSchema Json @map("output_schema")
JobConfiguration JobConfiguration[]
JobExecution JobExecution[]
@@unique([projectId, name, version])
@@index([projectId, id])
@@ -864,6 +870,9 @@ model JobExecution {
jobConfigurationId String @map("job_configuration_id")
jobConfiguration JobConfiguration @relation(fields: [jobConfigurationId], references: [id], onDelete: Cascade)
jobTemplateId String? @map("job_template_id")
jobTemplate EvalTemplate? @relation(fields: [jobTemplateId], references: [id], onDelete: SetNull)
status JobExecutionStatus
startTime DateTime? @map("start_time")
endTime DateTime? @map("end_time")
@@ -887,6 +896,26 @@ model JobExecution {
@@map("job_executions")
}
model DefaultLlmModel {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
projectId String @map("project_id")
Project Project @relation(fields: [projectId], references: [id])
llmApiKeyId String @map("llm_api_key_id")
LlmApiKey LlmApiKeys @relation("LlmApiKeyId", fields: [llmApiKeyId], references: [id], onDelete: Cascade)
provider String
adapter String // This controls the interface that is used to connect with the LLM, e.g. 'openai' or 'anthropic'
model String
modelParams Json? @map("model_params")
@@unique([projectId])
@@map("default_llm_models")
}
// Single Sign-On configuration for a domain
// This feature is part of the Enterprise Edition
model SsoConfig {
@@ -922,8 +951,8 @@ model BlobStorageIntegration {
type BlobStorageIntegrationType @map("type")
bucketName String @map("bucket_name")
prefix String @map("prefix")
accessKeyId String @map("access_key_id")
secretAccessKey String @map("secret_access_key")
accessKeyId String? @map("access_key_id")
secretAccessKey String? @map("secret_access_key")
region String @map("region")
endpoint String? @map("endpoint")
forcePathStyle Boolean @map("force_path_style")
@@ -982,7 +1011,7 @@ model BatchExport {
}
model Media {
id String @id @default(cuid())
id String
sha256Hash String @map("sha_256_hash") @db.Char(44)
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@ -998,6 +1027,7 @@ model Media {
TraceMedia TraceMedia[]
ObservationMedia ObservationMedia[]
@@unique([projectId, id])
@@unique([projectId, sha256Hash])
@@map("media")
}
@@ -1009,11 +1039,12 @@ model TraceMedia {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
mediaId String @map("media_id")
media Media @relation(fields: [mediaId], references: [id], onDelete: Cascade)
media Media @relation(fields: [mediaId, projectId], references: [id, projectId], onDelete: Cascade)
traceId String @map("trace_id")
field String @map("field")
@@unique([projectId, traceId, mediaId, field])
@@index([projectId, mediaId])
@@map("trace_media")
}
@@ -1024,13 +1055,13 @@ model ObservationMedia {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
mediaId String @map("media_id")
media Media @relation(fields: [mediaId], references: [id], onDelete: Cascade)
media Media @relation(fields: [mediaId, projectId], references: [id, projectId], onDelete: Cascade)
traceId String @map("trace_id")
observationId String @map("observation_id")
field String @map("field")
@@unique([projectId, traceId, observationId, mediaId, field])
@@index([projectId, observationId])
@@index([projectId, mediaId])
@@map("observation_media")
}
@@ -1124,6 +1155,7 @@ enum DashboardWidgetChartType {
HORIZONTAL_BAR
VERTICAL_BAR
PIE
NUMBER
}
model DashboardWidget {
+2 -2
View File
@@ -496,7 +496,7 @@ async function createDashboardsAndWidgets(projects: Project[]) {
x: 0,
y: 0,
x_size: 6,
y_size: 2,
y_size: 6,
},
{
type: "widget",
@@ -505,7 +505,7 @@ async function createDashboardsAndWidgets(projects: Project[]) {
x: 6,
y: 0,
x_size: 6,
y_size: 2,
y_size: 6,
},
],
},
+15
View File
@@ -38,6 +38,8 @@ const EnvSchema = z.object({
CLICKHOUSE_DB: z.string().default("default"),
CLICKHOUSE_USER: z.string(),
CLICKHOUSE_PASSWORD: z.string(),
CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL: z.coerce.number().int().default(9000),
CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(25),
LANGFUSE_INGESTION_QUEUE_DELAY_MS: z.coerce
.number()
@@ -48,6 +50,12 @@ const EnvSchema = z.object({
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
.optional(),
LANGFUSE_LOG_FORMAT: z.enum(["text", "json"]).default("text"),
LANGFUSE_LOG_PROPAGATED_HEADERS: z
.string()
.optional()
.transform((s) =>
s ? s.split(",").map((s) => s.toLowerCase().trim()) : [],
),
ENABLE_AWS_CLOUDWATCH_METRIC_PUBLISHING: z
.enum(["true", "false"])
.default("false"),
@@ -63,6 +71,8 @@ const EnvSchema = z.object({
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: z
.enum(["true", "false"])
.default("false"),
LANGFUSE_S3_EVENT_UPLOAD_SSE: z.enum(["AES256", "aws:kms"]).optional(),
LANGFUSE_S3_EVENT_UPLOAD_SSE_KMS_KEY_ID: z.string().optional(),
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: z.string().optional(),
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: z.string().default(""),
LANGFUSE_S3_MEDIA_UPLOAD_REGION: z.string().optional(),
@@ -72,6 +82,8 @@ const EnvSchema = z.object({
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: z
.enum(["true", "false"])
.default("false"),
LANGFUSE_S3_MEDIA_UPLOAD_SSE: z.enum(["AES256", "aws:kms"]).optional(),
LANGFUSE_S3_MEDIA_UPLOAD_SSE_KMS_KEY_ID: z.string().optional(),
LANGFUSE_USE_AZURE_BLOB: z.enum(["true", "false"]).default("false"),
LANGFUSE_USE_GOOGLE_CLOUD_STORAGE: z.enum(["true", "false"]).default("false"),
LANGFUSE_GOOGLE_CLOUD_STORAGE_CREDENTIALS: z.string().optional(),
@@ -80,6 +92,8 @@ const EnvSchema = z.object({
LANGFUSE_S3_CORE_DATA_EXPORT_IS_ENABLED: z
.enum(["true", "false"])
.default("false"),
LANGFUSE_S3_CORE_DATA_EXPORT_SSE: z.enum(["AES256", "aws:kms"]).optional(),
LANGFUSE_S3_CORE_DATA_EXPORT_SSE_KMS_KEY_ID: z.string().optional(),
LANGFUSE_POSTGRES_METERING_DATA_EXPORT_IS_ENABLED: z
.enum(["true", "false"])
.default("false"),
@@ -90,6 +104,7 @@ const EnvSchema = z.object({
LANGFUSE_API_TRACE_OBSERVATIONS_SIZE_LIMIT_BYTES: z.coerce
.number()
.default(80e6), // 80MB
LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS: z.coerce.number().default(240_000), // 4 minutes
});
export const env: z.infer<typeof EnvSchema> =
@@ -1,7 +1,7 @@
import z from "zod";
import { singleFilter } from "../../interfaces/filters";
import { orderBy } from "../../interfaces/orderBy";
import { BatchExportTableName } from "../batchExport/types";
import { BatchTableNames } from "../../interfaces/tableNames";
/* eslint-disable no-unused-vars */
export enum BatchActionType {
@@ -29,11 +29,11 @@ export const CreateBatchActionSchema = z.object({
actionId: ActionIdSchema,
targetId: z.string().optional(),
query: BatchActionQuerySchema,
tableName: z.nativeEnum(BatchExportTableName),
tableName: z.nativeEnum(BatchTableNames),
});
export const GetIsBatchActionInProgressSchema = z.object({
projectId: z.string(),
actionId: ActionIdSchema,
tableName: z.nativeEnum(BatchExportTableName),
tableName: z.nativeEnum(BatchTableNames),
});
@@ -4,6 +4,7 @@ import { BatchExport } from "@prisma/client";
import { singleFilter } from "../../interfaces/filters";
import { orderBy } from "../../interfaces/orderBy";
import { BatchTableNames } from "../../interfaces/tableNames";
export enum BatchExportStatus {
QUEUED = "QUEUED",
@@ -18,13 +19,9 @@ export enum BatchExportFileFormat {
JSONL = "JSONL",
}
export enum BatchExportTableName {
Scores = "scores",
Sessions = "sessions",
Traces = "traces",
Observations = "observations",
DatasetRunItems = "dataset_run_items",
}
// Use shared BatchTableNames enum for consistency across batch operations
// Keep BatchExportTableName as alias for backward compatibility
export { BatchTableNames as BatchExportTableName };
export const exportOptions: Record<
BatchExportFileFormat,
@@ -44,7 +41,7 @@ export const exportOptions: Record<
} as const;
export const BatchExportQuerySchema = z.object({
tableName: z.nativeEnum(BatchExportTableName),
tableName: z.nativeEnum(BatchTableNames),
filter: z.array(singleFilter).nullable(),
orderBy,
limit: z.number().optional(),
@@ -14,7 +14,6 @@ export const planLabels = {
"cloud:pro": "Pro",
"cloud:team": "Team",
"cloud:enterprise": "Enterprise",
"self-hosted:pro": "Pro (self-hosted)",
"self-hosted:enterprise": "Enterprise (self-hosted)",
} as const;
+5 -2
View File
@@ -8,6 +8,9 @@ export const langfuseObjects = [
"dataset_item",
] as const;
const langfuseObject = z.enum(langfuseObjects);
export type LangfuseEvaluationObject = z.infer<typeof langfuseObject>;
// variable mapping stored in the db for eval templates
export const variableMapping = z
.object({
@@ -15,7 +18,7 @@ export const variableMapping = z
// name of the observation to extract the variable from
// not required for trace, as we only have one.
objectName: z.string().nullish(),
langfuseObject: z.enum(langfuseObjects),
langfuseObject: langfuseObject,
selectedColumnId: z.string(),
jsonSelector: z.string().nullish(),
})
@@ -31,7 +34,7 @@ export const variableMappingList = z.array(variableMapping);
export const wipVariableMapping = z.object({
templateVariable: z.string(),
objectName: z.string().nullish(),
langfuseObject: z.enum(langfuseObjects),
langfuseObject: langfuseObject,
selectedColumnId: z.string().nullish(),
jsonSelector: z.string().nullish(),
});
@@ -0,0 +1,66 @@
import z from "zod";
import { variableMapping } from "./types";
/**
* Parses an unknown value to a string representation
* This is used for any evaluation variable that needs string conversion
*/
export const parseUnknownToString = (value: unknown): string => {
if (value === null || value === undefined) {
return "";
}
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return value.toString();
}
if (typeof value === "object") {
return JSON.stringify(value);
}
if (typeof value === "symbol") {
return value.toString();
}
return String(value);
};
function parseJsonDefault(selectedColumn: unknown, jsonSelector: string) {
// Front-end friendly JSON path extraction
const parsedJson =
typeof selectedColumn === "string"
? JSON.parse(selectedColumn)
: selectedColumn;
// Simple path extraction (could use a library)
return jsonSelector
.split(".")
.reduce((o, key) => (o as any)?.[key], parsedJson);
}
export function extractValueFromObject(
obj: Record<string, unknown>,
mapping: z.infer<typeof variableMapping>,
parseJson?: (selectedColumn: unknown, jsonSelector: string) => unknown,
): string {
const selectedColumn = obj[mapping.selectedColumnId];
const jsonParser = parseJson || parseJsonDefault;
let jsonSelectedColumn;
if (mapping.jsonSelector && selectedColumn) {
try {
jsonSelectedColumn = jsonParser(selectedColumn, mapping.jsonSelector);
} catch (error) {
console.error(
`Error parsing JSON selector: ${mapping.jsonSelector}`,
error,
);
jsonSelectedColumn = selectedColumn;
}
} else {
jsonSelectedColumn = selectedColumn;
}
return parseUnknownToString(jsonSelectedColumn);
}
+2 -1
View File
@@ -22,10 +22,11 @@ export * from "./server/llm/types";
// evals
export * from "./features/evals/types";
export * from "./features/evals/utilities";
// table actions
export * from "./features/batchExport/types";
export * from "./features/batchAction/types";
export { BatchTableNames } from "./interfaces/tableNames";
// annotation
export * from "./features/annotation/types";
@@ -5,8 +5,10 @@ export const RateLimitResource = z.enum([
"ingestion",
"public-api",
"public-api-metrics",
"public-api-daily-metrics-legacy",
"prompts",
"legacy-ingestion",
"datasets",
]);
// result of the rate limit check.
@@ -0,0 +1,12 @@
/**
* Shared table names used across batch operations (exports, actions, etc.)
* This enum provides a centralized definition of database table names
* to avoid coupling between different batch operation types.
*/
export enum BatchTableNames {
Scores = "scores",
Sessions = "sessions",
Traces = "traces",
Observations = "observations",
DatasetRunItems = "dataset_run_items",
}
+1
View File
@@ -55,4 +55,5 @@ export type ApiAccessScope = {
plan: Plan;
rateLimitOverrides: z.infer<typeof CloudConfigRateLimit>;
apiKeyId: string;
publicKey: string;
};
@@ -45,9 +45,9 @@ export class ClickHouseClientManager {
database: env.CLICKHOUSE_DB,
http_headers: opts?.http_headers,
settings: opts?.clickhouse_settings,
request_timeout: opts?.request_timeout ?? 30000,
// Include any other relevant config options
};
return JSON.stringify(keyParams);
}
@@ -60,7 +60,6 @@ export class ClickHouseClientManager {
opts: NodeClickHouseClientConfigOptions,
): ClickhouseClientType {
const key = this.generateClientSettingsKey(opts);
if (!this.clientMap.has(key)) {
const headers = opts?.http_headers ?? {};
const activeSpan = getCurrentSpan();
@@ -84,12 +83,25 @@ export class ClickHouseClientManager {
password: env.CLICKHOUSE_PASSWORD,
database: env.CLICKHOUSE_DB,
http_headers: headers,
keep_alive: {
idle_socket_ttl: env.CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL,
},
max_open_connections: env.CLICKHOUSE_MAX_OPEN_CONNECTIONS,
clickhouse_settings: {
...cloudOptions,
...opts.clickhouse_settings,
async_insert: 1,
wait_for_async_insert: 1, // if disabled, we won't get errors from clickhouse
...(opts.request_timeout && opts.request_timeout > 30000
? {
send_progress_in_http_headers: 1,
http_headers_progress_interval_ms: "25000", // UInt64, should be passed as a string
}
: {}),
},
...(opts.request_timeout
? { request_timeout: opts.request_timeout }
: {}),
});
this.clientMap.set(key, client);
@@ -0,0 +1,44 @@
import * as opentelemetry from "@opentelemetry/api";
import type { IncomingHttpHeaders } from "http";
import { env } from "../env";
export type LangfuseContextProps = {
headers?: IncomingHttpHeaders;
userId?: string;
projectId?: string;
};
/**
* Returns a new context containing baggage entries composed from
* the supplied props (headers, userId, projectId). Existing baggage
* entries are preserved.
*/
export const contextWithLangfuseProps = (
props: LangfuseContextProps,
): opentelemetry.Context => {
const ctx = opentelemetry.context.active();
let baggage =
opentelemetry.propagation.getBaggage(ctx) ??
opentelemetry.propagation.createBaggage();
if (props.headers) {
(env.LANGFUSE_LOG_PROPAGATED_HEADERS as string[]).forEach((name) => {
const value = props.headers![name];
if (!value) return;
const strValue = Array.isArray(value) ? JSON.stringify(value) : value;
baggage = baggage.setEntry(`langfuse.header.${name}`, {
value: strValue,
});
});
}
if (props.userId) {
baggage = baggage.setEntry("langfuse.user.id", { value: props.userId });
}
if (props.projectId) {
baggage = baggage.setEntry("langfuse.project.id", {
value: props.projectId,
});
}
return opentelemetry.propagation.setBaggage(ctx, baggage);
};
+3 -1
View File
@@ -41,13 +41,14 @@ export * from "./redis/dataRetentionProcessingQueue";
export * from "./redis/coreDataS3ExportQueue";
export * from "./redis/meteringDataPostgresExportQueue";
export * from "./redis/experimentCreateQueue";
export * from "./redis/dlxRetryQueue";
export * from "./redis/dlqRetryQueue";
export * from "./auth/types";
export * from "./queues";
export * from "./orderByToPrisma";
export * from "./filterToPrisma";
export * from "./instrumentation";
export * from "./logger";
export * from "./headerPropagation";
export * from "./queries";
export * from "./repositories";
export * from "./redis/evalExecutionQueue";
@@ -55,6 +56,7 @@ export * from "./services/sessions-ui-table-service";
export * from "./services/datasets-ui-table-service";
export * from "./services/DashboardService";
export * from "./services/TableViewService";
export * from "./services/DefaultEvaluationModelService";
export * from "./data-deletion/ingestionFileDeletion";
export * from "./s3";
@@ -13,7 +13,6 @@ import { getClickhouseEntityType } from "../clickhouse/schemaUtils";
import {
getCurrentSpan,
instrumentAsync,
instrumentSync,
recordDistribution,
recordIncrement,
traceException,
@@ -39,6 +38,8 @@ const getS3StorageServiceClient = (bucketName: string): StorageService => {
endpoint: env.LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT,
region: env.LANGFUSE_S3_EVENT_UPLOAD_REGION,
forcePathStyle: env.LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE === "true",
awsSse: env.LANGFUSE_S3_EVENT_UPLOAD_SSE,
awsSseKmsKeyId: env.LANGFUSE_S3_EVENT_UPLOAD_SSE_KMS_KEY_ID,
});
}
return s3StorageServiceClient;
@@ -78,11 +79,13 @@ const getDelay = (delay: number | null) => {
* @param input - Batch of IngestionEventType. Will validate the types first thing and return errors if they are invalid.
* @param authCheck - AuthHeaderValidVerificationResult
* @param delay - (Optional) Delay in ms to wait before processing events in the batch.
* @param source - (Optional) Source of the events for metrics tracking (e.g., "otel", "api").
*/
export const processEventBatch = async (
input: unknown[],
authCheck: AuthHeaderValidVerificationResult,
delay: number | null = null,
source: "api" | "otel" = "api",
): Promise<{
successes: { id: string; status: number }[];
errors: {
@@ -94,8 +97,10 @@ export const processEventBatch = async (
}> => {
// add context of api call to the span
const currentSpan = getCurrentSpan();
recordIncrement("langfuse.ingestion.event", input.length);
recordDistribution("langfuse.ingestion.event_distribution", input.length);
recordIncrement("langfuse.ingestion.event", input.length, { source });
recordDistribution("langfuse.ingestion.event_distribution", input.length, {
source,
});
currentSpan?.setAttribute("langfuse.ingestion.batch_size", input.length);
currentSpan?.setAttribute(
@@ -117,19 +122,7 @@ export const processEventBatch = async (
const batch: z.infer<typeof ingestionEvent>[] = input
.flatMap((event) => {
const parsed = instrumentSync(
{ name: "ingestion-zod-parse-individual-event" },
(span) => {
const parsedBody = ingestionEvent.safeParse(event);
if (parsedBody.data?.id !== undefined) {
span.setAttribute(
"langfuse.ingestion.entity.id",
parsedBody.data.id,
);
}
return parsedBody;
},
);
const parsed = ingestionEvent.safeParse(event);
if (!parsed.success) {
validationErrors.push({
id:
@@ -251,6 +244,11 @@ export const processEventBatch = async (
type: sortedBatchByEventBodyId[id].type,
eventBodyId: sortedBatchByEventBodyId[id].eventBodyId,
fileKey: sortedBatchByEventBodyId[id].key,
skipS3List:
source === "otel" &&
getClickhouseEntityType(
sortedBatchByEventBodyId[id].type,
) === "observation",
},
authCheck: authCheck as {
validKey: true;
@@ -43,6 +43,14 @@ export async function instrumentAsync<T>(
},
activeContext,
async (span) => {
const baggage = opentelemetry.propagation.getBaggage(
opentelemetry.context.active(),
);
if (baggage) {
baggage
.getAllEntries()
.forEach(([k, v]) => span.setAttribute(k, v.value));
}
try {
const result = await callback(span);
span.end();
@@ -77,6 +85,14 @@ export function instrumentSync<T>(
},
activeContext,
(span) => {
const baggage = opentelemetry.propagation.getBaggage(
opentelemetry.context.active(),
);
if (baggage) {
baggage
.getAllEntries()
.forEach(([k, v]) => span.setAttribute(k, v.value));
}
try {
const result = callback(span);
span.end();
@@ -141,30 +157,6 @@ export const traceException = (
});
};
export const addUserToSpan = (
attributes: {
userId?: string;
projectId?: string;
email?: string;
orgId?: string;
plan?: string;
},
span?: opentelemetry.Span,
) => {
const activeSpan = span ?? getCurrentSpan();
if (!activeSpan) {
return;
}
attributes.userId && activeSpan.setAttribute("user.id", attributes.userId);
attributes.email && activeSpan.setAttribute("user.email", attributes.email);
attributes.projectId &&
activeSpan.setAttribute("project.id", attributes.projectId);
attributes.orgId && activeSpan.setAttribute("org.id", attributes.orgId);
attributes.plan && activeSpan.setAttribute("org.plan", attributes.plan);
};
export const getTracer = (name: string) => opentelemetry.trace.getTracer(name);
const cloudWatchClient = new CloudWatchClient();
+13 -3
View File
@@ -71,7 +71,13 @@ export const OpenAIToolCallSchema = z.object({
z.record(z.string(), z.unknown()),
z
.string()
.transform((v) => JSON.parse(v))
.transform((v) => {
try {
return JSON.parse(v);
} catch {
return v;
}
})
.pipe(z.record(z.string(), z.unknown())),
]),
}),
@@ -297,6 +303,8 @@ export type OpenAIModel = (typeof openAIModels)[number];
// NOTE: Update docs page when changing this! https://langfuse.com/docs/playground#openai-playground--anthropic-playground
// WARNING: The first entry in the array is chosen as the default model to add LLM API keys
export const anthropicModels = [
"claude-sonnet-4-20250514",
"claude-opus-4-20250514",
"claude-3-7-sonnet-20250219",
"claude-3-5-sonnet-20241022",
"claude-3-5-sonnet-20240620",
@@ -311,7 +319,8 @@ export const anthropicModels = [
// WARNING: The first entry in the array is chosen as the default model to add LLM API keys
export const vertexAIModels = [
"gemini-2.5-pro-exp-03-25",
"gemini-2.5-pro-preview-05-06",
"gemini-2.5-flash-preview-05-20",
"gemini-2.0-pro-exp-02-05",
"gemini-2.0-flash",
"gemini-2.0-flash-001",
@@ -324,7 +333,8 @@ export const vertexAIModels = [
// WARNING: The first entry in the array is chosen as the default model to add LLM API keys. Make sure it supports top_p, max_tokens and temperature.
export const googleAIStudioModels = [
"gemini-2.5-pro-exp-03-25",
"gemini-2.5-pro-preview-05-06",
"gemini-2.5-flash-preview-05-20",
"gemini-2.0-flash",
"gemini-2.0-flash-lite-preview",
"gemini-2.0-flash-lite-preview-02-05",
+7
View File
@@ -1,6 +1,7 @@
import { env } from "../env";
import winston from "winston";
import { getCurrentSpan } from "./instrumentation";
import { propagation, context } from "@opentelemetry/api";
const tracingFormat = function () {
return winston.format((info) => {
@@ -13,6 +14,12 @@ const tracingFormat = function () {
info["trace_id"] = traceId;
info["span_id"] = spanId;
}
const baggage = propagation.getBaggage(context.active());
if (baggage) {
const headerObj: Record<string, string> = {};
baggage.getAllEntries().forEach(([k, v]) => (headerObj[k] = v.value));
if (Object.keys(headerObj).length) info = { ...headerObj, ...info };
}
return info;
})();
};
+6 -4
View File
@@ -5,13 +5,14 @@ import {
BatchActionQuerySchema,
BatchActionType,
} from "../features/batchAction/types";
import { BatchExportTableName } from "../features/batchExport/types";
import { BatchTableNames } from "../interfaces/tableNames";
export const IngestionEvent = z.object({
data: z.object({
type: z.nativeEnum(eventTypes),
eventBodyId: z.string(),
fileKey: z.string().optional(),
skipS3List: z.boolean().optional(),
}),
authCheck: z.object({
validKey: z.literal(true),
@@ -75,7 +76,7 @@ export const BatchActionProcessingEventSchema = z.discriminatedUnion(
actionId: z.literal("score-delete"),
projectId: z.string(),
query: BatchActionQuerySchema,
tableName: z.nativeEnum(BatchExportTableName),
tableName: z.nativeEnum(BatchTableNames),
cutoffCreatedAt: z.date(),
targetId: z.string().optional(),
type: z.nativeEnum(BatchActionType),
@@ -84,7 +85,7 @@ export const BatchActionProcessingEventSchema = z.discriminatedUnion(
actionId: z.literal("trace-delete"),
projectId: z.string(),
query: BatchActionQuerySchema,
tableName: z.nativeEnum(BatchExportTableName),
tableName: z.nativeEnum(BatchTableNames),
cutoffCreatedAt: z.date(),
targetId: z.string().optional(),
type: z.nativeEnum(BatchActionType),
@@ -93,7 +94,7 @@ export const BatchActionProcessingEventSchema = z.discriminatedUnion(
actionId: z.literal("trace-add-to-annotation-queue"),
projectId: z.string(),
query: BatchActionQuerySchema,
tableName: z.nativeEnum(BatchExportTableName),
tableName: z.nativeEnum(BatchTableNames),
cutoffCreatedAt: z.date(),
targetId: z.string().optional(),
type: z.nativeEnum(BatchActionType),
@@ -119,6 +120,7 @@ export const CreateEvalQueueEventSchema = DatasetRunItemUpsertEventSchema.and(
z.object({
timestamp: z.date(),
configId: z.string(),
exactTimestamp: z.date().optional(),
}),
),
);
@@ -26,7 +26,7 @@ export class BatchActionQueue {
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 10_000,
attempts: 2,
attempts: 10,
backoff: {
type: "exponential",
delay: 5000,
+1 -1
View File
@@ -20,7 +20,7 @@ import { DataRetentionProcessingQueue } from "./dataRetentionProcessingQueue";
import { BatchActionQueue } from "./batchActionQueue";
import { CreateEvalQueue } from "./createEvalQueue";
import { ScoreDeleteQueue } from "./scoreDelete";
import { DeadLetterRetryQueue } from "./dlxRetryQueue";
import { DeadLetterRetryQueue } from "./dlqRetryQueue";
export function getQueue(queueName: QueueName): Queue | null {
switch (queueName) {
+14 -5
View File
@@ -1,5 +1,5 @@
import Redis, { RedisOptions } from "ioredis";
import fs from 'fs';
import fs from "fs";
import { env } from "../../env";
import { logger } from "../logger";
@@ -10,8 +10,11 @@ const defaultRedisOptions: Partial<RedisOptions> = {
export const redisQueueRetryOptions: Partial<RedisOptions> = {
retryStrategy: (times: number) => {
if (times >= 5) {
// A few retries are expected and no cause for action.
logger.warn(`Connection to redis lost. Retry attempt: ${times}`);
}
// Retries forever. Waits at least 1s and at most 20s between retries.
logger.warn(`Connection to redis lost. Retry attempt: ${times}`);
return Math.max(Math.min(Math.exp(times), 20000), 1000);
},
reconnectOnError: (err) => {
@@ -29,9 +32,15 @@ export const createNewRedisInstance = (
const tlsOptions = tlsEnabled
? {
tls: {
ca: env.REDIS_TLS_CA_PATH ? fs.readFileSync(env.REDIS_TLS_CA_PATH) : undefined,
cert: env.REDIS_TLS_CERT_PATH ? fs.readFileSync(env.REDIS_TLS_CERT_PATH) : undefined,
key: env.REDIS_TLS_KEY_PATH ? fs.readFileSync(env.REDIS_TLS_KEY_PATH) : undefined,
ca: env.REDIS_TLS_CA_PATH
? fs.readFileSync(env.REDIS_TLS_CA_PATH)
: undefined,
cert: env.REDIS_TLS_CERT_PATH
? fs.readFileSync(env.REDIS_TLS_CERT_PATH)
: undefined,
key: env.REDIS_TLS_KEY_PATH
? fs.readFileSync(env.REDIS_TLS_KEY_PATH)
: undefined,
},
}
: {};
@@ -25,6 +25,8 @@ const getS3StorageServiceClient = (bucketName: string): StorageService => {
endpoint: env.LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT,
region: env.LANGFUSE_S3_EVENT_UPLOAD_REGION,
forcePathStyle: env.LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE === "true",
awsSse: env.LANGFUSE_S3_EVENT_UPLOAD_SSE,
awsSseKmsKeyId: env.LANGFUSE_S3_EVENT_UPLOAD_SSE_KMS_KEY_ID,
});
}
return s3StorageServiceClient;
@@ -12,7 +12,7 @@ import {
SCORE_TO_TRACE_OBSERVATIONS_INTERVAL,
} from "./constants";
export type DateTrunc = "year" | "month" | "week" | "day" | "hour" | "minute";
export type DateTrunc = "month" | "week" | "day" | "hour" | "minute";
const extractEnvironmentFilterFromFilters = (
filter: FilterState,
@@ -34,6 +34,8 @@ import {
import { env } from "../../env";
import { TracingSearchType } from "../../interfaces/search";
import { ClickHouseClientConfigOptions } from "@clickhouse/client";
import { ObservationType } from "../../domain";
import { recordDistribution } from "../instrumentation";
/**
* Checks if observation exists in clickhouse.
@@ -207,9 +209,20 @@ export const getObservationsForTrace = async <IncludeIO extends boolean>(
}
}
return records.map((r) =>
convertObservation({ ...r, metadata: r.metadata ?? {} }),
);
return records.map((r) => {
const observation = convertObservation({
...r,
metadata: r.metadata ?? {},
});
recordDistribution(
"langfuse.query_by_id_age",
new Date().getTime() - observation.startTime.getTime(),
{
table: "observations",
},
);
return observation;
});
};
export const getObservationForTraceIdByName = async (
@@ -278,20 +291,40 @@ export const getObservationForTraceIdByName = async (
return records.map(convertObservation);
};
export const getObservationById = async (
id: string,
projectId: string,
fetchWithInputOutput: boolean = false,
startTime?: Date,
) => {
const records = await getObservationByIdInternal(
export const getObservationById = async ({
id,
projectId,
fetchWithInputOutput = false,
startTime,
type,
traceId,
}: {
id: string;
projectId: string;
fetchWithInputOutput?: boolean;
startTime?: Date;
type?: ObservationType;
traceId?: string;
}) => {
const records = await getObservationByIdInternal({
id,
projectId,
fetchWithInputOutput,
startTime,
);
type,
traceId,
});
const mapped = records.map(convertObservation);
mapped.forEach((observation) => {
recordDistribution(
"langfuse.query_by_id_age",
new Date().getTime() - observation.startTime.getTime(),
{
table: "observations",
},
);
});
if (mapped.length === 0) {
throw new LangfuseNotFoundError(`Observation with id ${id} not found`);
}
@@ -354,12 +387,21 @@ export const getObservationsById = async (
return records.map(convertObservation);
};
const getObservationByIdInternal = async (
id: string,
projectId: string,
fetchWithInputOutput: boolean = false,
startTime?: Date,
) => {
const getObservationByIdInternal = async ({
id,
projectId,
fetchWithInputOutput = false,
startTime,
type,
traceId,
}: {
id: string;
projectId: string;
fetchWithInputOutput?: boolean;
startTime?: Date;
type?: ObservationType;
traceId?: string;
}) => {
const query = `
SELECT
id,
@@ -395,6 +437,8 @@ const getObservationByIdInternal = async (
WHERE id = {id: String}
AND project_id = {projectId: String}
${startTime ? `AND toDate(start_time) = toDate({startTime: DateTime64(3)})` : ""}
${type ? `AND type = {type: String}` : ""}
${traceId ? `AND trace_id = {traceId: String}` : ""}
ORDER BY event_ts desc
LIMIT 1 by id, project_id`;
return await queryClickhouse<ObservationRecordReadType>({
@@ -405,6 +449,7 @@ const getObservationByIdInternal = async (
...(startTime
? { startTime: convertDateToClickhouseDateTime(startTime) }
: {}),
...(traceId ? { traceId } : {}),
},
tags: {
feature: "tracing",
@@ -1022,7 +1067,7 @@ export const deleteObservationsByTraceIds = async (
traceIds,
},
clickhouseConfigs: {
request_timeout: 120_000, // 2 minutes
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: {
feature: "tracing",
@@ -1044,7 +1089,7 @@ export const deleteObservationsByProjectId = async (projectId: string) => {
projectId,
},
clickhouseConfigs: {
request_timeout: 120_000, // 2 minutes
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: {
feature: "tracing",
@@ -1071,7 +1116,7 @@ export const deleteObservationsOlderThanDays = async (
cutoffDate: convertDateToClickhouseDateTime(beforeDate),
},
clickhouseConfigs: {
request_timeout: 120_000, // 2 minutes
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: {
feature: "tracing",
@@ -30,6 +30,7 @@ import { env } from "../../env";
import { _handleGetScoreById, _handleGetScoresByIds } from "./scores-utils";
import { parseMetadataCHRecordToDomain } from "../utils/metadata_conversion";
import { ClickHouseClientConfigOptions } from "@clickhouse/client";
import { recordDistribution } from "../instrumentation";
export const searchExistingAnnotationScore = async (
projectId: string,
@@ -348,6 +349,14 @@ export const getScoresForTraces = async <
metadata: excludeMetadata ? {} : row.metadata,
});
recordDistribution(
"langfuse.query_by_id_age",
new Date().getTime() - score.timestamp.getTime(),
{
table: "scores",
},
);
if (includeHasMetadata) {
Object.assign(score, { hasMetadata: !!row.has_metadata });
}
@@ -922,6 +931,9 @@ export const deleteScores = async (projectId: string, scoreIds: string[]) => {
projectId,
scoreIds,
},
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: {
feature: "tracing",
type: "score",
@@ -969,7 +981,7 @@ export const deleteScoresByProjectId = async (projectId: string) => {
projectId,
},
clickhouseConfigs: {
request_timeout: 120_000, // 2 minutes
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: {
feature: "tracing",
@@ -996,7 +1008,7 @@ export const deleteScoresOlderThanDays = async (
cutoffDate: convertDateToClickhouseDateTime(beforeDate),
},
clickhouseConfigs: {
request_timeout: 120_000, // 2 minutes
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: {
feature: "tracing",
@@ -27,6 +27,7 @@ import {
} from "./constants";
import { env } from "../../env";
import { ClickHouseClientConfigOptions } from "@clickhouse/client";
import { recordDistribution } from "../instrumentation";
/**
* Checks if trace exists in clickhouse.
@@ -41,12 +42,21 @@ import { ClickHouseClientConfigOptions } from "@clickhouse/client";
* • Filters within ±2 day window
* • Used for validating trace references before eval job creation
*/
export const checkTraceExists = async (
projectId: string,
traceId: string,
timestamp: Date,
filter: FilterState,
): Promise<boolean> => {
export const checkTraceExists = async ({
projectId,
traceId,
timestamp,
filter,
maxTimeStamp,
exactTimestamp,
}: {
projectId: string;
traceId: string;
timestamp: Date;
filter: FilterState;
maxTimeStamp: Date | undefined;
exactTimestamp?: Date;
}): Promise<boolean> => {
const { tracesFilter } = getProjectIdDefaultFilter(projectId, {
tracesPrefix: "t",
});
@@ -101,7 +111,9 @@ export const checkTraceExists = async (
WHERE ${tracesFilterRes.query}
AND t.project_id = {projectId: String}
AND timestamp >= {timestamp: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}
AND timestamp <= {timestamp: DateTime64(3)} + INTERVAL 2 DAY
${maxTimeStamp ? `AND timestamp <= {maxTimeStamp: DateTime64(3)}` : ""}
${!maxTimeStamp ? `AND timestamp <= {timestamp: DateTime64(3)} + INTERVAL 2 DAY` : ""}
${exactTimestamp ? `AND timestamp = {exactTimestamp: DateTime64(3)}` : ""}
GROUP BY t.id, t.project_id
`;
@@ -114,6 +126,12 @@ export const checkTraceExists = async (
...(timestamp
? { timestamp: convertDateToClickhouseDateTime(timestamp) }
: {}),
...(maxTimeStamp
? { maxTimeStamp: convertDateToClickhouseDateTime(maxTimeStamp) }
: {}),
...(exactTimestamp
? { exactTimestamp: convertDateToClickhouseDateTime(exactTimestamp) }
: {}),
},
tags: {
feature: "tracing",
@@ -208,7 +226,16 @@ export const getTracesBySessionId = async (
},
});
return records.map(convertClickhouseToDomain);
const traces = records.map(convertClickhouseToDomain);
traces.forEach((trace) => {
recordDistribution(
"langfuse.traces_by_session_id_age",
new Date().getTime() - trace.timestamp.getTime(),
);
});
return traces;
};
export const hasAnyTrace = async (projectId: string) => {
@@ -353,6 +380,16 @@ export const getTraceById = async ({
const res = records.map(convertClickhouseToDomain);
res.forEach((trace) => {
recordDistribution(
"langfuse.query_by_id_age",
new Date().getTime() - trace.timestamp.getTime(),
{
table: "traces",
},
);
});
return res.shift();
};
@@ -570,7 +607,7 @@ export const deleteTraces = async (projectId: string, traceIds: string[]) => {
traceIds,
},
clickhouseConfigs: {
request_timeout: 120_000, // 2 minutes
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: {
feature: "tracing",
@@ -597,7 +634,7 @@ export const deleteTracesOlderThanDays = async (
cutoffDate: convertDateToClickhouseDateTime(beforeDate),
},
clickhouseConfigs: {
request_timeout: 120_000, // 2 minutes
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: {
feature: "tracing",
@@ -619,7 +656,7 @@ export const deleteTracesByProjectId = async (projectId: string) => {
projectId,
},
clickhouseConfigs: {
request_timeout: 120_000, // 2 minutes
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: {
feature: "tracing",
@@ -1036,3 +1073,37 @@ export const traceWithSessionIdExists = async (
return result.length > 0;
};
export async function getAgentGraphData(params: {
projectId: string;
traceId: string;
chMinStartTime: string;
chMaxStartTime: string;
}) {
const { projectId, traceId, chMinStartTime, chMaxStartTime } = params;
const query = `
SELECT
id,
parent_observation_id,
metadata['langgraph_node'] AS node,
metadata['langgraph_step'] AS step
FROM
observations
WHERE
project_id = {projectId: String}
AND trace_id = {traceId: String}
AND start_time >= {chMinStartTime: DateTime64(3)}
AND start_time <= {chMaxStartTime: DateTime64(3)}
`;
return queryClickhouse({
query,
params: {
traceId,
projectId,
chMinStartTime,
chMaxStartTime,
},
});
}
+4
View File
@@ -16,6 +16,8 @@ export const getS3MediaStorageClient = (bucketName: string): StorageService => {
endpoint: env.LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT,
region: env.LANGFUSE_S3_MEDIA_UPLOAD_REGION,
forcePathStyle: env.LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE === "true",
awsSse: env.LANGFUSE_S3_MEDIA_UPLOAD_SSE,
awsSseKmsKeyId: env.LANGFUSE_S3_MEDIA_UPLOAD_SSE_KMS_KEY_ID,
});
}
return s3MediaStorageClient;
@@ -30,6 +32,8 @@ export const getS3EventStorageClient = (bucketName: string): StorageService => {
endpoint: env.LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT,
region: env.LANGFUSE_S3_EVENT_UPLOAD_REGION,
forcePathStyle: env.LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE === "true",
awsSse: env.LANGFUSE_S3_EVENT_UPLOAD_SSE,
awsSseKmsKeyId: env.LANGFUSE_S3_EVENT_UPLOAD_SSE_KMS_KEY_ID,
});
}
return s3EventStorageClient;
@@ -1,5 +1,9 @@
import { prisma } from "../../../db";
import { LangfuseConflictError, type OrderByState } from "../../../";
import {
LangfuseConflictError,
LangfuseNotFoundError,
type OrderByState,
} from "../../../";
import {
CreateWidgetInput,
WidgetDomain,
@@ -30,7 +34,7 @@ export class DashboardService {
const [dashboards, totalCount] = await Promise.all([
prisma.dashboard.findMany({
where: {
projectId,
OR: [{ projectId }, { projectId: null }],
},
orderBy: orderBy
? [{ [orderBy.column]: orderBy.order.toLowerCase() }]
@@ -40,13 +44,16 @@ export class DashboardService {
}),
prisma.dashboard.count({
where: {
projectId,
OR: [{ projectId }, { projectId: null }],
},
}),
]);
const domainDashboards = dashboards.map((dashboard) =>
DashboardDomainSchema.parse(dashboard),
DashboardDomainSchema.parse({
...dashboard,
owner: dashboard.projectId ? "PROJECT" : "LANGFUSE",
}),
);
return {
@@ -78,7 +85,10 @@ export class DashboardService {
},
});
return DashboardDomainSchema.parse(newDashboard);
return DashboardDomainSchema.parse({
...newDashboard,
owner: newDashboard.projectId ? "PROJECT" : "LANGFUSE",
});
}
/**
@@ -111,7 +121,38 @@ export class DashboardService {
},
});
return DashboardDomainSchema.parse(updatedDashboard);
return DashboardDomainSchema.parse({
...updatedDashboard,
owner: updatedDashboard.projectId ? "PROJECT" : "LANGFUSE",
});
}
/**
* Updates a dashboard's name and description.
*/
public static async updateDashboard(
dashboardId: string,
projectId: string,
name: string,
description: string,
userId?: string,
): Promise<DashboardDomain> {
const updatedDashboard = await prisma.dashboard.update({
where: {
id: dashboardId,
projectId,
},
data: {
name,
description,
updatedBy: userId,
},
});
return DashboardDomainSchema.parse({
...updatedDashboard,
owner: updatedDashboard.projectId ? "PROJECT" : "LANGFUSE",
});
}
/**
@@ -124,7 +165,7 @@ export class DashboardService {
const dashboard = await prisma.dashboard.findFirst({
where: {
id: dashboardId,
projectId,
OR: [{ projectId }, { projectId: null }],
},
});
@@ -132,7 +173,10 @@ export class DashboardService {
return null;
}
return DashboardDomainSchema.parse(dashboard);
return DashboardDomainSchema.parse({
...dashboard,
owner: dashboard.projectId ? "PROJECT" : "LANGFUSE",
});
}
/**
@@ -183,7 +227,10 @@ export class DashboardService {
]);
const domainWidgets = widgets.map((widget) =>
WidgetDomainSchema.parse(widget),
WidgetDomainSchema.parse({
...widget,
owner: widget.projectId ? "PROJECT" : "LANGFUSE",
}),
);
return {
@@ -216,11 +263,14 @@ export class DashboardService {
},
});
return WidgetDomainSchema.parse(newWidget);
return WidgetDomainSchema.parse({
...newWidget,
owner: newWidget.projectId ? "PROJECT" : "LANGFUSE",
});
}
/**
* Gets a dashboard widget by ID.
* Gets a dashboard widget by ID. Look either in the current project or in the Langfuse managed widgets.
*/
public static async getWidget(
widgetId: string,
@@ -229,7 +279,7 @@ export class DashboardService {
const widget = await prisma.dashboardWidget.findFirst({
where: {
id: widgetId,
projectId,
OR: [{ projectId }, { projectId: null }],
},
});
@@ -237,7 +287,10 @@ export class DashboardService {
return null;
}
return WidgetDomainSchema.parse(widget);
return WidgetDomainSchema.parse({
...widget,
owner: widget.projectId ? "PROJECT" : "LANGFUSE",
});
}
/**
@@ -268,20 +321,8 @@ export class DashboardService {
});
return WidgetDomainSchema.parse({
id: widgetId,
projectId,
createdAt: updatedWidget.createdAt,
updatedAt: updatedWidget.updatedAt,
createdBy: updatedWidget.createdBy,
updatedBy: updatedWidget.updatedBy,
name: updatedWidget.name,
description: updatedWidget.description,
view: updatedWidget.view,
dimensions: updatedWidget.dimensions as WidgetDomain["dimensions"],
metrics: updatedWidget.metrics as WidgetDomain["metrics"],
filters: updatedWidget.filters as WidgetDomain["filters"],
chartType: updatedWidget.chartType,
chartConfig: updatedWidget.chartConfig as WidgetDomain["chartConfig"],
...updatedWidget,
owner: updatedWidget.projectId ? "PROJECT" : "LANGFUSE",
});
}
@@ -326,4 +367,80 @@ export class DashboardService {
},
});
}
/**
* Copies a Langfuse-owned widget into the user project, rewires the specified dashboard placement to the new widget and returns the new widget id.
*/
public static async copyWidgetToProject(props: {
sourceWidgetId: string;
projectId: string;
dashboardId: string;
placementId: string;
userId?: string;
}): Promise<string> {
const { sourceWidgetId, projectId, dashboardId, placementId, userId } =
props;
const sourceWidget = await prisma.dashboardWidget.findFirst({
where: {
id: sourceWidgetId,
projectId: null,
},
});
if (!sourceWidget) {
throw new LangfuseNotFoundError(
`Source widget ${sourceWidgetId} not found`,
);
}
// Duplicate widget and update dashboard definition atomically
return prisma.$transaction(async (tx) => {
// 1. create duplicate in project scope
const newWidget = await tx.dashboardWidget.create({
data: {
name: sourceWidget.name,
description: sourceWidget.description,
view: sourceWidget.view,
dimensions: sourceWidget.dimensions ?? [],
metrics: sourceWidget.metrics ?? [],
filters: sourceWidget.filters ?? [],
chartType: sourceWidget.chartType,
chartConfig: sourceWidget.chartConfig ?? {},
projectId, // project owned
createdBy: userId,
updatedBy: userId,
},
});
// 2. fetch dashboard to change reference
const dashboard = await tx.dashboard.findFirst({
where: { id: dashboardId, projectId },
});
if (!dashboard) {
throw new LangfuseNotFoundError(
`Dashboard ${dashboardId} not found in project ${projectId}`,
);
}
const definition = (dashboard.definition ?? {
widgets: [],
}) as z.infer<typeof DashboardDefinitionSchema>;
const updatedWidgets = (definition.widgets || []).map((w: any) =>
w.id === placementId ? { ...w, widgetId: newWidget.id } : w,
);
// 3. update dashboard with new widget reference
await tx.dashboard.update({
where: { id: dashboardId, projectId },
data: {
updatedBy: userId,
definition: { widgets: updatedWidgets },
},
});
return newWidget.id;
});
}
}
@@ -24,6 +24,10 @@ export const PieChartConfig = BaseTotalValueChartConfig.extend({
type: z.literal("PIE"),
});
export const BigNumberChartConfig = BaseTotalValueChartConfig.extend({
type: z.literal("NUMBER"),
});
// Define dimension schema
export const DimensionSchema = z.object({
field: z.string(),
@@ -42,6 +46,7 @@ export const ChartConfigSchema = z.discriminatedUnion("type", [
HorizontalBarChartConfig,
VerticalBarChartConfig,
PieChartConfig,
BigNumberChartConfig,
]);
export const DashboardDefinitionWidgetWidgetSchema = z.object({
@@ -62,6 +67,8 @@ export const DashboardDefinitionSchema = z.object({
widgets: z.array(DashboardDefinitionWidgetSchema),
});
export const OwnerEnum = z.enum(["PROJECT", "LANGFUSE"]);
// Define the dashboard domain object
export const DashboardDomainSchema = z.object({
id: z.string(),
@@ -73,6 +80,7 @@ export const DashboardDomainSchema = z.object({
name: z.string(),
description: z.string(),
definition: DashboardDefinitionSchema,
owner: OwnerEnum,
});
// Define the dashboard list response
@@ -97,6 +105,7 @@ export const WidgetDomainSchema = z.object({
filters: z.array(singleFilter),
chartType: z.nativeEnum(DashboardWidgetChartType),
chartConfig: ChartConfigSchema,
owner: OwnerEnum,
});
// Define create widget input schema
@@ -0,0 +1,188 @@
import z from "zod";
import { prisma } from "../../../db";
import { LangfuseNotFoundError } from "../../../errors";
import { LLMApiKeySchema, ZodModelConfig } from "../../llm/types";
type ValidConfig = {
provider: string;
model: string;
modelParams: z.infer<typeof ZodModelConfig>;
};
export class DefaultEvalModelService {
/**
* Gets the default evaluation model for a project
*/
public static async fetchDefaultModel(projectId: string) {
return prisma.defaultLlmModel.findUnique({
where: {
projectId,
},
});
}
/**
* Creates or updates a default evaluation model for a project
*/
public static async upsertDefaultModel(params: {
projectId: string;
provider: string;
adapter: string;
model: string;
modelParams?: z.infer<typeof ZodModelConfig>;
}) {
const { projectId, provider, adapter, model, modelParams } = params;
// Find the LLM API key for the provider
const llmApiKey = await prisma.llmApiKeys.findFirst({
where: {
projectId,
provider,
},
});
if (!llmApiKey) {
throw new LangfuseNotFoundError(
`API key for provider ${provider} in project ${projectId} not found`,
);
}
// Create or update the default model
return prisma.defaultLlmModel.upsert({
where: {
projectId,
},
update: {
llmApiKeyId: llmApiKey.id,
provider,
adapter,
model,
modelParams: modelParams ? modelParams : undefined,
},
create: {
projectId,
llmApiKeyId: llmApiKey.id,
provider,
adapter,
model,
modelParams: modelParams ? modelParams : undefined,
},
});
}
/**
* Simple validation that can also be used client side
* Validates if the provider, model, and model parameters are valid
*/
public static validateModelConfig(config: {
provider?: string;
model?: string;
modelParams?: unknown;
}): config is ValidConfig {
const errors: string[] = [];
if (!config.provider || !config.model) {
return false;
}
// Validate model parameters
if (config.modelParams) {
const result = ZodModelConfig.safeParse(config.modelParams);
if (!result.success) {
errors.push(
...result.error.errors.map(
(err) => `Model parameter error: ${err.message}`,
),
);
return false;
}
}
return true;
}
/**
* Validate model config against available API keys
* Checks if the provider and model are valid and an API key exists
*/
public static async fetchValidModelConfig(
projectId: string,
provider?: string,
model?: string,
modelParams?: unknown,
): Promise<
| {
valid: true;
config: {
provider: string;
model: string;
modelParams?: z.infer<typeof ZodModelConfig>;
apiKey: z.infer<typeof LLMApiKeySchema>;
};
}
| {
valid: false;
error: string;
}
> {
let selectedModel: ValidConfig | null = null;
// Basic validation first
const config = {
provider,
model,
modelParams,
};
const basicValidation = this.validateModelConfig(config);
if (basicValidation) {
selectedModel = config;
}
if (!selectedModel) {
// fetch default model
const defaultModel = await this.fetchDefaultModel(projectId);
const defaultConfig = {
provider: defaultModel?.provider,
model: defaultModel?.model,
modelParams: defaultModel?.modelParams,
};
const basicValidation = this.validateModelConfig(defaultConfig);
if (basicValidation) {
selectedModel = defaultConfig;
}
}
if (!selectedModel) {
return {
valid: false,
error: `No default model or custom model found for project ${projectId}.`,
};
}
// Check if API key exists for this provider
const apiKey = await prisma.llmApiKeys.findFirst({
where: {
projectId,
provider: selectedModel.provider,
},
});
const parsedKey = LLMApiKeySchema.safeParse(apiKey);
if (!parsedKey.success) {
return {
valid: false,
error: `API key for provider "${selectedModel.provider}" not found in project ${projectId}.`,
};
}
return {
valid: true,
config: {
...selectedModel,
apiKey: parsedKey.data,
},
};
}
}
@@ -0,0 +1 @@
export * from "./DefaultEvalModelService";
@@ -56,10 +56,10 @@ export class PromptService {
if ((await this.shouldUseCache(params)) && dbPrompt) {
await this.cachePrompt({ ...params, prompt: dbPrompt });
this.logInfo("Successfully cached prompt for params", params);
this.logDebug("Successfully cached prompt for params", params);
}
this.logInfo("Returning DB prompt for params", params);
this.logDebug("Returning DB prompt for params", params);
return dbPrompt;
}
@@ -100,7 +100,7 @@ export class PromptService {
return null;
}
private async resolvePrompt(
public async resolvePrompt(
prompt: Prompt | null,
): Promise<PromptResult | null> {
if (!prompt) return prompt;
@@ -4,6 +4,7 @@ import {
GetObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
PutObjectCommandInput,
S3Client,
} from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
@@ -65,6 +66,8 @@ export class StorageServiceFactory {
* @param params.useAzureBlob - Use Azure Blob Storage instead of S3
* @param params.useGoogleCloudStorage - Use Google Cloud Storage instead of S3
* @param params.googleCloudCredentials - Google Cloud Storage credentials JSON string or path to credentials file
* @param params.awsSse - Server-side encryption method (e.g., "aws:kms")
* @param params.awsSseKmsKeyId - SSE KMS Key ID when using KMS encryption
*/
public static getInstance(params: {
accessKeyId: string | undefined;
@@ -77,6 +80,8 @@ export class StorageServiceFactory {
useAzureBlob?: boolean;
useGoogleCloudStorage?: boolean;
googleCloudCredentials?: string;
awsSse: string | undefined;
awsSseKmsKeyId: string | undefined;
}): StorageService {
if (params.useAzureBlob || env.LANGFUSE_USE_AZURE_BLOB === "true") {
return new AzureBlobStorageService(params);
@@ -362,9 +367,10 @@ class AzureBlobStorageService implements StorageService {
class S3StorageService implements StorageService {
private client: S3Client;
private signedUrlClient: S3Client;
private bucketName: string;
private endpoint: string | undefined;
private externalEndpoint: string | undefined;
private awsSse: string | undefined;
private awsSseKmsKeyId: string | undefined;
constructor(params: {
accessKeyId: string | undefined;
@@ -374,6 +380,8 @@ class S3StorageService implements StorageService {
externalEndpoint?: string | undefined;
region: string | undefined;
forcePathStyle: boolean;
awsSse: string | undefined;
awsSseKmsKeyId: string | undefined;
}) {
// Use accessKeyId and secretAccessKey if provided or fallback to default credentials
const { accessKeyId, secretAccessKey } = params;
@@ -385,6 +393,7 @@ class S3StorageService implements StorageService {
}
: undefined;
// Create the main client for S3 operations using the internal endpoint
this.client = new S3Client({
credentials,
endpoint: params.endpoint,
@@ -396,9 +405,37 @@ class S3StorageService implements StorageService {
},
},
});
// Create a separate client for generating presigned URLs
// If an external endpoint is provided, use it for the URL client
// Otherwise, use the same client for both operations
this.signedUrlClient = params.externalEndpoint
? new S3Client({
credentials,
endpoint: params.externalEndpoint,
region: params.region,
forcePathStyle: params.forcePathStyle,
requestHandler: {
httpsAgent: {
maxSockets: env.LANGFUSE_S3_CONCURRENT_WRITES,
},
},
})
: this.client;
this.bucketName = params.bucketName;
this.endpoint = params.endpoint;
this.externalEndpoint = params.externalEndpoint;
this.awsSse = params.awsSse;
this.awsSseKmsKeyId = params.awsSseKmsKeyId;
}
private addSSEToParams<T>(params: Record<string, unknown>): T {
if (this.awsSse) {
params.ServerSideEncryption = this.awsSse;
if (this.awsSse === "aws:kms" && this.awsSseKmsKeyId) {
params.SSEKMSKeyId = this.awsSseKmsKeyId;
}
}
return params as T;
}
public async uploadFile({
@@ -410,12 +447,12 @@ class S3StorageService implements StorageService {
try {
await new Upload({
client: this.client,
params: {
params: this.addSSEToParams<PutObjectCommandInput>({
Bucket: this.bucketName,
Key: fileName,
Body: data,
ContentType: fileType,
},
}),
}).done();
const signedUrl = await this.getSignedUrl(fileName, expiresInSeconds);
@@ -423,17 +460,19 @@ class S3StorageService implements StorageService {
return { signedUrl };
} catch (err) {
logger.error(`Failed to upload file to ${fileName}`, err);
throw new Error("Failed to upload to S3 or generate signed URL");
throw new Error(`Failed to upload to S3 or generate signed URL: ${err}`);
}
}
public async uploadJson(path: string, body: Record<string, unknown>[]) {
const putCommand = new PutObjectCommand({
Bucket: this.bucketName,
Key: path,
Body: JSON.stringify(body),
ContentType: "application/json",
});
const putCommand = new PutObjectCommand(
this.addSSEToParams({
Bucket: this.bucketName,
Key: path,
Body: JSON.stringify(body),
ContentType: "application/json",
}),
);
try {
await this.client.send(putCommand);
@@ -487,8 +526,8 @@ class S3StorageService implements StorageService {
asAttachment: boolean = true,
): Promise<string> {
try {
let url = await getSignedUrl(
this.client,
return getSignedUrl(
this.signedUrlClient,
new GetObjectCommand({
Bucket: this.bucketName,
Key: fileName,
@@ -498,17 +537,6 @@ class S3StorageService implements StorageService {
}),
{ expiresIn: ttlSeconds },
);
// Replace internal endpoint with external endpoint if configured
if (
this.externalEndpoint &&
this.endpoint &&
url.includes(this.endpoint)
) {
url = url.replace(this.endpoint, this.externalEndpoint);
}
return url;
} catch (err) {
logger.error(`Failed to generate presigned URL for ${fileName}`, err);
throw Error("Failed to generate signed URL");
@@ -566,28 +594,23 @@ class S3StorageService implements StorageService {
}): Promise<string> {
const { path, ttlSeconds, contentType, contentLength, sha256Hash } = params;
let url = await getSignedUrl(
this.client,
new PutObjectCommand({
Bucket: this.bucketName,
Key: path,
ContentType: contentType,
ChecksumSHA256: sha256Hash,
ContentLength: contentLength,
}),
return getSignedUrl(
this.signedUrlClient,
new PutObjectCommand(
this.addSSEToParams({
Bucket: this.bucketName,
Key: path,
ContentType: contentType,
ChecksumSHA256: sha256Hash,
ContentLength: contentLength,
}),
),
{
expiresIn: ttlSeconds,
signableHeaders: new Set(["content-type", "content-length"]),
unhoistableHeaders: new Set(["x-amz-checksum-sha256"]),
},
);
// Replace internal endpoint with external endpoint if configured
if (this.externalEndpoint && this.endpoint && url.includes(this.endpoint)) {
url = url.replace(this.endpoint, this.externalEndpoint);
}
return url;
}
}
@@ -5,7 +5,6 @@
import * as React from "react";
import {
Body,
Button,
Container,
Head,
Heading,
@@ -21,11 +20,11 @@ import { render } from "@react-email/render";
import { type SendVerificationRequestParams } from "next-auth/providers/email";
interface ResetPasswordTemplateProps {
url: string;
token: string;
}
const ResetPasswordTemplate = ({ url }: ResetPasswordTemplateProps) => {
const previewText = "Reset your Langfuse password";
const ResetPasswordTemplate = ({ token }: ResetPasswordTemplateProps) => {
const previewText = "Your Langfuse reset code";
return (
<Html>
<Head />
@@ -48,17 +47,14 @@ const ResetPasswordTemplate = ({ url }: ResetPasswordTemplateProps) => {
It happens to the best of us.
</Heading>
<Section className="mb-8 mt-8 text-center">
<Button
className="rounded bg-black px-5 py-3 text-center text-xs font-semibold text-white no-underline"
href={url}
>
Reset your password
</Button>
<Text className="text-center text-sm font-semibold">
Your one time passcode:
</Text>
<Heading className="text-3xl mt-2">{token}</Heading>
</Section>
<Text className="text-center text-xs leading-6 text-[#666666]">
The link is valid for 10 minutes. If you do not want to change
your password or didn&apos;t request a reset, you can ignore and
delete this email.
This code is valid for 3 minutes. If you did not request a reset,
you can ignore this email.
</Text>
</Container>
</Body>
@@ -68,16 +64,16 @@ const ResetPasswordTemplate = ({ url }: ResetPasswordTemplateProps) => {
};
export async function sendResetPasswordVerificationRequest(
params: SendVerificationRequestParams
params: SendVerificationRequestParams,
) {
const { identifier, url, provider } = params;
const { identifier, token, provider } = params as SendVerificationRequestParams & { token: string };
const transport = createTransport(provider.server);
const htmlTemplate = render(<ResetPasswordTemplate url={url} />);
const htmlTemplate = render(<ResetPasswordTemplate token={token} />);
const result = await transport.sendMail({
to: identifier,
from: provider.from,
subject: `Forgot your password?`,
text: `To reset your Langfuse password, please confirm your email:\n${url}\n\nThe link is valid for 10 minutes. If you do not want to change your password or didn't request a reset, you can ignore and delete this email.`,
subject: `Your Langfuse password reset code`,
text: `Use the following code to reset your Langfuse password: ${token}\n\nThis code will expire in 3 minutes. If you did not request a reset, you can ignore this email.`,
html: htmlTemplate,
});
const failed = result.rejected.concat(result.pending).filter(Boolean);
@@ -157,7 +157,7 @@ export type TracesTableMetricsClickhouseReturnType = {
};
export type FetchTracesTableProps = {
select: "count" | "rows" | "metrics";
select: "count" | "rows" | "metrics" | "identifiers";
projectId: string;
filter: FilterState;
searchQuery?: string;
@@ -169,84 +169,42 @@ export type FetchTracesTableProps = {
tags?: Record<string, string>;
};
export const getTracesTableCount = async (props: {
projectId: string;
filter: FilterState;
searchQuery?: string;
searchType: TracingSearchType[];
orderBy?: OrderByState;
limit?: number;
page?: number;
}) => {
const countRows = await getTracesTableGeneric<{ count: string }>({
select: "count",
tags: { kind: "count" },
...props,
});
const converted = countRows.map((row) => ({
count: Number(row.count),
}));
return converted.length > 0 ? converted[0].count : 0;
// Define return type mapping for better type safety
type SelectReturnTypeMap = {
count: { count: string };
metrics: TracesTableMetricsClickhouseReturnType;
rows: TracesTableReturnType;
identifiers: { id: string; projectId: string; timestamp: string };
};
export const getTracesTableMetrics = async (props: {
projectId: string;
filter: FilterState;
searchQuery?: string;
orderBy?: OrderByState;
limit?: number;
page?: number;
clickhouseConfigs?: ClickHouseClientConfigOptions | undefined;
}): Promise<Array<Omit<TracesMetricsUiReturnType, "scores">>> => {
const countRows =
await getTracesTableGeneric<TracesTableMetricsClickhouseReturnType>({
select: "metrics",
tags: { kind: "analytic" },
...props,
});
// Function overloads for type-safe select-specific returns
async function getTracesTableGeneric(
// eslint-disable-next-line no-unused-vars
props: FetchTracesTableProps & { select: "count" },
): Promise<Array<SelectReturnTypeMap["count"]>>;
return countRows.map(convertToUITableMetrics);
};
async function getTracesTableGeneric(
// eslint-disable-next-line no-unused-vars
props: FetchTracesTableProps & { select: "metrics" },
): Promise<Array<SelectReturnTypeMap["metrics"]>>;
export const getTracesTable = async (p: {
projectId: string;
filter: FilterState;
searchQuery?: string;
searchType?: TracingSearchType[];
orderBy?: OrderByState;
limit?: number;
page?: number;
clickhouseConfigs?: ClickHouseClientConfigOptions | undefined;
}) => {
const {
projectId,
filter,
searchQuery,
searchType,
orderBy,
limit,
page,
clickhouseConfigs,
} = p;
const rows = await getTracesTableGeneric<TracesTableReturnType>({
select: "rows",
tags: { kind: "list" },
projectId,
filter,
searchQuery,
searchType,
orderBy,
limit,
page,
clickhouseConfigs,
});
async function getTracesTableGeneric(
// eslint-disable-next-line no-unused-vars
props: FetchTracesTableProps & { select: "rows" },
): Promise<Array<SelectReturnTypeMap["rows"]>>;
return rows.map(convertToUiTableRows);
};
async function getTracesTableGeneric(
// eslint-disable-next-line no-unused-vars
props: FetchTracesTableProps & { select: "identifiers" },
): Promise<Array<SelectReturnTypeMap["identifiers"]>>;
const getTracesTableGeneric = async <T>(props: FetchTracesTableProps) => {
// Implementation with union type for internal use
async function getTracesTableGeneric(
// eslint-disable-next-line no-unused-vars
props: FetchTracesTableProps,
): Promise<Array<SelectReturnTypeMap[keyof SelectReturnTypeMap]>>;
async function getTracesTableGeneric(props: FetchTracesTableProps) {
const {
select,
projectId,
@@ -297,6 +255,12 @@ const getTracesTableGeneric = async <T>(props: FetchTracesTableProps) => {
t.session_id as session_id,
t.public as public`;
break;
case "identifiers":
sqlSelect = `
t.id as id,
t.project_id as projectId,
t.timestamp as timestamp`;
break;
default:
throw new Error(`Unknown select type: ${select}`);
}
@@ -471,7 +435,7 @@ const getTracesTableGeneric = async <T>(props: FetchTracesTableProps) => {
)
SELECT ${sqlSelect}
-- FINAL is used for non default ordering and count.
FROM traces t ${["metrics", "rows"].includes(select) && defaultOrder ? "" : "FINAL"}
FROM traces t ${["metrics", "rows", "identifiers"].includes(select) && defaultOrder ? "" : "FINAL"}
${select === "metrics" || requiresObservationsJoin ? `LEFT JOIN observations_stats os on os.project_id = t.project_id and os.trace_id = t.id` : ""}
${select === "metrics" || requiresScoresJoin ? `LEFT JOIN scores_avg s on s.project_id = t.project_id and s.trace_id = t.id` : ""}
WHERE t.project_id = {projectId: String}
@@ -480,11 +444,13 @@ const getTracesTableGeneric = async <T>(props: FetchTracesTableProps) => {
${chOrderBy}
-- This is used for metrics and row queries. Count has only one result.
-- This is only used for default ordering. Otherwise, we use final.
${["metrics", "rows"].includes(select) && defaultOrder ? "LIMIT 1 BY id, project_id" : ""}
${["metrics", "rows", "identifiers"].includes(select) && defaultOrder ? "LIMIT 1 BY id, project_id" : ""}
${limit !== undefined && page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
`;
const res = await queryClickhouse<T>({
const res = await queryClickhouse<
SelectReturnTypeMap[keyof SelectReturnTypeMap]
>({
query: query,
params: {
limit: limit,
@@ -506,4 +472,120 @@ const getTracesTableGeneric = async <T>(props: FetchTracesTableProps) => {
});
return res;
}
export const getTracesTableCount = async (props: {
projectId: string;
filter: FilterState;
searchQuery?: string;
searchType: TracingSearchType[];
orderBy?: OrderByState;
limit?: number;
page?: number;
}) => {
const countRows = await getTracesTableGeneric({
select: "count",
tags: { kind: "count" },
...props,
});
const converted = countRows.map((row) => ({
count: Number(row.count),
}));
return converted.length > 0 ? converted[0].count : 0;
};
export const getTracesTableMetrics = async (props: {
projectId: string;
filter: FilterState;
searchQuery?: string;
orderBy?: OrderByState;
limit?: number;
page?: number;
clickhouseConfigs?: ClickHouseClientConfigOptions | undefined;
}): Promise<Array<Omit<TracesMetricsUiReturnType, "scores">>> => {
const countRows = await getTracesTableGeneric({
select: "metrics",
tags: { kind: "analytic" },
...props,
});
return countRows.map(convertToUITableMetrics);
};
export const getTracesTable = async (p: {
projectId: string;
filter: FilterState;
searchQuery?: string;
searchType?: TracingSearchType[];
orderBy?: OrderByState;
limit?: number;
page?: number;
clickhouseConfigs?: ClickHouseClientConfigOptions | undefined;
}) => {
const {
projectId,
filter,
searchQuery,
searchType,
orderBy,
limit,
page,
clickhouseConfigs,
} = p;
const rows = await getTracesTableGeneric({
select: "rows",
tags: { kind: "list" },
projectId,
filter,
searchQuery,
searchType,
orderBy,
limit,
page,
clickhouseConfigs,
});
return rows.map(convertToUiTableRows);
};
export const getTraceIdentifiers = async (props: {
projectId: string;
filter: FilterState;
searchQuery?: string;
searchType?: TracingSearchType[];
orderBy?: OrderByState;
limit?: number;
page?: number;
clickhouseConfigs?: ClickHouseClientConfigOptions | undefined;
}) => {
const {
projectId,
filter,
searchQuery,
searchType,
orderBy,
limit,
page,
clickhouseConfigs,
} = props;
const identifiers = await getTracesTableGeneric({
select: "identifiers",
tags: { kind: "list" },
projectId,
filter,
searchQuery,
searchType,
orderBy,
limit,
page,
clickhouseConfigs,
});
return identifiers.map((row) => ({
id: row.id,
projectId: row.projectId,
timestamp: parseClickhouseUTCDateTimeFormat(row.timestamp),
}));
};
@@ -74,6 +74,8 @@ export const tableNames = [
"sessions",
"prompts",
"users",
"job_configurations",
"job_executions",
"dataset_items",
] as const;
+3 -1
View File
@@ -34,4 +34,6 @@ export type TableName =
| "prompts"
| "dashboard"
| "widgets"
| "users";
| "users"
| "eval_configs"
| "job_executions";
+394 -334
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -85,7 +85,7 @@ ENV NEXT_MANUAL_SIG_HANDLE true
# set the CI flag to true to get CI specific logs
ENV CI true
RUN NODE_OPTIONS='--max-old-space-size=4096' turbo run build --filter=web...
RUN NODE_OPTIONS='--max-old-space-size=8192' turbo run build --filter=web...
# Production image, copy all the files and run next
FROM --platform=${TARGETPLATFORM:-linux/amd64} base AS runner
@@ -149,6 +149,8 @@ USER nextjs
# Default port to 3000
ENV PORT 3000
# Docker ENTRYPOINT (dumb-init) is covered by semantic versioning, not the entrypoint.sh itself
# Reasoning: ENTRYPOINT is overridden by some self-hosted deployments, thus changing this is breaking
ENTRYPOINT ["dumb-init", "--", "./web/entrypoint.sh"]
# startup command - use dd-trace if NEXT_PUBLIC_LANGFUSE_CLOUD_REGION is configured
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.56.0",
"version": "3.66.0",
"private": true,
"license": "MIT",
"engines": {
@@ -136,6 +136,7 @@
"react": "18.2.0",
"react-day-picker": "^8.10.1",
"react-dom": "18.2.0",
"react-grid-layout": "^1.5.1",
"react-hook-form": "^7.51.5",
"react-icons": "^5.2.1",
"react-markdown": "^9.0.1",
@@ -159,7 +160,7 @@
"uuid": "^9.0.1",
"vaul": "^1.1.2",
"vis-network": "^9.1.9",
"zod": "^3.23.8"
"zod": "^3.24.4"
},
"devDependencies": {
"@jedmao/location": "^3.0.0",
@@ -178,6 +179,7 @@
"@types/node": "20.10.5",
"@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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+472 -381
View File
@@ -845,6 +845,71 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/CreateDatasetRunItemRequest'
get:
description: List dataset run items
operationId: datasetRunItems_list
tags:
- DatasetRunItems
parameters:
- name: datasetId
in: query
required: true
schema:
type: string
- name: runName
in: query
required: true
schema:
type: string
- 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
- name: response
in: query
required: true
schema:
$ref: '#/components/schemas/PaginatedDatasetRunItems'
responses:
'204':
description: ''
'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: []
/api/public/v2/datasets:
get:
description: Get all datasets
@@ -1499,281 +1564,67 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/GetMediaUploadUrlRequest'
/api/public/organizations/memberships:
/api/public/metrics:
get:
description: >-
Get all memberships for the organization associated with the API key
(requires organization-scoped API key)
operationId: memberships_getOrganizationMemberships
tags:
- Memberships
parameters: []
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/MembershipsResponse'
'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 a membership for the organization associated with the
API key (requires organization-scoped API key)
operationId: memberships_updateOrganizationMembership
tags:
- Memberships
parameters: []
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/MembershipResponse'
'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/MembershipRequest'
/api/public/projects/{projectId}/memberships:
get:
description: >-
Get all memberships for a specific project (requires organization-scoped
API key)
operationId: memberships_getProjectMemberships
tags:
- Memberships
parameters:
- name: projectId
in: path
required: true
schema:
type: string
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/MembershipsResponse'
'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 a membership for a specific project (requires
organization-scoped API key). The user must already be a member of the
organization.
operationId: memberships_updateProjectMembership
tags:
- Memberships
parameters:
- name: projectId
in: path
required: true
schema:
type: string
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/MembershipResponse'
'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/MembershipRequest'
/api/public/metrics/daily:
get:
description: Get daily metrics of the Langfuse project
operationId: metrics_daily
description: Get metrics from the Langfuse project using a query object
operationId: metrics_metrics
tags:
- Metrics
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
- name: traceName
in: query
description: Optional filter by the name of the trace
required: false
schema:
type: string
nullable: true
- name: userId
in: query
description: Optional filter by the userId associated with the trace
required: false
schema:
type: string
nullable: true
- name: tags
in: query
description: Optional filter for metrics where traces include all of these tags
required: false
schema:
type: array
items:
type: string
nullable: true
- name: environment
- name: query
in: query
description: >-
Optional filter for metrics where events include any of these
environments
required: false
schema:
type: array
items:
type: string
nullable: true
- name: fromTimestamp
in: query
description: >-
Optional filter to only include traces and observations on or after
a certain datetime (ISO 8601)
required: false
JSON string containing the query parameters with the following
structure:
```json
{
"view": string, // Required. One of "traces", "observations", "scores-numeric", "scores-categorical"
"dimensions": [ // Optional. Default: []
{
"field": string // Field to group by, e.g. "name", "userId", "sessionId"
}
],
"metrics": [ // Required. At least one metric must be provided
{
"measure": string, // What to measure, e.g. "count", "latency", "value"
"aggregation": string // How to aggregate, e.g. "count", "sum", "avg", "p95"
}
],
"filters": [ // Optional. Default: []
{
"column": string, // Column to filter on
"operator": string, // Operator, e.g. "=", ">", "<", "contains"
"value": any, // Value to compare against
"type": string, // Data type, e.g. "string", "number", "stringObject"
"key": string // Required only when filtering on metadata
}
],
"timeDimension": { // Optional. Default: null. If provided, results will be grouped by time
"granularity": string // One of "minute", "hour", "day", "week", "month", "auto"
},
"fromTimestamp": string, // Required. ISO datetime string for start of time range
"toTimestamp": string, // Required. ISO datetime string for end of time range
"orderBy": [ // Optional. Default: null
{
"field": string, // Field to order by
"direction": string // "asc" or "desc"
}
]
}
```
required: true
schema:
type: string
format: date-time
nullable: true
- name: toTimestamp
in: query
description: >-
Optional filter to only include traces and observations before a
certain datetime (ISO 8601)
required: false
schema:
type: string
format: date-time
nullable: true
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/DailyMetrics'
$ref: '#/components/schemas/MetricsResponse'
'400':
description: ''
content:
@@ -2108,7 +1959,7 @@ paths:
- name: fromStartTime
in: query
description: >-
Retrieve only observations with a start_time or or after this
Retrieve only observations with a start_time on or after this
datetime (ISO 8601).
required: false
schema:
@@ -2166,6 +2017,242 @@ paths:
schema: {}
security:
- BasicAuth: []
/api/public/organizations/memberships:
get:
description: >-
Get all memberships for the organization associated with the API key
(requires organization-scoped API key)
operationId: organizations_getOrganizationMemberships
tags:
- Organizations
parameters: []
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/MembershipsResponse'
'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 a membership for the organization associated with the
API key (requires organization-scoped API key)
operationId: organizations_updateOrganizationMembership
tags:
- Organizations
parameters: []
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/MembershipResponse'
'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/MembershipRequest'
/api/public/projects/{projectId}/memberships:
get:
description: >-
Get all memberships for a specific project (requires organization-scoped
API key)
operationId: organizations_getProjectMemberships
tags:
- Organizations
parameters:
- name: projectId
in: path
required: true
schema:
type: string
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/MembershipsResponse'
'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 a membership for a specific project (requires
organization-scoped API key). The user must already be a member of the
organization.
operationId: organizations_updateProjectMembership
tags:
- Organizations
parameters:
- name: projectId
in: path
required: true
schema:
type: string
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/MembershipResponse'
'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/MembershipRequest'
/api/public/organizations/projects:
get:
description: >-
Get all projects for the organization associated with the API key
(requires organization-scoped API key)
operationId: organizations_getOrganizationProjects
tags:
- Organizations
parameters: []
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/OrganizationProjectsResponse'
'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: []
/api/public/projects:
get:
description: Get Project associated with API key
@@ -2264,7 +2351,7 @@ paths:
retention:
type: integer
description: >-
Number of days to retain data. Must be 0 or at least 7 days.
Number of days to retain data. Must be 0 or at least 3 days.
Requires data-retention entitlement for non-zero values.
Optional.
required:
@@ -2333,7 +2420,7 @@ paths:
retention:
type: integer
description: >-
Number of days to retain data. Must be 0 or at least 7 days.
Number of days to retain data. Must be 0 or at least 3 days.
Requires data-retention entitlement for non-zero values.
Optional.
required:
@@ -5181,19 +5268,19 @@ components:
type: number
format: double
nullable: true
description: Price (USD) per input unit
description: Deprecated. See 'prices' instead. Price (USD) per input unit
outputPrice:
type: number
format: double
nullable: true
description: Price (USD) per output unit
description: Deprecated. See 'prices' instead. Price (USD) per output unit
totalPrice:
type: number
format: double
nullable: true
description: >-
Price (USD) per total unit. Cannot be set if input or output price
is set.
Deprecated. See 'prices' instead. Price (USD) per total unit. Cannot
be set if input or output price is set.
tokenizerId:
type: string
nullable: true
@@ -5207,11 +5294,26 @@ components:
JSON. See docs for more details.
isLangfuseManaged:
type: boolean
prices:
type: object
additionalProperties:
$ref: '#/components/schemas/ModelPrice'
description: Price (USD) by usage type
required:
- id
- modelName
- matchPattern
- isLangfuseManaged
- prices
ModelPrice:
title: ModelPrice
type: object
properties:
price:
type: number
format: double
required:
- price
ModelUsageUnit:
title: ModelUsageUnit
type: string
@@ -5351,6 +5453,19 @@ components:
required:
- runName
- datasetItemId
PaginatedDatasetRunItems:
title: PaginatedDatasetRunItems
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/DatasetRunItem'
meta:
$ref: '#/components/schemas/utilsMetaResponse'
required:
- data
- meta
PaginatedDatasets:
title: PaginatedDatasets
type: object
@@ -6220,125 +6335,22 @@ components:
- application/xml
- application/octet-stream
description: The MIME type of the media record
MembershipRole:
title: MembershipRole
type: string
enum:
- OWNER
- ADMIN
- MEMBER
- VIEWER
MembershipRequest:
title: MembershipRequest
type: object
properties:
userId:
type: string
role:
$ref: '#/components/schemas/MembershipRole'
required:
- userId
- role
MembershipResponse:
title: MembershipResponse
type: object
properties:
userId:
type: string
role:
$ref: '#/components/schemas/MembershipRole'
email:
type: string
name:
type: string
required:
- userId
- role
- email
- name
MembershipsResponse:
title: MembershipsResponse
type: object
properties:
memberships:
type: array
items:
$ref: '#/components/schemas/MembershipResponse'
required:
- memberships
DailyMetrics:
title: DailyMetrics
MetricsResponse:
title: MetricsResponse
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/DailyMetricsDetails'
description: A list of daily metrics, only days with ingested data are included.
meta:
$ref: '#/components/schemas/utilsMetaResponse'
type: object
additionalProperties: true
description: >-
The metrics data. Each item in the list contains the metric values
and dimensions requested in the query.
Format varies based on the query parameters.
required:
- data
- meta
DailyMetricsDetails:
title: DailyMetricsDetails
type: object
properties:
date:
type: string
format: date
countTraces:
type: integer
countObservations:
type: integer
totalCost:
type: number
format: double
description: Total model cost in USD
usage:
type: array
items:
$ref: '#/components/schemas/UsageByModel'
required:
- date
- countTraces
- countObservations
- totalCost
- usage
UsageByModel:
title: UsageByModel
type: object
description: >-
Daily usage of a given model. Usage corresponds to the unit set for the
specific model (e.g. tokens).
properties:
model:
type: string
nullable: true
inputUsage:
type: integer
description: Total number of generation input units (e.g. tokens)
outputUsage:
type: integer
description: Total number of generation output units (e.g. tokens)
totalUsage:
type: integer
description: Total number of generation total units (e.g. tokens)
countTraces:
type: integer
countObservations:
type: integer
totalCost:
type: number
format: double
description: Total model cost in USD
required:
- inputUsage
- outputUsage
- totalUsage
- countTraces
- countObservations
- totalCost
PaginatedModels:
title: PaginatedModels
type: object
@@ -6435,6 +6447,85 @@ components:
required:
- data
- meta
MembershipRole:
title: MembershipRole
type: string
enum:
- OWNER
- ADMIN
- MEMBER
- VIEWER
MembershipRequest:
title: MembershipRequest
type: object
properties:
userId:
type: string
role:
$ref: '#/components/schemas/MembershipRole'
required:
- userId
- role
MembershipResponse:
title: MembershipResponse
type: object
properties:
userId:
type: string
role:
$ref: '#/components/schemas/MembershipRole'
email:
type: string
name:
type: string
required:
- userId
- role
- email
- name
MembershipsResponse:
title: MembershipsResponse
type: object
properties:
memberships:
type: array
items:
$ref: '#/components/schemas/MembershipResponse'
required:
- memberships
OrganizationProject:
title: OrganizationProject
type: object
properties:
id:
type: string
name:
type: string
metadata:
type: object
additionalProperties: true
nullable: true
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
required:
- id
- name
- createdAt
- updatedAt
OrganizationProjectsResponse:
title: OrganizationProjectsResponse
type: object
properties:
projects:
type: array
items:
$ref: '#/components/schemas/OrganizationProject'
required:
- projects
Projects:
title: Projects
type: object
@@ -6459,6 +6550,7 @@ components:
description: Metadata for the project
retentionDays:
type: integer
nullable: true
description: >-
Number of days to retain data. Null or 0 means no retention. Omitted
if no retention is configured.
@@ -6466,7 +6558,6 @@ components:
- id
- name
- metadata
- retentionDays
ProjectDeletionResponse:
title: ProjectDeletionResponse
type: object
+225 -184
View File
@@ -642,6 +642,57 @@
}
},
"response": []
},
{
"_type": "endpoint",
"name": "List",
"request": {
"description": "List dataset run items",
"url": {
"raw": "{{baseUrl}}/api/public/dataset-run-items?datasetId=&runName=&page=&limit=&response=",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"dataset-run-items"
],
"query": [
{
"key": "datasetId",
"value": "",
"description": null
},
{
"key": "runName",
"value": "",
"description": null
},
{
"key": "page",
"value": "",
"description": "page number, starts at 1"
},
{
"key": "limit",
"value": "",
"description": "limit of items per page"
},
{
"key": "response",
"value": "",
"description": null
}
],
"variable": []
},
"header": [],
"method": "GET",
"auth": null,
"body": null
},
"response": []
}
]
},
@@ -1195,147 +1246,6 @@
}
]
},
{
"_type": "container",
"description": null,
"name": "Memberships",
"item": [
{
"_type": "endpoint",
"name": "Get Organization Memberships",
"request": {
"description": "Get all memberships for the organization associated with the API key (requires organization-scoped API key)",
"url": {
"raw": "{{baseUrl}}/api/public/organizations/memberships",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"organizations",
"memberships"
],
"query": [],
"variable": []
},
"header": [],
"method": "GET",
"auth": null,
"body": null
},
"response": []
},
{
"_type": "endpoint",
"name": "Update Organization Membership",
"request": {
"description": "Create or update a membership for the organization associated with the API key (requires organization-scoped API key)",
"url": {
"raw": "{{baseUrl}}/api/public/organizations/memberships",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"organizations",
"memberships"
],
"query": [],
"variable": []
},
"header": [],
"method": "PUT",
"auth": null,
"body": {
"mode": "raw",
"raw": "{\n \"userId\": \"example\",\n \"role\": \"OWNER\"\n}",
"options": {
"raw": {
"language": "json"
}
}
}
},
"response": []
},
{
"_type": "endpoint",
"name": "Get Project Memberships",
"request": {
"description": "Get all memberships for a specific project (requires organization-scoped API key)",
"url": {
"raw": "{{baseUrl}}/api/public/projects/:projectId/memberships",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"projects",
":projectId",
"memberships"
],
"query": [],
"variable": [
{
"key": "projectId",
"value": "",
"description": null
}
]
},
"header": [],
"method": "GET",
"auth": null,
"body": null
},
"response": []
},
{
"_type": "endpoint",
"name": "Update Project Membership",
"request": {
"description": "Create or update a membership for a specific project (requires organization-scoped API key). The user must already be a member of the organization.",
"url": {
"raw": "{{baseUrl}}/api/public/projects/:projectId/memberships",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"projects",
":projectId",
"memberships"
],
"query": [],
"variable": [
{
"key": "projectId",
"value": "",
"description": null
}
]
},
"header": [],
"method": "PUT",
"auth": null,
"body": {
"mode": "raw",
"raw": "{\n \"userId\": \"example\",\n \"role\": \"OWNER\"\n}",
"options": {
"raw": {
"language": "json"
}
}
}
},
"response": []
}
]
},
{
"_type": "container",
"description": null,
@@ -1343,60 +1253,24 @@
"item": [
{
"_type": "endpoint",
"name": "Daily",
"name": "Metrics",
"request": {
"description": "Get daily metrics of the Langfuse project",
"description": "Get metrics from the Langfuse project using a query object",
"url": {
"raw": "{{baseUrl}}/api/public/metrics/daily?page=&limit=&traceName=&userId=&tags=&environment=&fromTimestamp=&toTimestamp=",
"raw": "{{baseUrl}}/api/public/metrics?query=",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"metrics",
"daily"
"metrics"
],
"query": [
{
"key": "page",
"key": "query",
"value": "",
"description": "page number, starts at 1"
},
{
"key": "limit",
"value": "",
"description": "limit of items per page"
},
{
"key": "traceName",
"value": "",
"description": "Optional filter by the name of the trace"
},
{
"key": "userId",
"value": "",
"description": "Optional filter by the userId associated with the trace"
},
{
"key": "tags",
"value": "",
"description": "Optional filter for metrics where traces include all of these tags"
},
{
"key": "environment",
"value": "",
"description": "Optional filter for metrics where events include any of these environments"
},
{
"key": "fromTimestamp",
"value": "",
"description": "Optional filter to only include traces and observations on or after a certain datetime (ISO 8601)"
},
{
"key": "toTimestamp",
"value": "",
"description": "Optional filter to only include traces and observations before a certain datetime (ISO 8601)"
"description": "JSON string containing the query parameters with the following structure:\n```json\n{\n \"view\": string, // Required. One of \"traces\", \"observations\", \"scores-numeric\", \"scores-categorical\"\n \"dimensions\": [ // Optional. Default: []\n {\n \"field\": string // Field to group by, e.g. \"name\", \"userId\", \"sessionId\"\n }\n ],\n \"metrics\": [ // Required. At least one metric must be provided\n {\n \"measure\": string, // What to measure, e.g. \"count\", \"latency\", \"value\"\n \"aggregation\": string // How to aggregate, e.g. \"count\", \"sum\", \"avg\", \"p95\"\n }\n ],\n \"filters\": [ // Optional. Default: []\n {\n \"column\": string, // Column to filter on\n \"operator\": string, // Operator, e.g. \"=\", \">\", \"<\", \"contains\"\n \"value\": any, // Value to compare against\n \"type\": string, // Data type, e.g. \"string\", \"number\", \"stringObject\"\n \"key\": string // Required only when filtering on metadata\n }\n ],\n \"timeDimension\": { // Optional. Default: null. If provided, results will be grouped by time\n \"granularity\": string // One of \"minute\", \"hour\", \"day\", \"week\", \"month\", \"auto\"\n },\n \"fromTimestamp\": string, // Required. ISO datetime string for start of time range\n \"toTimestamp\": string, // Required. ISO datetime string for end of time range\n \"orderBy\": [ // Optional. Default: null\n {\n \"field\": string, // Field to order by\n \"direction\": string // \"asc\" or \"desc\"\n }\n ]\n}\n```"
}
],
"variable": []
@@ -1646,7 +1520,7 @@
{
"key": "fromStartTime",
"value": "",
"description": "Retrieve only observations with a start_time or or after this datetime (ISO 8601)."
"description": "Retrieve only observations with a start_time on or after this datetime (ISO 8601)."
},
{
"key": "toStartTime",
@@ -1670,6 +1544,173 @@
}
]
},
{
"_type": "container",
"description": null,
"name": "Organizations",
"item": [
{
"_type": "endpoint",
"name": "Get Organization Memberships",
"request": {
"description": "Get all memberships for the organization associated with the API key (requires organization-scoped API key)",
"url": {
"raw": "{{baseUrl}}/api/public/organizations/memberships",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"organizations",
"memberships"
],
"query": [],
"variable": []
},
"header": [],
"method": "GET",
"auth": null,
"body": null
},
"response": []
},
{
"_type": "endpoint",
"name": "Update Organization Membership",
"request": {
"description": "Create or update a membership for the organization associated with the API key (requires organization-scoped API key)",
"url": {
"raw": "{{baseUrl}}/api/public/organizations/memberships",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"organizations",
"memberships"
],
"query": [],
"variable": []
},
"header": [],
"method": "PUT",
"auth": null,
"body": {
"mode": "raw",
"raw": "{\n \"userId\": \"example\",\n \"role\": \"OWNER\"\n}",
"options": {
"raw": {
"language": "json"
}
}
}
},
"response": []
},
{
"_type": "endpoint",
"name": "Get Project Memberships",
"request": {
"description": "Get all memberships for a specific project (requires organization-scoped API key)",
"url": {
"raw": "{{baseUrl}}/api/public/projects/:projectId/memberships",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"projects",
":projectId",
"memberships"
],
"query": [],
"variable": [
{
"key": "projectId",
"value": "",
"description": null
}
]
},
"header": [],
"method": "GET",
"auth": null,
"body": null
},
"response": []
},
{
"_type": "endpoint",
"name": "Update Project Membership",
"request": {
"description": "Create or update a membership for a specific project (requires organization-scoped API key). The user must already be a member of the organization.",
"url": {
"raw": "{{baseUrl}}/api/public/projects/:projectId/memberships",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"projects",
":projectId",
"memberships"
],
"query": [],
"variable": [
{
"key": "projectId",
"value": "",
"description": null
}
]
},
"header": [],
"method": "PUT",
"auth": null,
"body": {
"mode": "raw",
"raw": "{\n \"userId\": \"example\",\n \"role\": \"OWNER\"\n}",
"options": {
"raw": {
"language": "json"
}
}
}
},
"response": []
},
{
"_type": "endpoint",
"name": "Get Organization Projects",
"request": {
"description": "Get all projects for the organization associated with the API key (requires organization-scoped API key)",
"url": {
"raw": "{{baseUrl}}/api/public/organizations/projects",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"organizations",
"projects"
],
"query": [],
"variable": []
},
"header": [],
"method": "GET",
"auth": null,
"body": null
},
"response": []
}
]
},
{
"_type": "container",
"description": null,
+4 -1
View File
@@ -115,7 +115,10 @@ describe("Ingestion Pipeline", () => {
expect(trace).not.toBeNull();
expect(trace?.name).toBe("test trace");
const observation = await getObservationById(spanId, projectId);
const observation = await getObservationById({
id: spanId,
projectId,
});
expect(observation).not.toBeNull();
expect(observation?.name).toBe("test span");
@@ -21,6 +21,7 @@ import {
PostDatasetsV2Response,
DeleteDatasetItemV1Response,
DeleteDatasetRunV1Response,
GetDatasetRunItemsV1Response,
} from "@/src/features/public-api/types/datasets";
import { v4 as uuidv4 } from "uuid";
import {
@@ -1368,4 +1369,117 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
});
expect(dbRunItemAfterDelete).toBeNull();
});
it("should properly paginate and filter dataset run items", async () => {
// Create a dataset
const datasetName = `pagination-test-${v4()}`;
const dataset = await makeZodVerifiedAPICall(
PostDatasetsV1Response,
"POST",
"/api/public/datasets",
{
name: datasetName,
},
auth,
);
// Create 10 dataset items with different inputs
const itemIds = [];
for (let i = 0; i < 10; i++) {
const itemId = `item-${i}-${v4()}`;
itemIds.push(itemId);
await makeZodVerifiedAPICall(
PostDatasetItemsV1Response,
"POST",
"/api/public/dataset-items",
{
datasetName: datasetName,
id: itemId,
input: { value: `test-value-${i}` },
metadata: { index: i },
// Add sourceObservationId to odd numbered items
sourceTraceId: v4(),
sourceObservationId: i % 2 === 1 ? v4() : undefined,
datasetId: dataset.body.id,
},
auth,
);
}
// Create a run
const runName = `run-${v4()}`;
await prisma.datasetRuns.create({
data: {
id: v4(),
datasetId: dataset.body.id,
name: runName,
metadata: {},
projectId: dataset.body.projectId,
},
});
// Create 10 run items
for (let i = 0; i < 10; i++) {
await makeZodVerifiedAPICall(
PostDatasetRunItemsV1Response,
"POST",
"/api/public/dataset-run-items",
{
runName,
datasetItemId: itemIds[i],
traceId: v4(),
metadata: { index: i },
},
auth,
);
}
// Test basic pagination with limit
const pageSize = 3;
const page1 = await makeZodVerifiedAPICall(
GetDatasetRunItemsV1Response,
"GET",
`/api/public/dataset-run-items?datasetId=${dataset.body.id}&runName=${encodeURIComponent(runName)}&limit=${pageSize}&page=1`,
undefined,
auth,
);
expect(page1.status).toBe(200);
expect(page1.body.data.length).toBe(pageSize);
expect(page1.body.meta).toMatchObject({
totalItems: 10,
limit: pageSize,
page: 1,
totalPages: Math.ceil(10 / pageSize),
});
// Test second page
const page2 = await makeZodVerifiedAPICall(
GetDatasetRunItemsV1Response,
"GET",
`/api/public/dataset-run-items?datasetId=${dataset.body.id}&runName=${encodeURIComponent(runName)}&limit=${pageSize}&page=2`,
undefined,
auth,
);
expect(page2.status).toBe(200);
expect(page2.body.data.length).toBe(pageSize);
// Check that we got different items on different pages
const page1Ids = page1.body.data.map((item) => item.id);
const page2Ids = page2.body.data.map((item) => item.id);
page2Ids.forEach((id) => {
expect(page1Ids).not.toContain(id);
});
// Test non-existent dataset name
const nonExistent = await makeAPICall(
"GET",
`/api/public/dataset-run-items?datasetId=${v4()}&runName=does-not-exist`,
undefined,
auth,
);
expect(nonExistent.status).toBe(404);
});
});
@@ -123,6 +123,11 @@ describe("evals trpc", () => {
const response = await caller.evals.allConfigs({
projectId: project.id,
filter: [],
orderBy: {
column: "createdAt",
order: "DESC",
},
limit: 10,
page: 0,
});
@@ -94,7 +94,10 @@ describe("/api/public/ingestion API Endpoint", () => {
expect(response.status).toBe(207);
await waitForExpect(async () => {
const trace = await getTraceById({ traceId: entity.body.id, projectId });
const trace = await getTraceById({
traceId: entity.body.id,
projectId,
});
expect(trace).toBeDefined();
expect(trace!.id).toBe(entity.body.id);
expect(trace!.projectId).toBe(projectId);
@@ -212,11 +215,11 @@ describe("/api/public/ingestion API Endpoint", () => {
expect(response.status).toBe(207);
await waitForExpect(async () => {
const observation = await getObservationById(
entity.body.id,
const observation = await getObservationById({
id: entity.body.id,
projectId,
true,
);
fetchWithInputOutput: true,
});
expect(observation).toBeDefined();
expect(observation!.id).toBe(entity.body.id);
expect(observation!.projectId).toBe(projectId);
@@ -333,7 +336,10 @@ describe("/api/public/ingestion API Endpoint", () => {
expect(response.status).toBe(207);
await waitForExpect(async () => {
const trace = await getTraceById({ traceId: `${traceId}-${char}-test`, projectId });
const trace = await getTraceById({
traceId: `${traceId}-${char}-test`,
projectId,
});
expect(trace).toBeDefined();
expect(trace!.id).toBe(`${traceId}-${char}-test`);
expect(trace!.projectId).toBe(projectId);
@@ -569,11 +575,11 @@ describe("/api/public/ingestion API Endpoint", () => {
expect(response.status).toBe(207);
await waitForExpect(async () => {
const observation = await getObservationById(
observationId,
const observation = await getObservationById({
id: observationId,
projectId,
true,
);
fetchWithInputOutput: true,
});
expect(observation).toBeDefined();
expect(observation!.id).toBe(observationId);
expect(JSON.stringify(observation!.metadata)).toBe(
@@ -0,0 +1,402 @@
import { randomUUID } from "crypto";
import {
makeZodVerifiedAPICall,
makeZodVerifiedAPICallSilent,
makeAPICall,
} from "@/src/__tests__/test-utils";
import { GetMetricsV1Response } from "@/src/features/public-api/types/metrics";
import { createBasicAuthHeader } from "@langfuse/shared/src/server";
import { type QueryType } from "@/src/features/query/types";
import {
createTrace,
createObservation,
createTracesCh,
createObservationsCh,
} from "@langfuse/shared/src/server";
describe("/api/public/metrics API Endpoint", () => {
// Test setup variables
const testTraces: Array<{
id: string;
name: string;
timestamp: Date;
metadata?: Record<string, any>;
}> = [];
const testMetadataValue = randomUUID();
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
// Current time for traces
const tomorrow = new Date(new Date().getTime() + 3600 * 24 * 1000);
const now = new Date();
const yesterday = new Date(new Date().getTime() - 3600 * 24 * 1000);
const twoDaysAgo = new Date(new Date().getTime() - 3600 * 24 * 2 * 1000);
// Set up test data before running tests
beforeAll(async () => {
// Create test traces with different names
const trace1Id = randomUUID();
const trace2Id = randomUUID();
const trace3Id = randomUUID();
// Create traces with different names and timestamps
testTraces.push(
{
id: trace1Id,
name: "trace-test-1",
timestamp: now,
metadata: { test: testMetadataValue },
},
{
id: trace2Id,
name: "trace-test-2",
timestamp: yesterday,
metadata: { test: testMetadataValue },
},
{
id: trace3Id,
name: "trace-test-3",
timestamp: yesterday,
metadata: { test: testMetadataValue },
},
);
// Insert traces into database
await createTracesCh(
testTraces.map((trace) =>
createTrace({
id: trace.id,
name: trace.name,
project_id: projectId,
timestamp: trace.timestamp.getTime(),
metadata: trace.metadata || {},
}),
),
);
// Create observations for trace1
const trace1Observations = [];
for (let i = 0; i < 3; i++) {
trace1Observations.push(
createObservation({
id: randomUUID(),
trace_id: trace1Id,
project_id: projectId,
name: `observation-${i}`,
start_time: now.getTime(),
metadata: { test: testMetadataValue },
}),
);
}
// Create observations for trace2
const trace2Observations = [];
for (let i = 0; i < 2; i++) {
trace2Observations.push(
createObservation({
id: randomUUID(),
trace_id: trace2Id,
project_id: projectId,
name: `observation-${i}`,
start_time: yesterday.getTime(),
metadata: { test: testMetadataValue },
}),
);
}
await createObservationsCh([...trace1Observations, ...trace2Observations]);
});
it.each([
[
"simple trace query",
{
view: "traces",
dimensions: [{ field: "name" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [
{
column: "metadata",
operator: "contains",
key: "test",
value: testMetadataValue,
type: "stringObject",
},
],
timeDimension: null,
fromTimestamp: twoDaysAgo.toISOString(),
toTimestamp: tomorrow.toISOString(),
orderBy: null,
} as QueryType,
// Expected result structure (data will be dummy)
{
dataLength: 3,
expectedMetrics: ["count_count"],
expectedDimensions: ["name"],
},
],
[
"query with time dimension",
{
view: "traces",
dimensions: [{ field: "name" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [
{
column: "metadata",
operator: "contains",
key: "test",
value: testMetadataValue,
type: "stringObject",
},
],
timeDimension: {
granularity: "day",
},
fromTimestamp: twoDaysAgo.toISOString(),
toTimestamp: tomorrow.toISOString(),
orderBy: null,
} as QueryType,
// Expected result structure
{
dataLength: 4,
expectedMetrics: ["count_count"],
expectedDimensions: ["time_dimension", "name"],
},
],
[
"observations query with multiple metrics",
{
view: "observations",
dimensions: [{ field: "name" }],
metrics: [
{ measure: "count", aggregation: "count" },
{ measure: "latency", aggregation: "p95" },
],
filters: [
{
column: "metadata",
operator: "contains",
key: "test",
value: testMetadataValue,
type: "stringObject",
},
],
timeDimension: null,
fromTimestamp: twoDaysAgo.toISOString(),
toTimestamp: tomorrow.toISOString(),
orderBy: null,
} as QueryType,
// Expected result structure
{
dataLength: 3,
expectedMetrics: ["count_count", "p95_latency"],
expectedDimensions: ["name"],
},
],
])(
"should accept queries and return expected results: %s",
async (
_name: string,
queryObject: QueryType,
expectedResult: {
dataLength: number;
expectedMetrics: string[];
expectedDimensions: string[];
},
) => {
// Add pagination parameters needed for the API
const fullQuery = {
...queryObject,
// page: 1,
// limit: 10,
};
// Make the API call
const response = await makeZodVerifiedAPICall(
GetMetricsV1Response,
"GET",
`/api/public/metrics?query=${encodeURIComponent(JSON.stringify(fullQuery))}`,
);
// Validate response format
expect(response.status).toBe(200);
expect(Array.isArray(response.body.data)).toBe(true);
// Validate response matches expected structure
expect(response.body.data).toHaveLength(expectedResult.dataLength);
response.body.data.forEach((item) => {
expect(
expectedResult.expectedMetrics.every((metric) => metric in item),
).toBe(true);
expect(
expectedResult.expectedDimensions.every(
(dimension) => dimension in item,
),
).toBe(true);
});
},
);
it("should return 401 with invalid authentication", async () => {
const now = new Date();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
// Create the query object
const query = {
view: "traces",
metrics: [
{
measure: "totalCount",
aggregation: "count",
},
],
fromTimestamp: yesterday.toISOString(),
toTimestamp: now.toISOString(),
page: 1,
limit: 10,
};
const invalidAuth = createBasicAuthHeader(
"invalid-project-key",
"invalid-secret-key",
);
const { status } = await makeAPICall(
"GET",
`/api/public/metrics?query=${encodeURIComponent(JSON.stringify(query))}`,
undefined,
invalidAuth,
);
expect(status).toBe(401);
});
it("should return 400 for invalid query parameters", async () => {
// Test missing required parameters by sending an invalid JSON string
const { status } = await makeZodVerifiedAPICallSilent(
GetMetricsV1Response,
"GET",
`/api/public/metrics?query=invalid-json-string`,
undefined,
);
expect(status).toBe(400);
});
it("should return 400 for incomplete query object", async () => {
// Missing required fields in the query object
const incompleteQuery = {
view: "traces",
// Missing metrics, fromTimestamp, toTimestamp
};
const { status } = await makeZodVerifiedAPICallSilent(
GetMetricsV1Response,
"GET",
`/api/public/metrics?query=${encodeURIComponent(JSON.stringify(incompleteQuery))}`,
undefined,
);
expect(status).toBe(400);
});
it("should return 400 for invalid metric measure", async () => {
// Build a query with an invalid metric measure (name)
const invalidMetricNameQuery = {
view: "traces",
dimensions: [{ field: "name" }],
metrics: [{ measure: "invalidMetric", aggregation: "count" }],
fromTimestamp: new Date(
new Date().getTime() - 3600 * 24 * 1000,
).toISOString(),
toTimestamp: new Date().toISOString(),
};
const { status, body } = await makeAPICall(
"GET",
`/api/public/metrics?query=${encodeURIComponent(JSON.stringify(invalidMetricNameQuery))}`,
undefined,
);
expect(status).toBe(400);
expect(body).toHaveProperty("error");
expect(body.message).toMatch(/Invalid metric/);
});
it("should return 400 for invalid metric aggregation", async () => {
// Build a query with an invalid aggregation method
const invalidAggregationQuery = {
view: "traces",
dimensions: [{ field: "name" }],
metrics: [{ measure: "count", aggregation: "invalidAggregation" }],
fromTimestamp: new Date(
new Date().getTime() - 3600 * 24 * 1000,
).toISOString(),
toTimestamp: new Date().toISOString(),
};
const { status, body } = await makeAPICall(
"GET",
`/api/public/metrics?query=${encodeURIComponent(JSON.stringify(invalidAggregationQuery))}`,
undefined,
);
expect(status).toBe(400);
expect(body).toHaveProperty("error");
expect(body.message).toMatch(/Invalid request data/);
});
it("should return 400 for invalid dimension", async () => {
// Build a query with an invalid dimension
const invalidDimensionQuery = {
view: "traces",
dimensions: [{ field: "nonExistentDimension" }],
metrics: [{ measure: "count", aggregation: "count" }],
fromTimestamp: new Date(
new Date().getTime() - 3600 * 24 * 1000,
).toISOString(),
toTimestamp: new Date().toISOString(),
};
const { status, body } = await makeAPICall(
"GET",
`/api/public/metrics?query=${encodeURIComponent(JSON.stringify(invalidDimensionQuery))}`,
undefined,
);
expect(status).toBe(400);
expect(body).toHaveProperty("error");
expect(body.message).toMatch(/Invalid dimension/);
});
it("should return 400 for invalid filter column", async () => {
// Build a query with an invalid filter column
const invalidFilterQuery = {
view: "traces",
dimensions: [{ field: "name" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [
{
column: "nonExistentColumn",
operator: "=",
value: "test",
type: "string",
},
],
fromTimestamp: new Date(
new Date().getTime() - 3600 * 24 * 1000,
).toISOString(),
toTimestamp: new Date().toISOString(),
};
const { status, body } = await makeAPICall(
"GET",
`/api/public/metrics?query=${encodeURIComponent(JSON.stringify(invalidFilterQuery))}`,
undefined,
);
expect(status).toBe(400);
expect(body).toHaveProperty("error");
expect(body.message).toMatch(/Invalid filter column/);
});
});
@@ -0,0 +1,232 @@
/** @jest-environment node */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import type { Session } from "next-auth";
import { pruneDatabase } from "@/src/__tests__/test-utils";
import { prisma } from "@langfuse/shared/src/db";
import { appRouter } from "@/src/server/api/root";
import { createInnerTRPCContext } from "@/src/server/api/trpc";
import { TRPCError } from "@trpc/server";
describe("organization API keys trpc", () => {
const organizationId = "seed-org-id";
beforeEach(async () => await pruneDatabase());
const ownerSession: Session = {
expires: "1",
user: {
id: "user-1",
canCreateOrganizations: true,
name: "Demo User",
organizations: [
{
id: organizationId,
name: "Test Organization",
role: "OWNER",
plan: "cloud:hobby",
cloudConfig: undefined,
metadata: {},
projects: [],
},
],
featureFlags: {
excludeClickhouseRead: false,
templateFlag: true,
},
admin: true,
},
environment: {} as any,
};
const memberSession: Session = {
expires: "1",
user: {
id: "user-2",
canCreateOrganizations: true,
name: "Member User",
organizations: [
{
id: organizationId,
name: "Test Organization",
role: "MEMBER",
plan: "cloud:hobby",
cloudConfig: undefined,
metadata: {},
projects: [],
},
],
featureFlags: {
excludeClickhouseRead: false,
templateFlag: true,
},
admin: false,
},
environment: {} as any,
};
const ownerCtx = createInnerTRPCContext({ session: ownerSession });
const ownerCaller = appRouter.createCaller({ ...ownerCtx, prisma });
const memberCtx = createInnerTRPCContext({ session: memberSession });
const memberCaller = appRouter.createCaller({ ...memberCtx, prisma });
const unAuthedCtx = createInnerTRPCContext({ session: null });
const unAuthedCaller = appRouter.createCaller({ ...unAuthedCtx, prisma });
describe("organizationApiKeys.byOrganizationId", () => {
it("owner can fetch organization API keys", async () => {
// Create a test API key first
const apiKeyResult = await ownerCaller.organizationApiKeys.create({
orgId: organizationId,
note: "Test API Key",
});
expect(apiKeyResult).toBeDefined();
expect(apiKeyResult.secretKey).toBeDefined();
expect(apiKeyResult.publicKey).toBeDefined();
// Now fetch the keys
const apiKeys = await ownerCaller.organizationApiKeys.byOrganizationId({
orgId: organizationId,
});
expect(apiKeys.length).toBeGreaterThanOrEqual(1);
const newKey = apiKeys.find((key) => key.id === apiKeyResult.id);
expect(newKey?.note).toBe("Test API Key");
expect(newKey?.publicKey).toBe(apiKeyResult.publicKey);
expect(newKey?.displaySecretKey).toBeDefined();
});
it("regular member cannot fetch organization API keys", async () => {
await expect(
memberCaller.organizationApiKeys.byOrganizationId({
orgId: organizationId,
}),
).rejects.toThrow(TRPCError);
});
it("unauthenticated user cannot fetch organization API keys", async () => {
await expect(
unAuthedCaller.organizationApiKeys.byOrganizationId({
orgId: organizationId,
}),
).rejects.toThrow(TRPCError);
});
});
describe("organizationApiKeys.create", () => {
it("owner can create organization API keys", async () => {
const apiKeyResult = await ownerCaller.organizationApiKeys.create({
orgId: organizationId,
note: "Test API Key",
});
expect(apiKeyResult).toBeDefined();
expect(apiKeyResult.secretKey).toBeDefined();
expect(apiKeyResult.publicKey).toBeDefined();
expect(apiKeyResult.note).toBe("Test API Key");
});
it("regular member cannot create organization API keys", async () => {
await expect(
memberCaller.organizationApiKeys.create({
orgId: organizationId,
note: "Test API Key",
}),
).rejects.toThrow(TRPCError);
});
it("unauthenticated user cannot create organization API keys", async () => {
await expect(
unAuthedCaller.organizationApiKeys.create({
orgId: organizationId,
note: "Test API Key",
}),
).rejects.toThrow(TRPCError);
});
});
describe("organizationApiKeys.updateNote", () => {
it("owner can update API key note", async () => {
// Create a key first
const apiKeyResult = await ownerCaller.organizationApiKeys.create({
orgId: organizationId,
note: "Original Note",
});
// Update the note
await ownerCaller.organizationApiKeys.updateNote({
orgId: organizationId,
keyId: apiKeyResult.id,
note: "Updated Note",
});
// Fetch to verify
const apiKeys = await ownerCaller.organizationApiKeys.byOrganizationId({
orgId: organizationId,
});
expect(apiKeys.length).toBeGreaterThanOrEqual(1);
const updatedKey = apiKeys.find((key) => key.id === apiKeyResult.id);
expect(updatedKey?.note).toBe("Updated Note");
});
it("regular member cannot update API key note", async () => {
// Create a key as owner
const apiKeyResult = await ownerCaller.organizationApiKeys.create({
orgId: organizationId,
note: "Original Note",
});
// Try to update as member
await expect(
memberCaller.organizationApiKeys.updateNote({
orgId: organizationId,
keyId: apiKeyResult.id,
note: "Updated Note",
}),
).rejects.toThrow(TRPCError);
});
});
describe("organizationApiKeys.delete", () => {
it("owner can delete API key", async () => {
// Create a key first
const apiKeyResult = await ownerCaller.organizationApiKeys.create({
orgId: organizationId,
note: "To Be Deleted",
});
// Delete the key
await ownerCaller.organizationApiKeys.delete({
orgId: organizationId,
id: apiKeyResult.id,
});
// Verify it's gone
const apiKeys = await ownerCaller.organizationApiKeys.byOrganizationId({
orgId: organizationId,
});
const deletedKey = apiKeys.find((key) => key.id === apiKeyResult.id);
expect(deletedKey).toBeUndefined();
});
it("regular member cannot delete API key", async () => {
// Create a key as owner
const apiKeyResult = await ownerCaller.organizationApiKeys.create({
orgId: organizationId,
note: "To Be Deleted",
});
// Try to delete as member
await expect(
memberCaller.organizationApiKeys.delete({
orgId: organizationId,
id: apiKeyResult.id,
}),
).rejects.toThrow(TRPCError);
});
});
});
@@ -7,6 +7,10 @@ import {
import { prisma } from "@langfuse/shared/src/db";
import { z } from "zod";
import { randomUUID } from "crypto";
import {
createAndAddApiKeysToDb,
createBasicAuthHeader,
} from "@langfuse/shared/src/server";
// Schema for organization response
const OrganizationResponseSchema = z.object({
@@ -740,3 +744,292 @@ describe("Admin Organizations API", () => {
expect(result.body.error).toContain("Method Not Allowed");
});
});
// Schema for organization project response
const OrganizationProjectSchema = z.object({
id: z.string(),
name: z.string(),
metadata: z.record(z.unknown()).nullable(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
// Schema for organization projects list response
const OrganizationProjectsListSchema = z.object({
projects: z.array(OrganizationProjectSchema),
});
describe("Public Organizations API", () => {
// Create test data
let testOrgId: string;
let testProject1Id: string;
let testProject2Id: string;
let testApiKey: string;
let testApiSecretKey: string;
beforeAll(async () => {
// Create a test organization
const uniqueOrgName = `Test Org ${randomUUID().substring(0, 8)}`;
const org = await prisma.organization.create({
data: { name: uniqueOrgName, cloudConfig: { plan: "Team" } },
});
testOrgId = org.id;
// Create test projects
const uniqueProject1Name = `Test Project 1 ${randomUUID().substring(0, 8)}`;
const project1 = await prisma.project.create({
data: {
name: uniqueProject1Name,
orgId: testOrgId,
metadata: { type: "test", environment: "development" },
},
});
testProject1Id = project1.id;
const uniqueProject2Name = `Test Project 2 ${randomUUID().substring(0, 8)}`;
const project2 = await prisma.project.create({
data: {
name: uniqueProject2Name,
orgId: testOrgId,
metadata: { type: "test", environment: "production" },
},
});
testProject2Id = project2.id;
// Create an organization API key
const apiKey = await createAndAddApiKeysToDb({
prisma,
entityId: testOrgId,
scope: "ORGANIZATION",
note: "Test API Key for Organizations API",
predefinedKeys: {
publicKey: `pk-lf-org-${randomUUID().substring(0, 8)}`,
secretKey: `sk-lf-org-${randomUUID().substring(0, 8)}`,
},
});
testApiKey = apiKey.publicKey;
testApiSecretKey = apiKey.secretKey;
});
afterAll(async () => {
// Clean up test data
await prisma.organization.delete({
where: {
id: testOrgId,
},
});
});
describe("Organization Projects", () => {
describe("GET /api/public/organizations/projects", () => {
it("should get all organization projects with valid API key", async () => {
const response = await makeZodVerifiedAPICall(
OrganizationProjectsListSchema,
"GET",
`/api/public/organizations/projects`,
undefined,
createBasicAuthHeader(testApiKey, testApiSecretKey),
200,
);
expect(response.status).toBe(200);
expect(Array.isArray(response.body.projects)).toBe(true);
expect(response.body.projects.length).toBeGreaterThanOrEqual(2);
// Verify our test projects are in the response
const projectIds = response.body.projects.map((p) => p.id);
expect(projectIds).toContain(testProject1Id);
expect(projectIds).toContain(testProject2Id);
// Verify project structure
const project1 = response.body.projects.find(
(p) => p.id === testProject1Id,
);
expect(project1).toBeDefined();
expect(project1?.name).toBeTruthy();
expect(project1?.metadata).toEqual({
type: "test",
environment: "development",
});
expect(project1?.createdAt).toBeTruthy();
expect(project1?.updatedAt).toBeTruthy();
const project2 = response.body.projects.find(
(p) => p.id === testProject2Id,
);
expect(project2).toBeDefined();
expect(project2?.metadata).toEqual({
type: "test",
environment: "production",
});
});
it("should return 403 when using a project-scoped API key", async () => {
// Create a project API key
const projectApiKey = await createAndAddApiKeysToDb({
prisma,
entityId: testProject1Id,
scope: "PROJECT",
note: "Test Project API Key",
predefinedKeys: {
publicKey: `pk-lf-project-${randomUUID().substring(0, 8)}`,
secretKey: `sk-lf-project-${randomUUID().substring(0, 8)}`,
},
});
const result = await makeAPICall(
"GET",
`/api/public/organizations/projects`,
undefined,
createBasicAuthHeader(
projectApiKey.publicKey,
projectApiKey.secretKey,
),
);
expect(result.status).toBe(403);
expect(result.body.error).toContain(
"Organization-scoped API key required",
);
// Clean up
await prisma.apiKey.delete({
where: {
id: projectApiKey.id,
},
});
});
it("should return 401 when using invalid API key", async () => {
const result = await makeAPICall(
"GET",
`/api/public/organizations/projects`,
undefined,
createBasicAuthHeader("invalid-key", "invalid-secret"),
);
expect(result.status).toBe(401);
});
it("should return 405 for non-GET methods", async () => {
const result = await makeAPICall(
"POST",
`/api/public/organizations/projects`,
{ some: "data" },
createBasicAuthHeader(testApiKey, testApiSecretKey),
);
expect(result.status).toBe(405);
expect(result.body.error).toBe("Method not allowed");
});
it("should only return projects belonging to the organization", async () => {
// Create another organization with projects
const otherOrg = await prisma.organization.create({
data: {
name: `Other Org ${randomUUID().substring(0, 8)}`,
cloudConfig: { plan: "Team" },
},
});
const otherProject = await prisma.project.create({
data: {
name: `Other Project ${randomUUID().substring(0, 8)}`,
orgId: otherOrg.id,
},
});
// Get projects for our test organization
const response = await makeZodVerifiedAPICall(
OrganizationProjectsListSchema,
"GET",
`/api/public/organizations/projects`,
undefined,
createBasicAuthHeader(testApiKey, testApiSecretKey),
200,
);
// Verify only our organization's projects are returned
const projectIds = response.body.projects.map((p) => p.id);
expect(projectIds).toContain(testProject1Id);
expect(projectIds).toContain(testProject2Id);
expect(projectIds).not.toContain(otherProject.id);
// Clean up
await prisma.organization.delete({
where: {
id: otherOrg.id,
},
});
});
it("should handle organization with no projects", async () => {
// Create an empty organization
const emptyOrg = await prisma.organization.create({
data: {
name: `Empty Org ${randomUUID().substring(0, 8)}`,
cloudConfig: { plan: "Team" },
},
});
// Create API key for empty organization
const emptyOrgApiKey = await createAndAddApiKeysToDb({
prisma,
entityId: emptyOrg.id,
scope: "ORGANIZATION",
note: "Test API Key for Empty Org",
predefinedKeys: {
publicKey: `pk-lf-empty-${randomUUID().substring(0, 8)}`,
secretKey: `sk-lf-empty-${randomUUID().substring(0, 8)}`,
},
});
const response = await makeZodVerifiedAPICall(
OrganizationProjectsListSchema,
"GET",
`/api/public/organizations/projects`,
undefined,
createBasicAuthHeader(
emptyOrgApiKey.publicKey,
emptyOrgApiKey.secretKey,
),
200,
);
expect(response.status).toBe(200);
expect(response.body.projects).toEqual([]);
// Clean up
await prisma.organization.delete({
where: {
id: emptyOrg.id,
},
});
});
it("should handle projects with null metadata", async () => {
// Create a project with null metadata
const projectWithNullMetadata = await prisma.project.create({
data: {
name: `Null Metadata Project ${randomUUID().substring(0, 8)}`,
orgId: testOrgId,
metadata: null,
},
});
const response = await makeZodVerifiedAPICall(
OrganizationProjectsListSchema,
"GET",
`/api/public/organizations/projects`,
undefined,
createBasicAuthHeader(testApiKey, testApiSecretKey),
200,
);
expect(response.status).toBe(200);
const nullMetadataProject = response.body.projects.find(
(p) => p.id === projectWithNullMetadata.id,
);
expect(nullMetadataProject).toBeDefined();
expect(nullMetadataProject?.metadata).toBeNull();
});
});
});
});
@@ -239,13 +239,13 @@ describe("Projects API", () => {
it("should validate retention days", async () => {
const uniqueProjectName = `Test Project ${randomUUID().substring(0, 8)}`;
// Test with invalid retention days (less than 7 and not 0)
// Test with invalid retention days (less than 3 and not 0)
const invalidResult = await makeAPICall(
"POST",
"/api/public/projects",
{
name: uniqueProjectName,
retention: 5, // Invalid: less than 7 and not 0
retention: 2, // Invalid: less than 3 and not 0
},
createBasicAuthHeader(orgApiKey, orgSecretKey),
);
@@ -390,13 +390,13 @@ describe("Projects API", () => {
});
it("should validate retention days on update", async () => {
// Test with invalid retention days (less than 7 and not 0)
// Test with invalid retention days (less than 3 and not 0)
const invalidResult = await makeAPICall(
"PUT",
`/api/public/projects/${testProjectId}`,
{
name: "Updated Project Name",
retention: 5, // Invalid: less than 7 and not 0
retention: 2, // Invalid: less than 3 and not 0
},
createBasicAuthHeader(orgApiKey, orgSecretKey),
);
@@ -17,7 +17,12 @@ describe("Clickhouse Observations Repository Test", () => {
});
it("should throw if no observations are found", async () => {
await expect(getObservationById(v4(), v4())).rejects.toThrow();
await expect(
getObservationById({
id: v4(),
projectId: v4(),
}),
).rejects.toThrow();
});
it("should return an observation if exists", async () => {
@@ -58,14 +63,21 @@ describe("Clickhouse Observations Repository Test", () => {
await createObservationsCh([observation]);
const result = await getObservationById(observationId, projectId, true);
const result = await getObservationById({
id: observationId,
projectId,
fetchWithInputOutput: true,
});
if (!result) {
throw new Error("Observation not found");
}
expect(result.id).toEqual(observation.id);
expect(result.traceId).toEqual(observation.trace_id);
expect(result.projectId).toEqual(observation.project_id);
expect(result.type).toEqual(observation.type);
expect(result.metadata).toEqual(observation.metadata);
expect(result.createdAt).toEqual(new Date(observation.created_at));
expect(result.updatedAt).toEqual(new Date(observation.updated_at));
@@ -88,6 +100,59 @@ describe("Clickhouse Observations Repository Test", () => {
expect(result.outputUsage).toEqual(5678);
expect(result.totalUsage).toEqual(6912);
});
it("should return an observation if exists by traceId", async () => {
const observationId = v4();
const traceId = v4();
const observation = createObservation({
id: observationId,
trace_id: traceId,
project_id: projectId,
type: "sample_type",
metadata: {},
provided_usage_details: { input: 1234, output: 5678, total: 6912 },
provided_cost_details: { input: 100, output: 200, total: 300 },
usage_details: { input: 1234, output: 5678, total: 6912 },
cost_details: { input: 100, output: 200, total: 300 },
is_deleted: 0,
created_at: Date.now(),
updated_at: Date.now(),
start_time: Date.now(),
event_ts: Date.now(),
name: "sample_name",
level: "sample_level",
status_message: "sample_status",
version: "1.0",
input: "sample_input",
output: "sample_output",
provided_model_name: "sample_model",
internal_model_id: "sample_internal_model_id",
model_parameters: '{"something":"sample_param"}',
total_cost: 300,
prompt_id: "sample_prompt_id",
prompt_name: "sample_prompt_name",
prompt_version: 1,
end_time: Date.now(),
completion_start_time: Date.now(),
});
await createObservationsCh([observation]);
const result = await getObservationById({
id: observationId,
projectId,
traceId,
fetchWithInputOutput: true,
});
if (!result) {
throw new Error("Observation not found");
}
expect(result.id).toEqual(observation.id);
expect(result.traceId).toEqual(observation.trace_id);
expect(result.projectId).toEqual(observation.project_id);
expect(result.type).toEqual(observation.type);
});
it("should return an observation view", async () => {
const observationId = v4();
const traceId = v4();
@@ -16,13 +16,15 @@ describe("Clickhouse Traces Repository Test", () => {
});
it("should throw if no traces are found", async () => {
expect(await getTraceById({ traceId: v4(), projectId: v4() })).toBeUndefined();
expect(
await getTraceById({ traceId: v4(), projectId: v4() }),
).toBeUndefined();
});
it("should return a trace if it exists", async () => {
const traceId = v4();
const trace = {
const trace = createTrace({
id: traceId,
project_id: projectId,
session_id: v4(),
@@ -47,7 +49,7 @@ describe("Clickhouse Traces Repository Test", () => {
updated_at: Date.now(),
event_ts: Date.now(),
is_deleted: 0,
};
});
await createTracesCh([trace]);
@@ -71,7 +73,7 @@ describe("Clickhouse Traces Repository Test", () => {
expect(result.userId).toEqual(trace.user_id);
expect(result.sessionId).toEqual(trace.session_id);
expect(result.public).toEqual(trace.public);
expect(result.input).toEqual(JSON.parse(trace.input));
expect(result.input).toEqual(JSON.parse(trace.input ?? "{}"));
expect(result.output).toEqual("regular string");
expect(result.metadata).toEqual(trace.metadata);
expect(result.createdAt).toEqual(new Date(trace.created_at));
@@ -81,7 +83,7 @@ describe("Clickhouse Traces Repository Test", () => {
it("should find a trace if no timestamp is provided", async () => {
const traceId = v4();
const trace = {
const trace = createTrace({
id: traceId,
project_id: projectId,
session_id: v4(),
@@ -98,7 +100,7 @@ describe("Clickhouse Traces Repository Test", () => {
updated_at: Date.now(),
event_ts: Date.now(),
is_deleted: 0,
};
});
await createTracesCh([trace]);
@@ -126,7 +128,7 @@ describe("Clickhouse Traces Repository Test", () => {
});
it("should retrieve traces by session ID", async () => {
const sessionId = v4();
const trace1 = {
const trace1 = createTrace({
id: v4(),
project_id: projectId,
session_id: sessionId,
@@ -143,9 +145,9 @@ describe("Clickhouse Traces Repository Test", () => {
updated_at: Date.now(),
event_ts: Date.now(),
is_deleted: 0,
};
});
const trace2 = {
const trace2 = createTrace({
id: v4(),
project_id: projectId,
session_id: sessionId,
@@ -162,7 +164,7 @@ describe("Clickhouse Traces Repository Test", () => {
updated_at: Date.now(),
event_ts: Date.now(),
is_deleted: 0,
};
});
await createTracesCh([trace1, trace2]);
@@ -212,14 +214,20 @@ describe("Clickhouse Traces Repository Test", () => {
await createTracesCh([trace]);
await createObservationsCh(observations);
const exists = await checkTraceExists(projectId, traceId, new Date(), [
{
type: "stringOptions",
column: "level",
operator: "any of",
value: ["ERROR"],
},
]);
const exists = await checkTraceExists({
projectId,
traceId,
timestamp: new Date(),
filter: [
{
type: "stringOptions",
column: "level",
operator: "any of",
value: ["ERROR"],
},
],
maxTimeStamp: undefined,
});
expect(exists).toBe(true);
});
@@ -261,14 +269,20 @@ describe("Clickhouse Traces Repository Test", () => {
await createTracesCh([trace]);
await createObservationsCh(observations);
const exists = await checkTraceExists(projectId, traceId, new Date(), [
{
type: "stringOptions",
column: "level",
operator: "none of",
value: ["ERROR"],
},
]);
const exists = await checkTraceExists({
projectId,
traceId,
timestamp: new Date(),
filter: [
{
type: "stringOptions",
column: "level",
operator: "none of",
value: ["ERROR"],
},
],
maxTimeStamp: undefined,
});
expect(exists).toBe(false);
});
it("should check if trace exists without level filter", async () => {
@@ -309,7 +323,13 @@ describe("Clickhouse Traces Repository Test", () => {
await createTracesCh([trace]);
await createObservationsCh(observations);
const exists = await checkTraceExists(projectId, traceId, new Date(), []);
const exists = await checkTraceExists({
projectId,
traceId,
timestamp: new Date(),
filter: [],
maxTimeStamp: undefined,
});
expect(exists).toBe(true);
});
it("should check if trace exists with error level count > 0", async () => {
@@ -350,14 +370,20 @@ describe("Clickhouse Traces Repository Test", () => {
await createTracesCh([trace]);
await createObservationsCh(observations);
const exists = await checkTraceExists(projectId, traceId, new Date(), [
{
type: "number",
column: "errorCount",
operator: ">",
value: 0,
},
]);
const exists = await checkTraceExists({
projectId,
traceId,
timestamp: new Date(),
filter: [
{
type: "number",
column: "errorCount",
operator: ">",
value: 0,
},
],
maxTimeStamp: undefined,
});
expect(exists).toBe(true);
});
});
+1 -1
View File
@@ -187,7 +187,7 @@ describe("Media Upload API", () => {
} finally {
if (mediaId) {
result.mediaRecord = await prisma.media.findUnique({
where: { id: mediaId },
where: { projectId_id: { id: mediaId, projectId } },
});
result.traceMediaRecord = await prisma.traceMedia.findUnique({
where: {
@@ -38,6 +38,13 @@ describe("/models API Endpoints", () => {
unit: "TOKENS",
},
});
await prisma.price.createMany({
data: [
{ modelId: "model-1", usageType: "input", price: 0.001 },
{ modelId: "model-1", usageType: "output", price: 0.002 },
{ modelId: "model-1", usageType: "total", price: 0.1 },
],
});
await prisma.model.create({
data: {
id: "model-2",
@@ -51,6 +58,12 @@ describe("/models API Endpoints", () => {
unit: "TOKENS",
},
});
await prisma.price.createMany({
data: [
{ modelId: "model-2", usageType: "input", price: 0.002 },
{ modelId: "model-2", usageType: "output", price: 0.004 },
],
});
});
afterEach(async () => await pruneDatabase());
@@ -67,6 +80,11 @@ describe("/models API Endpoints", () => {
expect(models.body.data[0]).toMatchObject({
isLangfuseManaged: true,
modelName: "gpt-3.5-turbo",
prices: {
input: { price: 0.001 },
output: { price: 0.002 },
total: { price: 0.1 },
},
});
});
@@ -105,6 +123,10 @@ describe("/models API Endpoints", () => {
auth,
);
expect(customModel.body.isLangfuseManaged).toBe(false);
expect(customModel.body.prices).toMatchObject({
input: { price: 0.002 },
output: { price: 0.004 },
});
const models = await makeZodVerifiedAPICall(
GetModelsV1Response,
@@ -132,6 +154,10 @@ describe("/models API Endpoints", () => {
unit: "TOKENS",
tokenizerConfig: { tokensPerMessage: 3, tokensPerName: 1 },
isLangfuseManaged: false,
prices: {
input: { price: 0.002 },
output: { price: 0.004 },
},
});
const prices = await prisma.price.findMany({
+8 -2
View File
@@ -464,7 +464,10 @@ describe("/api/public/prompts API Endpoint", () => {
// Delay to allow for async processing
await new Promise((resolve) => setTimeout(resolve, 500));
const dbGeneration = await getObservationById(generationId, projectId);
const dbGeneration = await getObservationById({
id: generationId,
projectId,
});
expect(dbGeneration?.id).toBe(generationId);
expect(dbGeneration?.promptId).toBe(promptId);
@@ -523,7 +526,10 @@ describe("/api/public/prompts API Endpoint", () => {
expect(response.status).toBe(207);
expect(
getObservationById(generationId, "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a"),
getObservationById({
id: generationId,
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
}),
).rejects.toThrow("not found");
});
+4 -1
View File
@@ -405,7 +405,10 @@ describe("/api/public/v2/prompts API Endpoint", () => {
// Delay to allow for async processing
await new Promise((resolve) => setTimeout(resolve, 500));
const dbGeneration = await getObservationById(generationId, projectId);
const dbGeneration = await getObservationById({
id: generationId,
projectId,
});
expect(dbGeneration?.id).toBe(generationId);
expect(dbGeneration?.promptId).toBe(promptId);
@@ -2519,6 +2519,83 @@ describe("queryBuilder", () => {
expect(result.data[0].name).toBe("score-premium");
expect(parseFloat(result.data[0].avg_value)).toBeCloseTo(0.95);
});
it("LFE-4838: should filter scores-numeric by scoreName (fallback handling) without errors", async () => {
// Setup
const projectId = randomUUID();
// Create trace
const trace = createTrace({
project_id: projectId,
name: "score-name-test-trace",
environment: "production",
});
await createTracesCh([trace]);
// Create scores with different names
const scores = [
{
name: "accuracy",
traceId: trace.id,
value: 0.9,
dataType: "NUMERIC" as const,
},
{
name: "relevance",
traceId: trace.id,
value: 0.85,
dataType: "NUMERIC" as const,
},
];
await setupScores(projectId, scores);
// Define query with filter using "scoreName" instead of "name"
// This tests the fallback handling in queryBuilder.ts that handles column names ending with "Name"
const query: QueryType = {
view: "scores-numeric",
dimensions: [{ field: "name" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [
{
column: "scoreName", // Using scoreName instead of name to test the fallback logic
operator: "=",
value: "accuracy",
type: "string",
},
],
timeDimension: null,
fromTimestamp: new Date(
new Date().setDate(new Date().getDate() - 1),
).toISOString(),
toTimestamp: new Date(
new Date().setDate(new Date().getDate() + 1),
).toISOString(),
orderBy: null,
};
// Execute query
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
// Verify the compiled query contains filtering on name
expect(compiledQuery).toContain("scores_numeric.name");
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
// Assert - should only return scores with name "accuracy"
expect(result.data).toHaveLength(1);
expect(result.data[0].name).toBe("accuracy");
expect(result.data[0].count_count).toBe("1");
});
});
describe("scores-categorical view", () => {
@@ -2969,6 +3046,228 @@ describe("queryBuilder", () => {
);
});
it("should return null streamingLatency and timeToFirstToken when completion_start_time is null", async () => {
const projectId = randomUUID();
// Create trace
const trace = createTrace({
project_id: projectId,
name: "null-completion-start-time-trace",
environment: "default",
timestamp: new Date().getTime(),
});
await createTracesCh([trace]);
// Create observation with NULL completion_start_time
const startTime = new Date();
const endTime = new Date(startTime.getTime() + 1000);
const observation = createObservation({
project_id: projectId,
trace_id: trace.id,
type: "generation",
name: "model-x",
provided_model_name: "model-x",
environment: "default",
start_time: startTime.getTime(),
completion_start_time: null, // explicitly null
end_time: endTime.getTime(),
});
await createObservationsCh([observation]);
// Build query selecting metrics per observation
const query: QueryType = {
view: "observations",
dimensions: [{ field: "name" }],
metrics: [
{ measure: "timeToFirstToken", aggregation: "max" },
{ measure: "streamingLatency", aggregation: "max" },
],
filters: [
{
column: "type",
operator: "=",
value: "generation",
type: "string",
},
],
timeDimension: null,
fromTimestamp: new Date(
new Date().setDate(new Date().getDate() - 1),
).toISOString(),
toTimestamp: new Date(
new Date().setDate(new Date().getDate() + 1),
).toISOString(),
orderBy: null,
};
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
expect(result.data).toHaveLength(1);
const row = result.data[0];
expect(row.max_timeToFirstToken).toBeNull();
expect(row.max_streamingLatency).toBeNull();
});
it("should return streamingLatency and timeToFirstToken when completion_start_time is present", async () => {
const projectId = randomUUID();
// Create trace
const trace = createTrace({
project_id: projectId,
name: "null-completion-start-time-trace",
environment: "default",
timestamp: new Date().getTime(),
});
await createTracesCh([trace]);
// Create observation with NULL completion_start_time
const startTime = new Date();
const endTime = new Date(startTime.getTime() + 1000);
const observation = createObservation({
project_id: projectId,
trace_id: trace.id,
type: "generation",
name: "model-x",
provided_model_name: "model-x",
environment: "default",
start_time: startTime.getTime(),
completion_start_time: startTime.getTime() + 200,
end_time: endTime.getTime(),
});
await createObservationsCh([observation]);
// Build query selecting metrics per observation
const query: QueryType = {
view: "observations",
dimensions: [{ field: "name" }],
metrics: [
{ measure: "timeToFirstToken", aggregation: "max" },
{ measure: "streamingLatency", aggregation: "max" },
],
filters: [
{
column: "type",
operator: "=",
value: "generation",
type: "string",
},
],
timeDimension: null,
fromTimestamp: new Date(
new Date().setDate(new Date().getDate() - 1),
).toISOString(),
toTimestamp: new Date(
new Date().setDate(new Date().getDate() + 1),
).toISOString(),
orderBy: null,
};
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
expect(result.data).toHaveLength(1);
const row = result.data[0];
expect(row.max_timeToFirstToken).toBe("200");
expect(row.max_streamingLatency).toBe("800");
});
it("should calculate tokens correctly", async () => {
const projectId = randomUUID();
// Create trace
const trace = createTrace({
project_id: projectId,
name: "null-completion-start-time-trace",
environment: "default",
timestamp: new Date().getTime(),
});
await createTracesCh([trace]);
// Create observation with NULL completion_start_time
const startTime = new Date();
const endTime = new Date(startTime.getTime() + 1000);
const observation = createObservation({
project_id: projectId,
trace_id: trace.id,
type: "generation",
name: "model-x",
provided_model_name: "model-x",
environment: "default",
start_time: startTime.getTime(),
completion_start_time: startTime.getTime() + 200,
end_time: endTime.getTime(),
usage_details: {
input_tokens: 100,
input_cache_tokens: 200,
output_tokens: 300,
output_cache_tokens: 400,
total: 1000,
},
});
await createObservationsCh([observation]);
// Build query selecting metrics per observation
const query: QueryType = {
view: "observations",
dimensions: [{ field: "name" }],
metrics: [
{ measure: "inputTokens", aggregation: "sum" },
{ measure: "outputTokens", aggregation: "sum" },
{ measure: "totalTokens", aggregation: "sum" },
{ measure: "outputTokensPerSecond", aggregation: "avg" },
],
filters: [],
timeDimension: null,
fromTimestamp: new Date(
new Date().setDate(new Date().getDate() - 1),
).toISOString(),
toTimestamp: new Date(
new Date().setDate(new Date().getDate() + 1),
).toISOString(),
orderBy: null,
};
const queryBuilder = new QueryBuilder();
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
expect(result.data).toHaveLength(1);
const row = result.data[0];
expect(row.sum_inputTokens).toBe("300");
expect(row.sum_outputTokens).toBe("700");
expect(row.sum_totalTokens).toBe("1000");
});
it("should filter observations by metadata correctly", async () => {
// Setup
const projectId = randomUUID();
+4 -4
View File
@@ -78,8 +78,8 @@ describe("RateLimitService", () => {
expect(result?.res).toEqual({
scope: scope,
resource: "public-api",
points: 20,
remainingPoints: 19,
points: 30,
remainingPoints: 29,
msBeforeNext: expect.any(Number),
consumedPoints: 1,
isFirstInDuration: true,
@@ -111,8 +111,8 @@ describe("RateLimitService", () => {
expect(result?.res).toEqual({
scope: scope,
resource: "public-api",
points: 20,
remainingPoints: 18,
points: 30,
remainingPoints: 28,
msBeforeNext: expect.any(Number),
consumedPoints: 2,
isFirstInDuration: false,
@@ -5,6 +5,558 @@ import {
import { ingestionEvent } from "@langfuse/shared/src/server";
describe("OTel Resource Span Mapping", () => {
describe("Langfuse OTEL SDK spans", () => {
const publicKey = "pk-lf-1234567890";
it("should convert LF-OTEL spans to LF-events", () => {
const langfuseOtelSpans = [
{
resource: {
attributes: [
{
key: "telemetry.sdk.language",
value: { stringValue: "python" },
},
{
key: "telemetry.sdk.name",
value: { stringValue: "opentelemetry" },
},
{
key: "telemetry.sdk.version",
value: { stringValue: "1.32.0" },
},
{
key: "langfuse.environment",
value: { stringValue: "production" },
},
{ key: "langfuse.release", value: { stringValue: "0.0.1" } },
{
key: "service.name",
value: { stringValue: "unknown_service" },
},
],
},
scopeSpans: [
{
scope: {
name: "langfuse-sdk",
version: "2.60.3",
attributes: [
{
key: "public_key",
value: { stringValue: "pk-lf-1234567890" },
},
],
},
spans: [
{
traceId: {
type: "Buffer",
data: [
44, 206, 24, 247, 232, 205, 6, 90, 11, 78, 99, 78, 239,
114, 131, 145,
],
},
spanId: {
type: "Buffer",
data: [87, 240, 37, 84, 23, 151, 65, 189],
},
parentSpanId: {
type: "Buffer",
data: [223, 227, 135, 254, 167, 239, 59, 2],
},
name: "my-generation",
kind: 1,
startTimeUnixNano: {
low: 466848096,
high: 406528574,
unsigned: true,
},
endTimeUnixNano: {
low: 467248096,
high: 406528574,
unsigned: true,
},
attributes: [
{
key: "langfuse.observation.type",
value: { stringValue: "generation" },
},
{
key: "langfuse.observation.level",
value: { stringValue: "WARNING" },
},
{
key: "langfuse.observation.status_message",
value: { stringValue: "nothing to report" },
},
{
key: "langfuse.observation.input",
value: {
stringValue: '[{"role": "user", "content": "hello"}]',
},
},
{
key: "langfuse.observation.output",
value: {
stringValue:
'{"role": "assistant", "content": "what\'s up?"}',
},
},
{
key: "langfuse.observation.model.name",
value: { stringValue: "gpt-4o" },
},
{
key: "langfuse.observation.prompt.name",
value: { stringValue: "my-prompt" },
},
{
key: "langfuse.observation.prompt.version",
value: { intValue: { low: 1, high: 0, unsigned: false } },
},
{
key: "langfuse.observation.usage_details",
value: {
stringValue:
'{"input_tokens": 123, "output_tokens": 456}',
},
},
{
key: "langfuse.observation.cost_details",
value: {
stringValue:
'{"input_tokens": 0.0001, "output_tokens": 0.002}',
},
},
{
key: "langfuse.observation.completion_start_time",
value: { stringValue: '"2025-04-30T15:28:50.686390Z"' },
},
{
key: "langfuse.observation.model_parameters",
value: {
stringValue: '{"temperature": 1, "top_p": "0.2"}',
},
},
{
key: "langfuse.observation.metadata.key1",
value: { stringValue: '"value1"' },
},
{
key: "langfuse.observation.metadata.key2",
value: { stringValue: '"value2"' },
},
{
key: "langfuse.trace.name",
value: { stringValue: "test-trace" },
},
{ key: "user.id", value: { stringValue: "my-user" } },
{ key: "session.id", value: { stringValue: "my-session" } },
{
key: "langfuse.version",
value: { stringValue: "trace-0.0.1" },
},
{
key: "langfuse.trace.input",
value: {
stringValue: '[{"role": "user", "content": "hello"}]',
},
},
{
key: "langfuse.trace.output",
value: {
stringValue:
'{"role": "assistant", "content": "what\'s up?"}',
},
},
{
key: "langfuse.trace.tags",
value: {
arrayValue: { values: [{ stringValue: "tag2" }] },
},
},
{
key: "langfuse.trace.public",
value: { boolValue: true },
},
{
key: "langfuse.trace.metadata.trace-key1",
value: { stringValue: '"value1"' },
},
{
key: "langfuse.trace.metadata.trace-key2",
value: { stringValue: '"value2"' },
},
],
status: {},
},
{
traceId: {
type: "Buffer",
data: [
44, 206, 24, 247, 232, 205, 6, 90, 11, 78, 99, 78, 239,
114, 131, 145,
],
},
spanId: {
type: "Buffer",
data: [223, 227, 135, 254, 167, 239, 59, 2],
},
name: "my-span",
kind: 1,
startTimeUnixNano: {
low: 205413096,
high: 406528574,
unsigned: true,
},
endTimeUnixNano: {
low: 467608096,
high: 406528574,
unsigned: true,
},
attributes: [
{
key: "langfuse.observation.type",
value: { stringValue: "span" },
},
{
key: "langfuse.trace.tags",
value: {
arrayValue: { values: [{ stringValue: "tag1" }] },
},
},
{
key: "langfuse.trace.metadata.trace-key0",
value: { stringValue: '"value1"' },
},
],
status: {},
},
],
},
],
},
];
const events = langfuseOtelSpans.flatMap((span) =>
convertOtelSpanToIngestionEvent(span, publicKey),
);
const traceEvents = events.filter((e) => e.type === "trace-create");
const generationEvents = events.filter(
(e) => e.type === "generation-create",
);
const spanEvents = events.filter((e) => e.type === "span-create");
expect(events.length).toBe(4);
expect(traceEvents.length).toBe(2);
expect(spanEvents.length).toBe(1);
expect(generationEvents.length).toBe(1);
const spanEvent = spanEvents[0];
const generationEvent = generationEvents[0];
const defaultMetadata = {
resourceAttributes: {
"langfuse.environment": "production",
"langfuse.release": "0.0.1",
"service.name": "unknown_service",
"telemetry.sdk.language": "python",
"telemetry.sdk.name": "opentelemetry",
"telemetry.sdk.version": "1.32.0",
},
scope: {
name: "langfuse-sdk",
version: "2.60.3",
},
};
expect(spanEvent.body).toMatchObject({
id: "dfe387fea7ef3b02",
traceId: "2cce18f7e8cd065a0b4e634eef728391",
parentObservationId: null,
name: "my-span",
startTime: "2025-04-30T15:28:50.424Z",
endTime: "2025-04-30T15:28:50.687Z",
environment: "production",
metadata: { ...defaultMetadata },
level: "DEFAULT",
version: null,
modelParameters: {},
model: undefined,
promptName: null,
promptVersion: null,
usageDetails: {},
costDetails: {},
input: null,
output: null,
});
expect(generationEvent.body).toMatchObject({
id: "57f02554179741bd",
traceId: "2cce18f7e8cd065a0b4e634eef728391",
parentObservationId: "dfe387fea7ef3b02",
name: "my-generation",
startTime: "2025-04-30T15:28:50.686Z",
endTime: "2025-04-30T15:28:50.686Z",
environment: "production",
metadata: { ...defaultMetadata },
level: "WARNING",
statusMessage: "nothing to report",
version: "trace-0.0.1",
modelParameters: {},
model: "gpt-4o",
promptName: "my-prompt",
promptVersion: 1,
usageDetails: { input_tokens: 123, output_tokens: 456 },
costDetails: { input_tokens: 0.0001, output_tokens: 0.002 },
input: '[{"role": "user", "content": "hello"}]',
output: '{"role": "assistant", "content": "what\'s up?"}',
});
expect(traceEvents[0].body).toMatchObject({
id: "2cce18f7e8cd065a0b4e634eef728391",
name: "test-trace",
tags: ["tag2"],
userId: "my-user",
sessionId: "my-session",
input: '[{"role": "user", "content": "hello"}]',
output: '{"role": "assistant", "content": "what\'s up?"}',
version: "trace-0.0.1",
environment: "production",
public: true,
metadata: {
...defaultMetadata,
"trace-key1": '"value1"',
"trace-key2": '"value2"',
},
});
expect(traceEvents[1].body).toMatchObject({
id: "2cce18f7e8cd065a0b4e634eef728391",
timestamp: "2025-04-30T15:28:50.424Z",
name: "my-span",
metadata: {
...defaultMetadata,
"trace-key0": '"value1"',
},
tags: ["tag1"],
environment: "production",
});
});
it("should create a trace when as_root has been specified", () => {
const langfuseOtelSpans = [
{
resource: {
attributes: [
{
key: "telemetry.sdk.language",
value: { stringValue: "python" },
},
{
key: "telemetry.sdk.name",
value: { stringValue: "opentelemetry" },
},
{
key: "telemetry.sdk.version",
value: { stringValue: "1.32.0" },
},
{
key: "langfuse.environment",
value: { stringValue: "production" },
},
{ key: "langfuse.release", value: { stringValue: "0.0.1" } },
{
key: "service.name",
value: { stringValue: "unknown_service" },
},
],
},
scopeSpans: [
{
scope: {
name: "langfuse-sdk",
version: "2.60.3",
attributes: [
{
key: "public_key",
value: { stringValue: "pk-lf-1234567890" },
},
],
},
spans: [
{
traceId: {
type: "Buffer",
data: [
149, 243, 185, 38, 199, 208, 9, 146, 91, 203, 93, 188, 39,
49, 17, 32,
],
},
spanId: {
type: "Buffer",
data: [212, 62, 55, 183, 209, 126, 84, 118],
},
parentSpanId: {
type: "Buffer",
data: [131, 78, 40, 181, 145, 127, 190, 246],
},
name: "my-span-with-custom-trace-id",
kind: 1,
startTimeUnixNano: {
low: 1047784088,
high: 406627672,
unsigned: true,
},
endTimeUnixNano: {
low: 1149405088,
high: 406627672,
unsigned: true,
},
attributes: [
{
key: "langfuse.observation.type",
value: { stringValue: "span" },
},
{
key: "langfuse.internal.as_root",
value: { boolValue: true },
},
],
status: {},
},
],
},
],
},
];
const events = langfuseOtelSpans.flatMap((span) =>
convertOtelSpanToIngestionEvent(span, publicKey),
);
const traceEvents = events.filter((e) => e.type === "trace-create");
const spanEvents = events.filter((e) => e.type === "span-create");
expect(events.length).toBe(2);
expect(traceEvents.length).toBe(1);
expect(spanEvents.length).toBe(1);
const spanEvent = spanEvents[0];
const traceEvent = traceEvents[0];
expect(spanEvent.body).toMatchObject({
id: "d43e37b7d17e5476",
traceId: "95f3b926c7d009925bcb5dbc27311120",
parentObservationId: "834e28b5917fbef6",
name: "my-span-with-custom-trace-id",
startTime: "2025-05-05T13:42:33.936Z",
endTime: "2025-05-05T13:42:34.038Z",
environment: "production",
level: "DEFAULT",
});
expect(traceEvent.body).toMatchObject({
id: "95f3b926c7d009925bcb5dbc27311120",
timestamp: "2025-05-05T13:42:33.936Z",
name: undefined,
environment: "production",
});
});
it("should throw an error if langfuse scope spans have wrong project ID", () => {
const langfuseOtelSpans = [
{
resource: {
attributes: [
{
key: "telemetry.sdk.language",
value: {
stringValue: "python",
},
},
{
key: "telemetry.sdk.name",
value: {
stringValue: "opentelemetry",
},
},
{
key: "telemetry.sdk.version",
value: {
stringValue: "1.32.0",
},
},
{
key: "service.name",
value: {
stringValue: "unknown_service",
},
},
],
},
scopeSpans: [
{
scope: {
name: "langfuse-sdk",
version: "2.60.3",
attributes: [
{
key: "public_key",
value: {
stringValue: "pk-lf-another",
},
},
],
},
spans: [
{
traceId: {
type: "Buffer",
data: [
219, 242, 249, 255, 154, 168, 21, 165, 233, 52, 222, 186,
28, 97, 54, 95,
],
},
spanId: {
type: "Buffer",
data: [128, 191, 93, 22, 69, 180, 81, 135],
},
name: "t1",
kind: 1,
startTimeUnixNano: {
low: -1422874264,
high: 406650983,
unsigned: true,
},
endTimeUnixNano: {
low: -904695264,
high: 406650983,
unsigned: true,
},
attributes: [
{
key: "langfuse.observation.type",
value: {
stringValue: "span",
},
},
],
status: {},
},
],
},
],
},
];
expect(() =>
langfuseOtelSpans.flatMap((span) =>
convertOtelSpanToIngestionEvent(span, publicKey),
),
).toThrowError("Langfuse OTEL SDK span has different public key");
});
});
describe("Vendor Spans", () => {
it("should convert an OpenLit OTel Span to Langfuse Events", () => {
// Setup
@@ -307,6 +859,149 @@ describe("OTel Resource Span Mapping", () => {
);
expect(parsedEvents).toHaveLength(2);
});
it("LFE-5171: should convert a Semantic Kernel 1.55+ OTel Span with new event-based semantic conventions to Langfuse Events", () => {
// Setup - Semantic Kernel 1.55+ uses new event names instead of deprecated gen_ai.content.prompt/completion
const resourceSpan = {
scopeSpans: [
{
scope: { name: "Microsoft.SemanticKernel" },
spans: [
{
traceId: {
type: "Buffer",
data: [
234, 103, 55, 8, 68, 28, 41, 132, 165, 74, 62, 57, 98, 211,
89, 95,
],
},
spanId: {
type: "Buffer",
data: [185, 4, 191, 251, 32, 190, 109, 126],
},
name: "chat_completion",
kind: 3,
startTimeUnixNano: {
low: 153687506,
high: 404677085,
unsigned: true,
},
endTimeUnixNano: {
low: 1327836088,
high: 404677085,
unsigned: true,
},
attributes: [
{
key: "gen_ai.response.model",
value: { stringValue: "gpt-4-0613" },
},
],
events: [
{
timeUnixNano: {
low: 1327691067,
high: 404677085,
unsigned: true,
},
name: "gen_ai.system.message",
attributes: [
{
key: "content",
value: {
stringValue: "You are a helpful assistant.",
},
},
],
},
{
timeUnixNano: {
low: 1327691068,
high: 404677085,
unsigned: true,
},
name: "gen_ai.user.message",
attributes: [
{
key: "content",
value: {
stringValue: "What is the capital of France?",
},
},
],
},
{
timeUnixNano: {
low: 1327737136,
high: 404677085,
unsigned: true,
},
name: "gen_ai.choice",
attributes: [
{
key: "index",
value: {
intValue: { low: 0, high: 0, unsigned: false },
},
},
{
key: "finish_reason",
value: { stringValue: "stop" },
},
{
key: "message",
value: {
stringValue: JSON.stringify({
role: "assistant",
content: "The capital of France is Paris.",
}),
},
},
],
},
],
status: { code: 1 },
},
],
},
],
};
// When
const langfuseEvents = convertOtelSpanToIngestionEvent(resourceSpan);
// Then
// Will throw an error if the parsing fails
const parsedEvents = langfuseEvents.map((event) =>
ingestionEvent.parse(event),
);
expect(parsedEvents).toHaveLength(2);
// Check that input contains both system and user messages
const observationEvent = parsedEvents.find(
(event) => event.type === "generation-create",
);
expect(observationEvent?.body.input).toEqual([
{
role: "system",
content: "You are a helpful assistant.",
},
{
role: "user",
content: "What is the capital of France?",
},
]);
// Check that output contains the choice
expect(observationEvent?.body.output).toEqual({
index: 0,
finish_reason: "stop",
message: JSON.stringify({
role: "assistant",
content: "The capital of France is Paris.",
}),
});
});
});
describe("Property Mapping", () => {
@@ -1149,6 +1844,180 @@ describe("OTel Resource Span Mapping", () => {
},
},
],
// Semantic Kernel 1.55+ new event-based semantic conventions
[
"should extract input from gen_ai.system.message event",
{
entity: "observation",
otelEventName: "gen_ai.system.message",
otelEventAttributeKey: "content",
otelEventAttributeValue: {
stringValue: "You are a helpful assistant.",
},
entityAttributeKey: "input",
entityAttributeValue: [
{
role: "system",
content: "You are a helpful assistant.",
},
],
},
],
[
"should extract input from gen_ai.user.message event",
{
entity: "observation",
otelEventName: "gen_ai.user.message",
otelEventAttributeKey: "content",
otelEventAttributeValue: {
stringValue: "What is the capital of France?",
},
entityAttributeKey: "input",
entityAttributeValue: [
{
role: "user",
content: "What is the capital of France?",
},
],
},
],
[
"should extract input from gen_ai.assistant.message event with tool_calls",
{
entity: "observation",
otelEventName: "gen_ai.assistant.message",
otelEventAttributeKey: "tool_calls",
otelEventAttributeValue: {
stringValue: JSON.stringify([{
id: "call_123",
type: "function",
function: {
name: "get_weather",
arguments: '{"location": "Paris"}'
}
}]),
},
entityAttributeKey: "input",
entityAttributeValue: [
{
role: "assistant",
tool_calls: JSON.stringify([{
id: "call_123",
type: "function",
function: {
name: "get_weather",
arguments: '{"location": "Paris"}'
}
}]),
},
],
},
],
[
"should extract input from gen_ai.tool.message event",
{
entity: "observation",
otelEventName: "gen_ai.tool.message",
otelEventAttributeKey: "content",
otelEventAttributeValue: {
stringValue: "Sunny, 22°C",
},
entityAttributeKey: "input",
entityAttributeValue: [
{
role: "tool",
content: "Sunny, 22°C",
},
],
},
],
[
"should extract output from gen_ai.choice event",
{
entity: "observation",
otelEventName: "gen_ai.choice",
otelEventAttributeKey: "message",
otelEventAttributeValue: {
stringValue: JSON.stringify({
role: "assistant",
content: "The capital of France is Paris.",
}),
},
entityAttributeKey: "output",
entityAttributeValue: {
message: JSON.stringify({
role: "assistant",
content: "The capital of France is Paris.",
}),
},
},
],
[
"should extract output from gen_ai.choice event with finish_reason",
{
entity: "observation",
otelEventName: "gen_ai.choice",
otelEventAttributeKey: "finish_reason",
otelEventAttributeValue: {
stringValue: "stop",
},
entityAttributeKey: "output",
entityAttributeValue: {
finish_reason: "stop",
},
},
],
[
"should extract input from gen_ai.tool.message event with id attribute",
{
entity: "observation",
otelEventName: "gen_ai.tool.message",
otelEventAttributeKey: "id",
otelEventAttributeValue: {
stringValue: "call_456",
},
entityAttributeKey: "input",
entityAttributeValue: [
{
role: "tool",
id: "call_456",
},
],
},
],
[
"should extract output from gen_ai.choice event with index attribute",
{
entity: "observation",
otelEventName: "gen_ai.choice",
otelEventAttributeKey: "index",
otelEventAttributeValue: {
intValue: { low: 0, high: 0, unsigned: false },
},
entityAttributeKey: "output",
entityAttributeValue: {
index: 0,
},
},
],
[
"should extract input from gen_ai.assistant.message event with content",
{
entity: "observation",
otelEventName: "gen_ai.assistant.message",
otelEventAttributeKey: "content",
otelEventAttributeValue: {
stringValue: "I'll help you with that.",
},
entityAttributeKey: "input",
entityAttributeValue: [
{
role: "assistant",
content: "I'll help you with that.",
},
],
},
],
])(
"Events: %s",
(
+1 -1
View File
@@ -1,4 +1,4 @@
import chatCompletionHandler from "@/src/ee/features/playground/server/chatCompletionHandler";
import chatCompletionHandler from "@/src/features/playground/server/chatCompletionHandler";
export const dynamic = "force-dynamic";
export const maxDuration = 120;

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