Compare commits

...
182 Commits
Author SHA1 Message Date
Nimar 100ad2ecff chore: release v3.149.0 2026-01-27 16:37:16 +01:00
617e396ba9 chore: add detailed logging for QueryBuilderError on invalid filter columns (#11744)
When a filter column doesn't match any UI/CH table mapping, the error log
now includes the invalid column name, filter type, and all available columns
for the table. This helps diagnose issues where users accidentally send
filters for one table to a different table's endpoint.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 14:20:14 +00:00
marliessophieandGitHub d1754b8d52 fix(dataset-compare): adjust trace query to include timestamp and disable retries (#11743) 2026-01-27 13:37:29 +00:00
NimarandGitHub e4277eddcc fix(e2e-tests): preliminarily disable tests and change wait pattern (#11730)
* fix(e2e-tests): change wait pattern not to be fixed

* fix timeouts

* redirect after sign in

* wait for button to be enabled before click

* insane timeouts

* show debug

* remove clutter

* clean

* run test one after another

* wait for sign up

* fix double config

* disable E2E tests
2026-01-27 13:08:32 +00:00
NimarandGitHub 1b4cd50c66 feat(llm-as-a-judge): pre-filter langfuse evals out on creation (#11728) 2026-01-27 13:02:50 +00:00
NimarandGitHub e30920406f feat(playground): set thinking budget for vertex gemini models (#11741) 2026-01-27 14:03:20 +01:00
NimarandGitHub 94ed3a4b02 fix(tools): offer array names on widget x-axis (#11496) 2026-01-27 13:36:37 +01:00
Valery MeleshkinandGitHub fc41347f39 fix: media and batch delete queue config fixes (#11739) 2026-01-27 10:52:06 +00:00
Steffen SchmitzandGitHub 80ed179fa6 chore: enhance logging around TRPC errors (#11737)
* chore: enhance logging around TRPC errors

* chore: logging
2026-01-27 10:14:50 +00:00
Valery MeleshkinandGitHub 7259baff17 feat: implement preflight checks for delete operations in events, observations, and traces (#11723) 2026-01-27 09:43:39 +00:00
Hassieb PakzadandGitHub 68778ee4bc fix(support): create plain customer for users with missing name (#11734) 2026-01-27 08:13:02 +02:00
NimarandGitHub d1824a5e36 chore(events-table): add scores (#11727) 2026-01-26 22:33:12 +01:00
NimarandGitHub f30d6210dc chore(events-table): add trace id filter (#11720) 2026-01-26 14:43:42 +01:00
Valery MeleshkinandGitHub bedc8b1e59 chore: make CH send http progress slightly more often (#11719) 2026-01-26 13:33:43 +00:00
Valery MeleshkinandGitHub c3cc1d0a0d chore: report data retention behind cutoff as p90 (#11717) 2026-01-26 11:59:53 +00:00
Valery MeleshkinandGitHub d570372e4a chore: make CH send http progress slightly more often (#11716) 2026-01-26 11:36:35 +00:00
44825f5cb5 feat(auth): support multiple default orgs/projects for automated access provisioning (#11691)
* feat(auth): support multiple default orgs/projects for automated access provisioning

Extend LANGFUSE_DEFAULT_ORG_ID and LANGFUSE_DEFAULT_PROJECT_ID to accept
comma-separated lists of IDs, enabling automatic provisioning of new users
to multiple organizations and projects on signup.

- Update env.mjs Zod schemas to parse CSV strings into arrays
- Refactor createProjectMembershipsOnSignup to iterate over arrays
- Maintain backward compatibility with single-value configs
- Add documentation comment to .env.prod.example

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: update example

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 10:21:20 +00:00
Valery MeleshkinandGitHub c9aa60f3b1 fix: tweak CSV transformation stream performance (#11712) 2026-01-25 13:56:06 +00:00
Valery MeleshkinandGitHub 500d1dd0fa fix: move more logger.debug with JSON stringify under if debug (#11710) 2026-01-25 13:07:12 +00:00
Valery MeleshkinandGitHub 291b8f6420 chore: decrease data retention CH timeout (#11703) 2026-01-23 20:01:35 +00:00
Lotte VerheydenandGitHub 8174e0153e feat(support-chat): add Community Hours link with PostHog tracking (#11662) 2026-01-23 14:36:30 +01:00
Valery MeleshkinandGitHub 88da95404f fix: move early return so that pending_projects is always updates (#11679)
* fix: move early return so that pending_projects is always updates

* chore: add an oldest work item age metric

* chore: time past cutoff instead of just age

* chore: bump retention delete timeout

* fix: use the corret lower bound for reduce
2026-01-23 11:46:37 +00:00
NimarandGitHub dcee2e9e39 feat(dx): use tsgo for typechecking + parallel build command (#11682) 2026-01-22 20:21:12 +01:00
NimarandGitHub cbd07b3d73 fix(CI): only release tagged versions as docker images on hub (#11653) 2026-01-22 20:16:54 +01:00
NimarandGitHub c5467ef614 fix(events-table-ui): move trace observation table to header (#11680)
* fix(events-table-ui): move trace observation table to header

* add toggle

* move it
2026-01-22 17:59:10 +00:00
NimarandGitHub e8d841ab43 fix(events-table): show session id and user id if available (#11678)
* fix(events-table): show session id and user id if available

* fix lint
2026-01-22 15:36:37 +00:00
marliessophieandGitHub 4d0b5372e5 feat(folders): add breadcrumb navigation for prompt and dataset detail pages (#11676) 2026-01-22 13:38:40 +00:00
NimarandGitHub 04dce2d7d9 feat(trace): add experiment filters for event table (#11673) 2026-01-22 14:40:49 +01:00
Valery MeleshkinandGitHub 9e47e12b6c chore: remove subquery from deleteEventsByTraceIds (#11677) 2026-01-22 13:33:56 +00:00
Valery MeleshkinandGitHub f5328bc018 chore: use retention condition when estimating workload (#11674)
* chore: use retention condition when estimating workload

* chore: use hashes instead of {i}

* chore: cache hashes
2026-01-22 13:05:52 +00:00
NimarandGitHub 3cc6da2ff4 chore: bump lodash to 4.17.23 (#11672) 2026-01-22 11:09:34 +01:00
Steffen SchmitzandGitHub 662e202041 perf: refactor lodash imports for potentially more effective tree-shaking (#11671)
chore: refactor lodash imports for potentially more effective tree-shaking
2026-01-22 09:49:01 +00:00
6e6606f06e feat(IOPreview): add footer rendering for corrected output when corrections are enabled (#11543)
* feat(IOPreview): add footer rendering for corrected output when corrections are enabled

* style: padding

* chore: push

* chore: fix format

* chore: lint

* fix: remove unused JsonSection import

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-22 09:35:44 +00:00
NimarandGitHub 99562dbc6d feat(trace): events based observation/trace table (#11519)
* feat(trace): events based observation/trace table

* fix filter propagation

* add trace detail view

* only show to lf users

* add truncation

* full tilt

* fixplayground button

* add missing hooks

* no trace as root

* roots are arrays
2026-01-22 09:26:25 +00:00
Valery MeleshkinandGitHub 60b5706c18 fix: avoid potentialy expensive JSON.stringify when debug logging is disabled (#11661) 2026-01-21 18:40:48 +00:00
Valery MeleshkinandGitHub fb875bf26e fix(eval): yield from the loop inside createEvalJobs (#11660) 2026-01-21 16:26:47 +00:00
Nimar 695a2c01bc chore: release v3.148.0 2026-01-21 09:25:40 +01:00
Hassieb PakzadandGitHub 414c865fac fix(json-path): parse primitive strings in json root (#11654) 2026-01-20 19:40:32 +00:00
NimarandGitHub 3bf32dd981 fix(playground): allow saving new prompts after editing in playground (#11648)
* fix(playground): allow saving new prompts after editing in playground

* fix race condition

* simplify
2026-01-20 17:55:49 +00:00
NimarandGitHub 2e58620f6e fix(app): fix scrolling behavior in chrome v144 and later (#11652) 2026-01-20 17:54:52 +01:00
Valery MeleshkinandGitHub 197125b4de fix: allow using commentCount filter in batch export (#11650) 2026-01-20 16:22:51 +00:00
Valery MeleshkinGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
cbd66df315 feat: an alternative to retention queue: batch-oriented periodic jobs. (#11644)
* feat: an alternative to retention queue: batch-oriented periodic jobs.

* Update worker/src/features/batch-data-retention-cleaner/index.ts

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

* chore: validate that we only operate on supported tables

* fixing a similar potential security issue for BATCH_DELETION_TABLES

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2026-01-20 15:30:49 +00:00
NimarandGitHub 7681971dfa chore: upgrade MCP to 1.25.3 (#11645) 2026-01-20 15:08:17 +00:00
NimarandGitHub bb4cb1cb50 fix(codemirror): allow scrolling entire page with editor on chrome 144+ (#11642) 2026-01-20 14:14:02 +00:00
marliessophieandGitHub 3695c70b92 chore(corrections): enhance CorrectedOutputField with strict JSON mode and improved display logic (#11570)
* chore(corrections): enhance CorrectedOutputField with strict JSON mode and improved display logic

* chore: lint
2026-01-20 14:08:50 +00:00
marliessophieandGitHub 9cbebbf4a0 fix(scores-table-cell): add copy to clipboard functionality and fix scroll-behaviour (#11616)
* fix(scores-table-cell): add copy to clipboard functionality and fix scroll-behaviour

* fix(scores-table-cell): prevent event propagation in copy to clipboard handler
2026-01-20 13:44:38 +00:00
NimarandGitHub 8abc89b53f chore: bump jsdiff from 7.0.0 to 8.0.3 (#11639) 2026-01-20 13:26:01 +00:00
NimarandGitHub 9667816ae0 fix(trace): dont show empty string on only tool call + thinking strings (#11641) 2026-01-20 13:21:38 +00:00
NimarandGitHub 246e844450 chore: bump release bumper deps (#11638) 2026-01-20 12:46:38 +00:00
ca4fb68b53 fix(storage): add max file limit to GCS and Azure listFiles (#11637)
Apply the same LANGFUSE_S3_LIST_MAX_KEYS limit (default: 200) to Google
Cloud Storage and Azure Blob Storage listFiles methods for consistency
with S3 implementation. This prevents potential resource exhaustion when
listing large numbers of files.

Closes #11394

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 12:37:01 +00:00
02a83085e9 chore(worker): consolidate ClickhouseWriter drop logs into single summary (#11636)
Replace per-record "Max attempts reached, dropping record" log messages
with a single summary log showing the total count of dropped records.
This reduces log noise while maintaining the same error metric for alerting.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 12:24:35 +00:00
NimarandGitHub d9d9bc78a0 fix(trace): render thinking also on only tool calls (#11634)
* render thinking on only tool call

* add test
2026-01-20 10:54:53 +00:00
NimarandGitHub 90cf22da84 fix(prompts): draft button overlap (#11633) 2026-01-20 11:15:34 +01:00
Steffen SchmitzandGitHub 0147836b41 chore: increase event backfill dual write timeout to 10min (#11623) 2026-01-19 19:45:39 +01:00
marliessophieandGitHub ed11164d96 chore(annotation-queue-ui): simplify button labels (#11548)
* chore(annotation-queue-ui): simplify button labels

* chore: push
2026-01-19 15:56:20 +00:00
marliessophieandGitHub aa2369c4cb docs(corrections): link to docs from heading (#11571) 2026-01-19 15:37:04 +00:00
5ac1a18430 feat: add org audit log viewer (#11529)
* feat: add organization-level audit logs viewing

Organization-level changes (org CRUD, project CRUD, membership changes)
were being logged to the database but had no UI or API to view them.

This change adds:
- `auditLogs:read` scope to organization access rights (OWNER, ADMIN)
- `allByOrg` tRPC endpoint in auditLogsRouter for org-level audit logs
- OrgAuditLogsTable component for displaying org-level audit logs
- OrgAuditLogsSettingsPage component with entitlement/access checks
- "Audit Logs" tab in organization settings (visible with audit-logs entitlement)

Organization audit logs show changes where projectId is null, including:
organization create/update/delete, project create/delete/transfer, and
organization membership changes.

* refactor(audit-logs): unify project and org audit log tables

Consolidate OrgAuditLogsTable into AuditLogsTable using discriminated
union props to support both project and organization scopes. This
removes ~130 lines of duplicate code while preserving all functionality.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Steffen Schmitz <steffen@langfuse.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 15:33:45 +00:00
marliessophieandGitHub 7c26edfb1e style(experiments-ui): enhance PromptModelStep to display truncated labels (#11546) 2026-01-19 15:16:48 +00:00
Steffen SchmitzandGitHub ced2fd3a40 chore: remove root core binary (#11622) 2026-01-19 17:44:12 +01:00
Hassieb PakzadandGitHub 7d328b404e chore(claude-code): CC should prefer params objects when writing funcs (#11620) 2026-01-19 18:07:13 +02:00
NimarandGitHub 3f77e7a091 fix(prompts): save editor state to sessionStorage and restore on remount (#11618) 2026-01-19 16:37:57 +01:00
NimarandGitHub 4e02088135 feat(trace): render thinking / reasoning parts in trace detail (#11615) 2026-01-19 15:26:39 +01:00
NimarandGitHub 24e165d595 chore: bump turbo from 2.7.2 to 2.7.5 (#11617) 2026-01-19 14:08:37 +00:00
Hassieb PakzadandGitHub 212e88525e fix(json-path): return all results from array slice syntax (#11568) 2026-01-19 09:31:52 +00:00
steffen911 b520cad77b chore: release v3.147.0 2026-01-15 11:05:14 +01:00
NimarandGitHub 8fd5e5bfc1 fix(trace): ensure JSON expansion state is honoured across traces (#11518)
* fix(trace): ensure JSON expansion state is honoured across traces

* add disclaimer

* add fixes

* fix expansion state handling

* fix types
2026-01-15 09:40:23 +00:00
Steffen SchmitzandGitHub 3adc89e4d7 fix(slack): force project auth on slack install endpoint (#11566) 2026-01-15 08:57:34 +01:00
24195bb369 feat: cursor-based sequential processing for event propagation (#11561)
* feat: cursor-based sequential processing for event propagation

Replace lock-based parallel partition processing with cursor-based
sequential processing for the event propagation job:

- Track last processed partition in Redis cursor
- Process partitions sequentially in chronological order
- Rely on ClickHouse table TTL (12h) for partition cleanup instead of
  explicit DROP PARTITION calls
- Enforce global concurrency of 1 in queue configuration
- Remove lock functions and multi-job scheduling

This improves debuggability by keeping data in observations_batch_staging
for 12 hours, allowing verification of the data pipeline when issues occur.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: increase buffer before partition processing

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 07:38:21 +00:00
marliessophieandGitHub 69c196c42d chore(sessions): enhance TraceRow component with annotation queue item creation functionality (#11560) 2026-01-14 18:42:34 +00:00
marliessophieandGitHub 5d319e1fe3 feat(corrections): support diff viewer between actual vs. corrected output (#11556)
* feat(corrections): add corrected vs actual output diff

* chore(corrections): enhance editing state management and auto-save functionality in CorrectedOutputField
2026-01-14 18:34:45 +00:00
Hassieb PakzadandGitHub c0120f7984 fix(events-ingestion): add trace_name to direct writes (#11559)
* fix(events-ingestion): add trace_name to direct writes

* push
2026-01-14 16:55:54 +00:00
Steffen SchmitzandGitHub 535bc2d2d7 chore: reduce event dual write timestamp buffer from 3.5min to 2min (#11552)
* chore: reduce event dual write timestamp buffer from 3.5min to 2min

* Update comment for timestamp validation logic
2026-01-14 15:31:43 +00:00
NimarandGitHub a5db5aa940 chore: forbidden on excessive chatcompletion use (#11555)
* chore: forbidden on excessive chatcompletion use

* move to env.mjs
2026-01-14 14:48:23 +00:00
Jannik MaierhöferandGitHub f79a5cc52f feat(otel): support mapping of custom trace_id for litellm (#11553) 2026-01-14 15:43:17 +01:00
Valery MeleshkinandGitHub 44a8487410 chore: LANGFUSE_BATCH_PROJECT_CLEANER_SLEEP_ON_EMPTY_MS bump (#11554) 2026-01-14 13:48:05 +00:00
Valery MeleshkinandGitHub a16f4a0526 fix: fixing bullmq config for BatchProjectDelete. removing accidentally commited dataModel changes. (#11551) 2026-01-14 12:08:54 +00:00
Max DeichmannandGitHub 60183f48fc chore: add span attributes for session span (#11549) 2026-01-14 12:13:24 +01:00
NimarandGitHub 2efaace4a8 fix(filters): date picker time adjustments don't change value (#11531) 2026-01-13 19:35:11 +00:00
marliessophieandGitHub ca24bebccd chore(ui): show 1pass modal only for sign-up/sign-in input fields (#11530) 2026-01-13 18:29:07 +00:00
Valery MeleshkinandGitHub b7bb9eefe6 feat: extend BatchProjectCleanerJob to handle dataset run items (#11528) 2026-01-13 17:44:37 +00:00
marliessophieandGitHub f2125ddc43 chore(annotation-queue-ui): add onOpenChange callback for smooth scrolling behavior when the dropdown opens (#11527) 2026-01-13 17:33:29 +00:00
dc2789af31 fix(score-analytics): include source type in distribution chart legends (#11458)
Add source type (EVAL, ANNOTATION, API) to score names in distribution
chart legends to distinguish scores with identical names. This matches
the existing behavior in timeline charts and prevents confusion when
comparing e.g. 'friendliness (EVAL)' vs 'friendliness (ANNOTATION)'.

Co-authored-by: Nimar <l.nimar.b@gmail.com>
2026-01-13 16:17:59 +00:00
marliessophieandGitHub 39855914b5 chore(dataset-item-events): drop unused tables and sys_id col (#11136)
* chore(dataset-items): drop sys_id col

* chore(dataset-item-events): drop foreign key constraint from dataset_item_events

* chore(dataset-item-events): remove DatasetItemEvent model and associated migration

* chore: reorder migrations
2026-01-13 15:49:36 +00:00
marliessophieandGitHub a2b3ab9b93 feat(corrections): add global toggle for showing corrections in session and IOPreview components (#11522) 2026-01-13 14:32:42 +00:00
marliessophieandGitHub 2cdf13dc0d feat(corrections): add JSON validation toggle (#11520)
* feat(corrections): add JSON validation toggle

* chore(corrections): re-run validation if correction format changes

* chore: lint
2026-01-13 13:39:59 +00:00
Valery MeleshkinandGitHub 924d653993 fix(api): fix score API environment filtering for session scores (#11515) 2026-01-13 11:53:51 +00:00
Hassieb PakzadandGitHub e99b3a1e0e fix(otel-ingestion-ai-sdk): parse usage from both ai.usage and providerMetadata (#11498)
* fix(otel-ingestion-ai-sdk): parse usage from both ai.usage and providerMetadata

* push
2026-01-13 11:13:05 +02:00
Valery MeleshkinandGitHub 26ae2080d0 feat: move batchProjectCleaner to use BullMQ (#11504) 2026-01-12 15:46:46 +00:00
Valery MeleshkinandGitHub cd7f1b419f fix: prevent retention configuration drift when queues are backlogged (#11502) 2026-01-12 15:05:52 +00:00
marliessophieandGitHub e835a9758e fix: add search for prompt version dropdown (#11500) 2026-01-12 14:39:58 +00:00
marliessophieandGitHub 3d460b71f4 style: display message for non-annotation scores in AnnotationDrawer (#11497) 2026-01-12 14:12:43 +00:00
NimarandGitHub e33c6b42c9 fix(trace): add beta toggle instead of tab (#11495)
* fix(trace): add beta toggle instead of tab

* add hook

* fix defaults
2026-01-12 13:19:14 +00:00
marliessophieandGitHub 80e441a211 fix: update dataset name duplication logic to append "(copy)" (#11494) 2026-01-12 12:22:20 +00:00
Valery MeleshkinandGitHub 6cbb93019e fix: deleteEventsByTraceIds should respect global deletion timeout (#11491) 2026-01-12 09:58:38 +00:00
marliessophieandGitHub 7f448db0ac fix: return empty string for correction data type (#11490) 2026-01-12 09:00:42 +00:00
Valery MeleshkinandGitHub 5fae64e377 fix: make BatchProjectCleaner start conditional for events table (#11483) 2026-01-09 18:30:27 +00:00
NimarandGitHub 7aa0c4995c fix: posthog init (#11482) 2026-01-09 19:07:03 +01:00
Valery MeleshkinandGitHub a8047b629b fix: make BatchProjectCleaner start conditional for events table (#11480) 2026-01-09 17:06:10 +00:00
NimarandGitHub ae6acee9c9 fix: posthog reset (#11481) 2026-01-09 17:57:24 +01:00
NimarandGitHub 1d0567426b fix(tables): small row height spacing equalized (#11478) 2026-01-09 16:47:23 +00:00
Valery MeleshkinandGitHub d2deef409a feat: add BatchProjectCleaner as an optimization when multiple project deletions are pending (#11476)
* feat: add BatchProjectCleaner as an optimization when multiple project
deletions are pending.

* chore: add PeriodicRunner abstract base class for periodic task
execution

* chore: adjusting MutationMonitor for project deletion.

* chore: extracting RedisLock utility; lock ownership fix.
2026-01-09 15:00:12 +00:00
NimarandGitHub 240d4d7def chore: add sign up event (#11473) 2026-01-09 15:39:38 +01:00
Steffen SchmitzGitHubClaude Opus 4.5depthfirst-app[bot] <184448029+depthfirst-app[bot]@users.noreply.github.com>
99ffc45173 fix(api): make retention optional and fix metadata handling in update project (#11442)
* fix(api): make retention optional and fix metadata handling in update project

- Make retention field optional in update project API to retain existing
  setting when omitted
- Fix metadata spreading to only apply when defined, preventing null
  overwrites
- Update Fern API spec and OpenAPI documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Update web/src/ee/features/admin-api/server/projects/projectById/index.ts

Co-authored-by: depthfirst-app[bot] <184448029+depthfirst-app[bot]@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: depthfirst-app[bot] <184448029+depthfirst-app[bot]@users.noreply.github.com>
2026-01-09 13:20:06 +00:00
Steffen SchmitzGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
c6207c0f62 fix: ensure correct numeric handling of usage_details values instead of concatenation (#11472)
* fix: ensure correct numeric handling of usage_details values instead of concatenation

* Update worker/src/services/IngestionService/tests/calculateTokenCost.unit.test.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>
2026-01-09 13:18:15 +00:00
Steffen SchmitzandGitHub 8499a38958 fix: enforce plan limits for org member invites (#11455)
* fix: enforce plan limits for org member invites

* chore: lint fix
2026-01-09 13:09:02 +00:00
Max Deichmann 6f221302b7 chore: release v3.146.0 2026-01-08 20:50:45 +01:00
Max DeichmannandGitHub f6ae7c5cd4 chore: remove erd generator (#11459) 2026-01-08 19:14:49 +01:00
Valery MeleshkinandGitHub 43b271ea4c chore: double-check that a project still exists before starting potentially expensive DELETE (#11456)
* chore: double-check that a project still exists before starting potentially expensive DELETE

* chore: appling the same pre-flight SELECT to data retention queries

* fix: use feature flag for event-based test

* fix: CI for worker should be able to test event tables
2026-01-08 17:21:58 +00:00
b4fe4529d9 chore(llm-connection): allow global bedrock anthropic models (#11457)
* fix: update match patterns to include global region for Claude models

* fix: add missing newline at end of default-model-prices.json

* fix: removed global prefix from regex for models without global inference support

* fix: add missing newline at end of default-model-prices.json

---------

Co-authored-by: Nimar <l.nimar.b@gmail.com>
2026-01-08 16:39:11 +00:00
Valery MeleshkinandGitHub 725a88f5ae chore: remove mutaiton waiter code path (#11454) 2026-01-08 15:56:22 +01:00
NimarandGitHub 1b2a2c01fa chore: bump preact to 10.28.2 (#11446)
chore: bump preact
2026-01-08 09:30:59 +00:00
NimarandGitHub ae0857494b fix(playground): empty values on playground jump for microsoft semant… (#11434)
* fix(playground): empty values on playground jump for microsoft semantic kernel
instrumentation

* add file

* add test

* filter

* fx
2026-01-07 21:36:55 +00:00
Valery MeleshkinandGitHub c278bc0ca1 fix: use system.processes in async delete monitoring together with query_log (#11435) 2026-01-07 21:44:18 +01:00
Valery MeleshkinandGitHub 5571ae376a fix: fixes for delete query tracking (#11431) 2026-01-07 18:47:27 +01:00
c338b0409e fix: escape CSV headers to handle commas and special characters (#11430)
Refactored the CSV field escaping logic into a reusable `escapeCsvField`
utility function that properly escapes double quotes and wraps fields.
Applied this function to both headers and body rows, fixing an issue
where headers containing commas would break CSV parsing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 17:39:10 +00:00
Steffen SchmitzandGitHub 95394b39aa chore: revert cookie workaround after auth misconfiguration (#11427) 2026-01-07 16:27:14 +00:00
Valery MeleshkinandGitHub 1ebc413df4 feat(api): allow hight cardinality measures in v2/metrics when its topN (#11243)
* feat(api): allow hight cardinality measures in v2/metrics when its topN

* chore: getting rid of preflight in favor of using
max_bytes_before_external_group_by
2026-01-07 14:47:36 +01:00
Valery MeleshkinandGitHub 12733ff7da fix: a workaround for socket hangup issues in our delete pipeline. (#11408)
* fix: a workaround for socket hangup issues in our delete pipeline.

* chore: re-implement based on waiting for queryId
2026-01-07 14:22:18 +01:00
41f064cb0f fix(api-docs): sync Fern API types with TypeScript definitions (#11421)
* fix(api-docs): sync Fern API types with TypeScript definitions

- Update fern/apis/server/definition/commons.yml to match TypeScript types
- Add source file references to each Fern type definition
- Fix nullable vs optional type mappings:
  - .nullable() → nullable<T>
  - .nullish() → optional<nullable<T>>
  - .optional() → optional<T>
  - Always present fields → T (not optional)
- Update backend-dev-guidelines skill with Fern API sync guidelines
- Add API Documentation section to REVIEW.md

Closes #11232

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: patch

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 12:05:15 +00:00
0e23a857c6 feat: add CLICKHOUSE_ASYNC_INSERT_BUSY_TIMEOUT_MIN_MS setting (#11420)
Add configurable async_insert_busy_timeout_min_ms for ClickHouse client.
The setting is optional and when provided must be >= 50ms.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 10:47:13 +00:00
Nimar c77890c52a chore: release v3.145.0 2026-01-07 10:45:45 +01:00
Valery MeleshkinandGitHub fa072d0074 feat(api): allow selective expansion of metadata on observations-v2 endpoints (#11416) 2026-01-07 09:41:53 +01:00
0777afcd05 fix(auth): assign default org/project memberships for SSO users with existing accounts (#11413)
When users log in via SSO (e.g., Keycloak) with allowDangerousEmailAccountLinking
enabled, existing users were not being assigned to the default org/project because
createProjectMembershipsOnSignup was only called from createUser, not linkAccount.

This fix:
- Changes all prisma.create() calls to prisma.upsert() with update: {} to make
  the function idempotent and preserve existing roles
- Calls createProjectMembershipsOnSignup from linkAccount so SSO users with
  pre-existing accounts get default memberships assigned

Fixes #10907

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 16:56:41 +00:00
NimarandGitHub dba9fe80d1 fix(comments): dont expose inline data path on public api yet (#11415) 2026-01-06 17:59:37 +01:00
NimarandGitHub a2e086d7e6 fix(comments): make migration distinct (#11414) 2026-01-06 17:05:33 +01:00
NimarandGitHub 57537ad59e feat(trace): allow trace comments inline on fractions of IO data (#11171) 2026-01-06 17:02:21 +01:00
Valery MeleshkinandGitHub 07e863f44a fix: revert "fix: reverting get rid of extra IN clauses along trace and score deletion paths " (#11401)
Seems to make no real the difference to performance. Reverting my canry-revert to at least get #11101 fixed.

This reverts commit 9fe60a2971.
2026-01-06 13:12:31 +01:00
NimarandGitHub c50af94e91 fix(playground): enable parsing for double stringified msg array (#11400) 2026-01-06 12:05:56 +01:00
Steffen SchmitzandGitHub 22dade8808 chore: remove noisy debug log (#11398) 2026-01-06 10:38:09 +00:00
Nimar 41e6015580 chore: release v3.144.0 2026-01-06 11:04:54 +01:00
NimarandGitHub c01bd573bb fix(seeder): add default value for scores (#11397) 2026-01-06 11:03:52 +01:00
f45bc5ffe8 fix(security): encrypt blob storage secretAccessKey in public API (#11395)
Previously, the public API endpoint for blob storage integrations stored
secretAccessKey in plaintext, while the tRPC endpoint correctly encrypted it.

Changes:
- Encrypt secretAccessKey before storing in public API endpoint
- Add background migration to encrypt existing unencrypted secrets
- Add test verifying encryption works correctly

The background migration detects unencrypted values by attempting to decrypt
them - if decryption fails with "Invalid or corrupted cipher format", the
value is unencrypted and needs encryption. This is reliable because cloud
provider secrets (AWS/Azure/GCP) never contain colons, which are required
in the encrypted format (iv:encrypted:authTag).

Closes INT-372

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 09:40:38 +00:00
Steffen SchmitzandGitHub 3224365277 chore: upgrade golang-migrate to 4.19.1 (#11390) 2026-01-05 17:16:24 +00:00
NimarandGitHub 6ec4eb4cf5 feat(filters): add clear all button (#11387)
* Refactor: Add clear all filters button and tooltip

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

* use global tooltip provider
2026-01-05 17:46:36 +01:00
dacfb3b86e feat(traces): add refresh button for manual and periodic refresh (#11276)
* feat(traces): add refresh button for manual and periodic refresh

* use react-query pattern + add to observations table

* recalc date range on tick

* sanity check values

---------

Co-authored-by: Nimar <l.nimar.b@gmail.com>
2026-01-05 16:16:36 +01:00
Max DeichmannandGitHub 8f1b7e3db7 chore: add alpine image documentation (#11376) 2026-01-04 13:30:41 +01:00
NimarandGitHub 3570bbb22f chore: clean up error naming after eslint upgrade (#11371) 2026-01-02 23:06:09 +00:00
Max DeichmannandGitHub 5593d83f5b chore: adjust logger levels (#11370) 2026-01-02 16:52:49 +01:00
Max DeichmannandGitHub c64a9ba535 chore: add clickhouse logger (#11369) 2026-01-02 13:28:55 +01:00
Hassieb PakzadandGitHub 994b96e687 chore: revert qs and @modelcontextprotocol/sdk upgrade (#11364) 2026-01-02 00:19:15 +01:00
Max DeichmannandGitHub 23f722bf7b chore: add score v2 docs (#11356) 2026-01-01 18:47:33 +01:00
Max DeichmannandGitHub d59b6a3b62 perf: add fields api for scores v2 api (#11352) 2026-01-01 17:44:02 +01:00
Max DeichmannandGitHub 1857ae9d1e chore: upgrade playwright (#11354)
* chore: upgrade playwright

* chore: upgrade playwright
2026-01-01 15:39:50 +00:00
NimarandGitHub 9d4d99010c chore: bump lodash to 4.17.21 (#11353) 2026-01-01 14:04:44 +00:00
Max DeichmannandGitHub 1d3db1a6de chore: revert qs upgrade (#11351) 2026-01-01 13:15:18 +00:00
Max DeichmannandGitHub 1c629b9c0b chore: enable logs level trace for deletions (#11349) 2026-01-01 10:06:24 +00:00
9c799dfb33 fix(worker): improve test isolation for StorageService dependent tests (#11339)
fix(worker): improve test isolation for StorageService dependent tests by prexing files with a random value and then deleting all files with that value once the test finishes.

Co-authored-by: Nimar <l.nimar.b@gmail.com>
2025-12-31 14:01:22 +00:00
NimarandGitHub 009a932945 chore: bump qs to 6.14.1 (#11344)
* chore: upgrade turbo to 2.7.2

* Update pnpm-lock.yaml

* chore: bump qs to 6.14.1
2025-12-31 13:48:17 +00:00
NimarandGitHub 0b2cc19d92 chore: upgrade turbo to 2.7.2 (#11343)
* chore: upgrade turbo to 2.7.2

* Update pnpm-lock.yaml
2025-12-31 12:55:12 +00:00
NimarandGitHub 64bd529c98 chore: upgrade eslint to v9 (#11327) 2025-12-31 13:34:48 +01:00
Max DeichmannandGitHub 9fc7c990aa chore: reduce remainder from betterstack (#11330)
* chore: reduce remainder from betterstack

* chore: reduce remainder from betterstack
2025-12-29 20:14:33 +00:00
Max DeichmannandGitHub afbeb507db chore: adjust csp for azure-ad (#11331)
* chore: reduce remainder from betterstack

* chore: reduce remainder from betterstack
2025-12-29 19:53:48 +00:00
NimarandGitHub 86af0efae7 fix(prompts): don't throw clientside on empty labels (#11328)
* fix(prompts): don't throw clientside on empty lables

* catch earlier
2025-12-29 13:21:56 +00:00
NimarandGitHub be6ce949d6 chore: upgrade jest to v30 (#11273)
* chore: upgrade jest to v30

* fix paths

* make tests parallel again

* update

* clean
2025-12-29 09:27:52 +00:00
marliessophieandGitHub 09986a745a feat(corrections): add corrections to trace and observation preview (#11313)
* feat(corrections): add corrections to trace and observation preview

* chore: lint

* chore: rename variable

* chore: implement getMostRecentCorrection utility and update previews

* chore: push

* fix: upsert correction

* chore: push

* chore: push

* chore: push

* chore: push

* chore: add projectId, traceId, and environment props to various components for enhanced trace context

* chore: push

* chore: revert

* refactor: simplify state management in CorrectedOutputField and improve JSON error handling

* chore: remove outputCorrection from row count calculations in IOPreviewJSON and adjust scores router schema

* chore: access

* refactor: remove isSaving state from correction hooks and update cache handling
2025-12-29 09:10:29 +00:00
f1d6c26276 security: PostHog SSRF validation (#11311)
* feat: Add SSRF protection for PostHog hostname

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

* Refactor PostHog integration tests and add hostname validation

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

* Fix: Remove port from PostHog hostname in tests

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-12-24 11:14:51 +00:00
Max Deichmann 1515a49d53 chore: release v3.143.0 2025-12-23 23:52:10 +01:00
Max DeichmannandGitHub 0cec22ba56 security: upgrade langchain core (#11303)
* security: upgrade langchain

* security: upgrade langchain
2025-12-23 22:34:08 +00:00
NimarandGitHub dc23fa1d68 fix(annotation-queue): correctly parse IO in embeded viewers (#11298)
* fix(annotation-queue): correctly parse IO in embeded viewers

* fix double parsing
2025-12-23 21:51:23 +00:00
Max DeichmannandGitHub 7d7ec1ed6c security: upgrade langchain (#11302) 2025-12-23 21:08:30 +00:00
Valery MeleshkinandGitHub d18d2a0de9 fix: link to v2 endpoints from v1 (#11297) 2025-12-23 17:04:29 +00:00
marliessophieandGitHub 51fd0edf80 fix(trace-preview): optimistically populate annotation scores in trace tree (#11296)
- Updated Trace, TracePreview, and other components to use serverScores instead of scores for clarity.
- Introduced mergedScores in TraceDataContext for better score management.
- Adjusted related components to ensure consistent data handling across the application.
2025-12-23 16:17:27 +00:00
marliessophieandGitHub a9621b2673 chore: add long string value column to scores table (#11233)
* chore(prisma): rename ScoreDataType to ScoreConfigDataType and update related schema and types

* chore: update score config types

* chore: adjust score types for use cases

* fixup: adjust score types for use cases

* chore: push

* chore: push

* chore: push

* chore: push

* chore: push

* chore(corrections): add `long_string_value` to `scores` table

* fix(migrations): change `long_string_value` column type from Nullable(String) to String in scores table

* chore: add correction type definition and schema

* chore: add correction type to public API

* chore: adjust score types for use cases

* chore: add scores tests for corrections on scores v2 API

* chore: update ingestion and aggreation types

* tests: scores v1 and v2 API

* chore: update API types

* feat: enhance score type handling with data type filtering

* feat: read aggregate score types only by default

* feat: exclude CORRECTION scores in v1 and include in scores v2

* fixup: read aggregate score types only by default

* chore: push

* chore: push

* chore: types

* chore: types

* chore: types

* chore: reorder migrations

* chore: types

* chore: types

* fix: prevent association of CORRECTION scores with sessions and dataset runs

* fixup: test

* fix: test

* chore: schema

* chore: build

* chore: test

* chore: never return long_string_value but string_value for corrections

* test: add

* chore: converter

* chore: test

* fix: API return types

* chore: override correction scores to reference output

* chore: rename

* chore: rm test
2025-12-23 14:32:54 +00:00
99ff3073b6 feat(advanced-json-viewer): fully virtualized JSON Beta view with search and media attachments (#11253)
* feat: add adaptive virtualization and fix scroll handling for JSON Beta view

- Add virtualization threshold (2500 rows) to IOPreviewJSON
- Split rendering: virtualized (accordion) vs continuous (non-virtualized)
- Fix scroll capture by removing height constraints in non-virtualized mode
- Add onVirtualizationChange callback to parent components
- Create rowCount utility for threshold detection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(ui): add multi-section JSON viewer with adaptive rendering

Implement MultiSectionJsonViewer component that displays multiple JSON
objects in a single viewer with collapsible sections, sticky headers,
and adaptive virtualization.

Features:
- Multiple JSON roots in one viewer with distinct sections
- Sticky section headers that remain visible during scroll
- Search across all sections with auto-expand on matches
- Per-section line numbering and custom backgrounds
- Adaptive rendering: simple (< 500 nodes) or virtualized (> 500 nodes)
- Supports wrap/nowrap/truncate string modes with proper width handling
- Context API for custom section header/footer components

Implementation:
- MultiSectionJsonViewer: Main component with data/presentation separation
- SimpleMultiSectionViewer: Non-virtualized renderer for small datasets
- VirtualizedMultiSectionViewer: Virtualized renderer for large datasets
- useMultiSectionTreeState: Hook for building and managing section trees
- multiSectionTree utils: Tree construction with section nodes
- SectionContext: React context for section state access

Width handling fixes:
- Scrollable column uses fit-content + minWidth (tree.maxContentWidth)
- Section wrappers use fit-content in nowrap mode for full expansion
- Background color applied at section wrapper level to cover full area
- Proper overflow handling: hidden for truncate/wrap, undefined for nowrap

Integration:
- IOPreviewJSON updated to use MultiSectionJsonViewer for input/output/metadata
- Command-based search UI matching LogViewToolbar styling
- Theme support with per-section background colors

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ui): fix virtualization and stable line numbers in multi-section JSON viewer

Refactored VirtualizedMultiSectionViewer to match VirtualizedJsonViewer architecture:
- Removed nested scroll container that broke virtualization
- Fixed absolute positioning for all virtual items (headers, footers, spacers, rows)
- Added stable totalContentWidth calculation instead of reactive measurement
- Increased overscan from 50 to 500 for smoother scrolling

Made section line numbers stable and immutable:
- Section line numbers now assigned once during tree building
- Removed recomputeSectionLineNumbers function (no longer needed)
- Line numbers remain constant regardless of expansion state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* remove overscan

* fix(trace-view): eliminate flicker in JSON Beta viewer when selecting observations

When selecting an observation, the JSON Beta viewer would first render
unparsed JSON strings, then flicker and re-render with parsed data.
This caused a jarring visual transition and unnecessary tree rebuilds.

Root cause: Progressive rendering pattern used fallback (parsedInput ?? input),
causing component to render with raw data while Web Worker was parsing.

Changes:
- Add isWaitingForParsing flag to useParsedObservation hook
- Wait for parsing to complete before rendering IOPreviewJSON
- Show "Parsing data..." loading state (100-300ms typical)
- Remove fallback pattern - use only parsed data
- Remove unused props (input, output, metadata, isLoading, media)
- Fix React hooks rules violation (early return after all hooks)

Result: Single clean render with parsed data, no flicker.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(json-viewer): ensure rows fill container width in multi-section viewer

When container width exceeded calculated content width, rows would only
use content width, creating a white gap on the right side.

Solution: Calculate effectiveRowWidth as max(totalContentWidth, containerWidth)
to ensure rows always fill at least the container width.

Also added minWidth: 100% to content container for consistency.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(json-viewer): eliminate white margin with long content in nowrap mode

When stringWrapMode is "nowrap" and content exceeds container width,
individual rows would grow beyond their set width due to fit-content
children, but the parent container stayed at 100%, creating a white
margin on the right.

Solution: Match VirtualizedJsonViewer's approach:
- Parent width: nowrap ? "fit-content" : "100%"
- Parent minWidth: "100%"

In nowrap mode, parent grows to accommodate wide content, enabling
proper horizontal scrolling. In wrap/truncate modes, parent stays
constrained to 100% width.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(json-viewer): measure actual monospace font width for accurate content sizing

Replaces hardcoded 6.2px character width estimate with actual DOM measurement
of the browser's monospace font at 0.7rem. This eliminates white margin issues
on the right side when viewing long content in virtualized JSON view.

Changes:
- Add useMonospaceCharWidth hook to measure actual rendered character width
- Store measurement in sessionStorage to avoid re-measuring per session
- Integrate measured width into tree building (useTreeState, useMultiSectionTreeState)
- Update VirtualizedMultiSectionViewer to use minWidth + max-content pattern

The measurement adapts to different OS/browser monospace fonts (Menlo, Consolas,
Monaco, etc.) providing accurate width estimation regardless of platform.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: format code with prettier

* fix(json-viewer): enable search in virtualized mode

The debounce effect had an inverted condition that prevented
debouncedSearchQuery from being updated when needsVirtualization=true.
This caused search to appear broken in virtualized mode (datasets >2500 rows).

Root cause: Line 93 had `if (needsVirtualization) return;` which exited
early when virtualization was needed, preventing the search query from
being debounced and passed to MultiSectionJsonViewer.

Fix: Remove the early return condition. Search now works in both
virtualized and non-virtualized modes with 300ms debounce.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(json-viewer): improve height estimation accuracy for wrap mode

Use measured character width to calculate dynamic characters-per-line
instead of hardcoded "80 chars per line". This significantly improves
the virtualizer's initial height estimates, reducing re-measurements
during fast scrolling.

Changes:
- Add charWidth parameter to useJsonViewerLayout
- Calculate available width accounting for indent, key, colon, quotes
- Dynamically compute charsPerLine based on measured font width
- Fall back to 80-char estimate if charWidth unavailable
- Apply to both VirtualizedJsonViewer and VirtualizedMultiSectionViewer

Benefits:
- More accurate initial height estimates for wrapped strings
- Fewer layout shifts during virtualized scrolling
- Smoother performance with large datasets in wrap mode

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(json-viewer): correct wrap mode height estimation for CSS layout

Fix height estimation to match actual CSS white-space: pre-wrap behavior.
All wrapped lines (including continuations) start at the same horizontal
position after the opening quote, not from the left margin.

This improves virtualization accuracy for deeply nested wrapped strings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(json-viewer): add match count badges to multi-section viewer

Enable per-row match count badges in both virtualized and non-virtualized
multi-section JSON viewers, showing indicators like "3/5" when a row has
multiple search matches.

Changes:
- MultiSectionJsonViewer: Calculate matchCounts using getMatchCountsPerNode()
- VirtualizedMultiSectionViewer: Accept and pass matchCounts to JsonRowScrollable
- SimpleMultiSectionViewer: Accept and pass matchCounts to JsonRowScrollable

This brings multi-section viewer search UX to parity with single-section viewer.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(json-viewer): move match count badges to sticky column to prevent text wrapping

Move match count badges from scrollable column to sticky fixed column
(overlaid on line numbers/expand buttons) to prevent them from consuming
horizontal space and causing premature text wrapping in wrap mode.

Changes:
- JsonRowFixed: Add matchCount/currentMatchIndexInRow props, render badge absolutely positioned
- JsonRowScrollable: Remove badge rendering and unused props
- All viewers: Pass matchCount to JsonRowFixed instead of JsonRowScrollable

Benefits:
- Badge no longer reduces available width for wrapped text
- Badge always visible in sticky column (even when scrolling)
- Consistent position regardless of value length
- No layout shifts when badges appear/disappear

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(trace-view): add section navigation hint bar to JSON viewer

Add a thin navigation bar below the search toolbar that allows quick
jumping to Input, Output, and Metadata sections.

Features:
- Shows "Jump to: Input, Output, Metadata" with clickable section links
- Only displays links for visible sections
- Smooth scroll to section headers on click
- Compact 24px height bar with muted background
- Links styled with hover underline effect

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(trace-view): remove tinted backgrounds in JSON viewer sections

Change section backgrounds from colored tints (light blue, light green,
light purple) to transparent/white in light mode for a cleaner look.

Dark mode section backgrounds remain unchanged (dark slate, dark blue-gray,
dark purple for visual separation).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(trace-view): use clean background for section navigation bar

Change "Jump to:" navigation bar background from bg-muted/30 to bg-background
for a cleaner white appearance that matches the UI.

Section backgrounds remain with their colored tints (blue, green, purple)
for visual separation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(json-viewer): improve section scroll-to behavior for virtualized mode

Add data-section-key attributes to section elements and use querySelector
instead of parsing text content. This ensures scroll-to works correctly in
both virtualized and non-virtualized modes, and scrolls to the actual section
position rather than just making the sticky header visible.

Changes:
- VirtualizedMultiSectionViewer: Add data-section-key to section header divs
- SimpleMultiSectionViewer: Add data-section-key to section wrapper divs
- IOPreviewJSON: Use querySelector with data attribute instead of text matching

Benefits:
- Works reliably in virtualized mode (separate virtual rows)
- Scrolls to actual section position, not just sticky header
- Simpler, more maintainable code
- No text parsing needed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(json-viewer): remove debug console.log statements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(json-viewer): add scrollToSection method via ref for multi-section viewers

- Add findSectionHeaderIndex utility to find section headers by key
- Expose scrollToSection via imperative handle in both virtualized and simple viewers
- MultiSectionJsonViewer forwards ref with unified interface
- IOPreviewJSON uses ref-based scrolling instead of querySelector

Works correctly in both virtualized and non-virtualized modes, handling
dynamic section positions as sections expand/collapse.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(json-viewer): use auto scroll behavior instead of smooth for virtualizer

TanStack Virtual doesn't fully support smooth scrolling with dynamic sizing.
Changed from behavior: 'smooth' to 'auto' to avoid scroll failures.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(trace-view): remove extra spacing in section navigation bar

Removed gap-1.5 from section wrapper and added &nbsp; after comma
to tighten spacing between section names.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* test: add NASA audio file and trace creation script for media testing

- Add sounds-of-mars-one-small-step-earth.wav (NASA public domain audio)
- Add create-test-traces-with-media.ts script to generate test traces with
  different media attachment permutations (image/audio/document)
- Script creates 7 test traces for UI testing of media buttons feature

Audio file courtesy of NASA (public domain)
Source: https://www.nasa.gov/audio-and-ringtones/

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(json-viewer): add media attachment buttons to section headers

- Add MediaButtonGroup component that displays media buttons grouped by type
  (image, audio, video, document) with count badges for multiple files
- Show media buttons in JSON viewer section headers (Input, Output, Metadata)
- Support hover-to-preview and click-to-pin interaction patterns
- Filter and display media by section field
- Update section header to show "N keys" instead of "N rows" with thousands
  separator and smaller font size
- Add virtualization badge in navigation bar when data exceeds threshold
- Thread media prop through component hierarchy from IOPreview to section
  headers

Media buttons appear only when media attachments exist for a section.
Hovering shows preview, clicking pins it open for interaction.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(json-viewer): show media previews in popover instead of file icons

Replace file icon cards with actual media previews:
- Images: 96x96px preview that opens in new tab on click
- Audio: HTML5 audio player with controls
- Video: HTML5 video player with controls
- Documents: Keep file icon card (no preview available)

Also remove debug console.log statements from hover/click interaction.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(json-viewer): add delay before closing media popover on mouse leave

Add 300ms delay before closing the popover when mouse leaves the button
or popover content. This prevents premature closing when moving the mouse
from the button down to the popover.

- Clear timeout when mouse enters either button or popover content
- Apply same delay to both button and content mouseLeave handlers
- Improves UX by giving users time to move mouse between elements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* remove lgos

* move media creation to seeder

* add chatml media seeder

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Nimar <l.nimar.b@gmail.com>
2025-12-23 14:26:32 +00:00
Valery MeleshkinandGitHub c04b990c49 chore: link to v2 endpoints from v1 (#11292) 2025-12-23 13:26:40 +00:00
Valery MeleshkinandGitHub 4341215f70 chore: limit RAM usage before forcing external group by (#11290) 2025-12-23 12:50:22 +00:00
Max DeichmannandGitHub e10102eb0b feat: add org details to project API (#11288) 2025-12-23 12:05:39 +00:00
Valery MeleshkinandGitHub 3bf5a351ef chore: add a test that observation v2 supports nested metadata keys (#11277) 2025-12-22 18:04:44 +00:00
marliessophieandGitHub 223f4571c9 chore(score-configs): rename ScoreDataType -> ScoreConfigDataType (#11266)
* chore(prisma): rename ScoreDataType to ScoreConfigDataType and update related schema and types

* chore: update score config types

* chore: adjust score types for use cases

* fixup: adjust score types for use cases

* chore: push

* chore: push

* chore: push

* chore: push

* chore: push
2025-12-22 17:27:21 +00:00
Valery MeleshkinandGitHub ece6ee5144 chore: present a better articulated error on environments without V2 support. (#11274) 2025-12-22 16:27:53 +00:00
NimarandGitHub 66b850d365 chore: minor bump radix elements (#11271) 2025-12-22 15:35:37 +00:00
NimarandGitHub dddc92e683 chore: upgrade turbo to 2.7.1 (#11268) 2025-12-22 15:03:40 +00:00
NimarandGitHub 33022d7428 feat(tracing): filter observations by tool calls (#11031)
* add plan

* add test

* move adapters to shared

* allow unused _vars in worker

* fix more lint

* fix lint

* add tests

* cleanup

* fix test

* no more json column

* don't use metadata

* increase migration version

* fix test

* frontend

* test

* fix test

* fix

* import

* fix seeder

* add seeder data

* unstage

* remove migrations

* new column setup

* update

* spelling

* update

* fixup

* fix type

* update

* fix build

* don't show on public API yet
2025-12-22 09:40:20 +00:00
Hassieb PakzadandGitHub 21de8052fa fix(ingestion-ai-sdk): subtract output_reasoning_tokens from total output tokens (#11264) 2025-12-22 09:23:30 +00:00
Max Deichmann 3d71892d16 chore: release v3.142.0 2025-12-22 09:55:07 +01:00
Max DeichmannandGitHub 6bcf0fad2c security: validate webhook reditrects (#11256)
* chore: validate redirected ip[

* chore: validate redirected ip[

* chore: validate redirected ip[

* chore: validate redirected ip[
2025-12-21 20:41:31 +00:00
Hassieb PakzadandGitHub 8df81dfdb5 feat(cost-tracking): add gemini-3-flash-preview (#11255) 2025-12-21 12:25:40 +00:00
Max DeichmannandGitHub 080d73c7a5 chore: add skeleton for events table (#11250) 2025-12-20 12:10:15 +00:00
NimarandGitHub 2089a37ff5 chore: bump google cloud storage to 7.18.0 (#11249) 2025-12-20 07:37:42 +00:00
8a90191156 chore: patch bump next-auth to 4.24.13 (#11173)
* chore: patch bump next-auth to 4.24.13

* perf: events table io loading (#11178)

* perf: events table io loading

* push

* perf: events table io loading

* perf: events table io loading

* perf: events table io loading

* perf: events table io loading

* perf: events table io loading

* fix(evalService): add filter for valid_to in extractVariables (#11238)

* chore: upgrade trpc to 11.8.0 (#11239)

* chore: release v3.141.0

* perf: optimize events reads (#11241)

* fix: reverting get rid of extra IN clauses along trace and score deletion paths  (#11242)

Revert "fix: get rid of extra IN clauses along trace and score deletion paths…"

This reverts commit a7ad12da68.

---------

Co-authored-by: Max Deichmann <m.deichmann@tum.de>
Co-authored-by: marliessophie <74332854+marliessophie@users.noreply.github.com>
Co-authored-by: Valery Meleshkin <valeriy@langfuse.com>
2025-12-20 07:11:25 +00:00
Valery MeleshkinandGitHub 9fe60a2971 fix: reverting get rid of extra IN clauses along trace and score deletion paths (#11242)
Revert "fix: get rid of extra IN clauses along trace and score deletion paths…"

This reverts commit a7ad12da68.
2025-12-19 15:30:44 +00:00
Max DeichmannandGitHub 0dd2a1ce0c perf: optimize events reads (#11241) 2025-12-19 14:50:48 +00:00
591 changed files with 28296 additions and 11776 deletions
@@ -50,6 +50,7 @@ Automatically activates when working on:
- [ ] **Authentication**: Authorization via basic auth
- [ ] **Validation**: Zod schemas for query/body/response
- [ ] **Versioning**: Versioning in API path and Zod schemas for query/body/response
- [ ] **Fern API Docs**: Update `fern/apis/server/definition/` to match TypeScript types
- [ ] **Tests**: Add end-to-end test in `__tests__/async/`
### New Queue Processor Checklist (Worker)
@@ -423,6 +424,32 @@ const traces = await queryClickhouse({
});
```
### 9. Keep Fern API Definitions in Sync with TypeScript Types
When modifying public API types in `web/src/features/public-api/types/`, the corresponding Fern API definitions in `fern/apis/server/definition/` must be updated to match.
**Zod to Fern Type Mapping:**
| Zod Type | Fern Type | Example |
| -------- | --------- | ------- |
| `.nullish()` | `optional<nullable<T>>` | `z.string().nullish()``optional<nullable<string>>` |
| `.nullable()` | `nullable<T>` | `z.string().nullable()``nullable<string>` |
| `.optional()` | `optional<T>` | `z.string().optional()``optional<string>` |
| Always present | `T` | `z.string()``string` |
**Source References:**
Add a comment at the top of each Fern type referencing the TypeScript source file:
```yaml
# Source: web/src/features/public-api/types/traces.ts - APITrace
Trace:
properties:
id: string
name:
type: nullable<string>
```
---
## Common Imports
@@ -645,7 +645,42 @@ async doIt()
async execute()
```
### 3. Return Types
### 3. Use Params Objects for Multiple Arguments
When a function receives multiple arguments, use a single params object instead of positional arguments:
```typescript
// ❌ BAD - Positional arguments are unclear and can be swapped
async function createTrace(
projectId: string,
userId: string,
sessionId: string,
name: string,
) {}
// Call site - which string is which?
await createTrace(projectId, userId, sessionId, name);
// ✅ GOOD - Params object makes intent clear
async function createTrace(params: {
projectId: string;
userId: string;
sessionId: string;
name: string;
}) {}
// Call site - clear and prevents argument swapping bugs
await createTrace({ projectId, userId, sessionId, name });
```
**Benefits:**
- More readable at call sites
- Prevents bugs when positional arguments of the same type are accidentally swapped
- Easier to add optional parameters later
- Self-documenting code
### 4. Return Types
Always use explicit return types:
@@ -659,7 +694,7 @@ async deleteUser(id: string): Promise<void> {}
async createUser(data) {} // No types!
```
### 4. Error Handling
### 5. Error Handling
Services should throw meaningful errors:
@@ -679,7 +714,7 @@ if (!user) {
}
```
### 5. Avoid God Services
### 6. Avoid God Services
Don't create services that do everything:
+1 -1
View File
@@ -2,7 +2,7 @@
FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:20-bookworm
# Install golang-migrate for database migrations
RUN curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.0/migrate.linux-amd64.tar.gz | tar xvz && \
RUN curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.1/migrate.linux-amd64.tar.gz | tar xvz && \
chmod +x migrate && \
mv migrate /usr/local/bin/migrate
+2
View File
@@ -147,3 +147,5 @@ LANGFUSE_AWS_BEDROCK_MODEL="eu.anthropic.claude-3-haiku-20240307-v1:0"
# Events table migration
LANGFUSE_ENABLE_EVENTS_TABLE_OBSERVATIONS=true
LANGFUSE_ENABLE_EVENTS_TABLE_FLAGS=true
LANGFUSE_ENABLE_EVENTS_TABLE_V2_APIS=true
LANGFUSE_EXPERIMENT_INSERT_INTO_EVENTS_TABLE=true
+2
View File
@@ -41,8 +41,10 @@ OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318"
OTEL_SERVICE_NAME="langfuse"
# Default role for users who sign up, optional, can be org or org+project
# Supports comma-separated IDs for multiple orgs (e.g., "org1,org2,org3")
# LANGFUSE_DEFAULT_ORG_ID=
# LANGFUSE_DEFAULT_ORG_ROLE=
# Supports comma-separated IDs for multiple projects (e.g., "proj1,proj2,proj3")
# LANGFUSE_DEFAULT_PROJECT_ID=
# LANGFUSE_DEFAULT_PROJECT_ROLE=
-4
View File
@@ -1,4 +0,0 @@
node_modules
**/node_modules
build
coverage
+20 -8
View File
@@ -172,7 +172,7 @@ jobs:
- uses: actions/checkout@v4
- name: Install golang-migrate for Clickhouse migrations
run: |
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.0/migrate.linux-amd64.tar.gz | tar xvz
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.1/migrate.linux-amd64.tar.gz | tar xvz
sudo mv migrate /usr/bin/migrate
which migrate
- uses: pnpm/action-setup@v3
@@ -269,7 +269,7 @@ jobs:
- uses: actions/checkout@v4
- name: Install golang-migrate for Clickhouse migrations
run: |
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.0/migrate.linux-amd64.tar.gz | tar xvz
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.1/migrate.linux-amd64.tar.gz | tar xvz
sudo mv migrate /usr/bin/migrate
which migrate
- uses: pnpm/action-setup@v3
@@ -396,7 +396,7 @@ jobs:
pnpm install
- name: Install golang-migrate for Clickhouse migrations
run: |
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.0/migrate.linux-amd64.tar.gz | tar xvz
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.1/migrate.linux-amd64.tar.gz | tar xvz
sudo mv migrate /usr/bin/migrate
which migrate
- name: Load default env
@@ -422,6 +422,18 @@ jobs:
pnpm run db:migrate
pnpm run db:seed
pnpm run --filter=shared ch:up
- name: Download and extract ClickHouse client for dev-tables setup
if: matrix.deploy-mode == ''
run: |
CH_VERSION="24.9.3.128"
ARCH="amd64"
wget "https://packages.clickhouse.com/deb/pool/main/c/clickhouse/clickhouse-common-static_${CH_VERSION}_${ARCH}.deb" -O clickhouse-client.deb
dpkg-deb -x clickhouse-client.deb ch_client_dir
sudo cp ch_client_dir/usr/bin/clickhouse /usr/local/bin/
- name: Setup Dev Tables
if: matrix.deploy-mode == ''
run: |
pnpm --filter=shared ch:dev-tables
- name: Build
run: pnpm --filter=worker... run build
- name: run tests
@@ -456,7 +468,7 @@ jobs:
pnpm install
- name: Install golang-migrate for Clickhouse migrations
run: |
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.0/migrate.linux-amd64.tar.gz | tar xvz
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.1/migrate.linux-amd64.tar.gz | tar xvz
sudo mv migrate /usr/bin/migrate
which migrate
- name: Load default env
@@ -548,7 +560,7 @@ jobs:
cp .env.dev.example web/.env
- name: Install golang-migrate for Clickhouse migrations
run: |
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.0/migrate.linux-amd64.tar.gz | tar xvz
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.1/migrate.linux-amd64.tar.gz | tar xvz
sudo mv migrate /usr/bin/migrate
which migrate
- name: Run + migrate
@@ -604,7 +616,7 @@ jobs:
pnpm install
- name: Install golang-migrate for Clickhouse migrations
run: |
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.0/migrate.linux-amd64.tar.gz | tar xvz
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.1/migrate.linux-amd64.tar.gz | tar xvz
sudo mv migrate /usr/bin/migrate
which migrate
- name: Load default env
@@ -653,7 +665,7 @@ jobs:
tests-web-sync,
tests-worker,
test-worker-llm-connections,
e2e-tests,
# e2e-tests, # Temporarily disabled - flaky timeouts
test-docker-build,
e2e-server-tests,
tests-web-async,
@@ -693,7 +705,7 @@ jobs:
push-docker-image:
needs: all-ci-passed
# if something inside all-ci-passed was skipped, but everything that ran passed, we still want to deploy
if: always() && needs.all-ci-passed.outputs.success == 'true' && github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
if: always() && needs.all-ci-passed.outputs.success == 'true' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
environment: "protected branches"
runs-on: ubuntu-latest
permissions:
+2
View File
@@ -17,6 +17,7 @@ migrate
# next.js
/.next/
/.next-check/
/out/
next-env.d.ts
@@ -64,6 +65,7 @@ node_modules
**/node_modules
**/dist
**/.next/*
**/.next-check/*
**/.turbo/*
.yarn
.turbo
+7 -2
View File
@@ -28,6 +28,11 @@
"editor.defaultFormatter": "Prisma.prisma"
},
"eslint.lintTask.enable": true,
"eslint.workingDirectories": ["./web", "./worker"],
"eslint.useFlatConfig": false
"eslint.workingDirectories": [
{ "pattern": "./web" },
{ "pattern": "./worker" },
{ "pattern": "./packages/shared" },
{ "pattern": "./ee" }
],
"eslint.useFlatConfig": true
}
+21 -4
View File
@@ -72,9 +72,11 @@ pnpm run infra:dev:up # Start Docker services (PostgreSQL, ClickHouse, Redi
pnpm run infra:dev:down # Stop Docker services
```
### Building
### Building & Type Checking
```sh
pnpm --filter=PACKAGE_NAME run build # Runs the build command, will show real typescript errors etc.
pnpm tc # Fast typecheck across all packages (alias for pnpm typecheck)
pnpm build:check # Full Next.js build to alternate dir (can run parallel with dev server)
```
### Testing in Web Package
@@ -82,11 +84,11 @@ The web package uses JEST for unit tests.
Depending on the file location (sync, async)
`web` related tests must go into the `web/src/__tests__/` folder.
```sh
pnpm test-sync --testPathPattern="$FILE_LOCATION_PATTERN" --testNamePattern="$TEST_NAME_PATTERN"
pnpm test-sync --testPathPatterns="$FILE_LOCATION_PATTERN" --testNamePattern="$TEST_NAME_PATTERN"
# For tests in the async folder:
pnpm test -- --testPathPattern="$FILE_LOCATION_PATTERN" --testNamePattern="$TEST_NAME_PATTERN"
pnpm test -- --testPathPatterns="$FILE_LOCATION_PATTERN" --testNamePattern="$TEST_NAME_PATTERN"
# For client tests:
pnpm test-client --testPathPattern="buildStepData" --testNamePattern="buildStepData"
pnpm test-client --testPathPatterns="buildStepData" --testNamePattern="buildStepData"
```
### Testing in the Worker Package
@@ -192,6 +194,21 @@ To get a project, use the `get_project` capability with the full project name as
## TypeScript Best Practices
- In TypeScript, if possible, don't use the `any` type
- **Use a single params object for functions with multiple arguments** - This makes code more readable at call sites and prevents bugs when arguments of the same type are accidentally swapped:
```typescript
// ❌ Bad - positional arguments are unclear and can be swapped without type errors
function sendMessage(userId: string, sessionId: string, projectId: string) {
// ...
}
sendMessage(someString, someOtherString, anotherString); // Which is which?
// ✅ Good - params object makes intent clear and prevents argument swapping
function sendMessage(params: { userId: string; sessionId: string; projectId: string }) {
// ...
}
sendMessage({ userId: someString, sessionId: someOtherString, projectId: anotherString });
```
## General Coding Guidelines
- For easier code reviews, prefer not to move functions etc around within a file unless necessary or instructed to do so
+6 -7
View File
@@ -94,8 +94,6 @@ The diagram below may not show all relationships if the foreign key is not defin
Full database schema: [packages/shared/prisma/schema.prisma](packages/shared/prisma/schema.prisma)
<img src="./packages/shared/prisma/database.svg">
## Repository Structure
We built a monorepo using [pnpm](https://pnpm.io/motivation) and [turbo](https://turbo.build/repo/docs) to manage the dependencies and build process. The monorepo contains the following packages:
@@ -122,8 +120,8 @@ Requirements
**Steps**
1. Install development dependencies:
- [golang-migrate](https://github.com/golang-migrate/migrate/tree/master/cmd/migrate#migrate-cli) as CLI
- [clickhouse binary](https://clickhouse.com/docs/install) on macOS with brew: `brew install --cask clickhouse`
- [golang-migrate](https://github.com/golang-migrate/migrate/tree/master/cmd/migrate#migrate-cli) as CLI
- [clickhouse binary](https://clickhouse.com/docs/install) on macOS with brew: `brew install --cask clickhouse`
2. Fork the repository and clone it locally
@@ -182,6 +180,8 @@ pnpm run db:seed:examples
pnpm install
pnpm run dev
pnpm --filter=web run dev # execute command only in one package
pnpm tc # fast typecheck all packages
pnpm build:check # Full Next.js build to alternate dir (can run parallel with dev server)
```
In the root `package.json`, you can find scripts which are executed with turbo e.g. `turbo run dev`. These scripts are executed with the help of Turbo. Turbo executes the commands in all packages taking care of the correct order of execution. Task definitions can be found in the `turbo.config.js` file.
@@ -254,9 +254,9 @@ To run a specific test, for example the test: `"should handle special characters
```sh
cd web # or with --filter=web
pnpm test-sync --testPathPattern="prompts\.v2\.servertest" --testNamePattern="should handle special characters in prompt names"
pnpm test-sync --testPathPatterns="prompts\.v2\.servertest" --testNamePattern="should handle special characters in prompt names"
# for async folder tests:
pnpm test -- --testPathPattern="observations-api" --testNamePattern="should fetch all observations"
pnpm test -- --testPathPatterns="observations-api" --testNamePattern="should fetch all observations"
```
To run all tests:
@@ -424,4 +424,3 @@ npx fern-api generate --api organizations # for the organizations API
Langfuse is MIT licensed, except for `ee/` folder. See [LICENSE](LICENSE) and [docs](https://langfuse.com/docs/open-source) for more details.
When contributing to the Langfuse codebase, you need to agree to the [Contributor License Agreement](https://cla-assistant.io/langfuse/langfuse). You only need to do this once and the CLA bot will remind you if you haven't signed it yet.
+5 -3
View File
@@ -76,8 +76,6 @@ Langfuse is an **open source LLM engineering** platform. It helps teams collabor
[![Langfuse Overview Video](https://github.com/user-attachments/assets/925d71db-6331-445e-8f3e-727ee95d1c9f)](https://langfuse.com/watch-demo)
## ✨ Core Features
<img width="4856" height="1944" alt="Langfuse Overview" src="https://github.com/user-attachments/assets/5dac68ef-d546-49fb-b06f-cfafc19282e3" />
@@ -144,7 +142,7 @@ See [self-hosting documentation](https://langfuse.com/self-hosting) to learn mor
| [Haystack](https://langfuse.com/docs/integrations/haystack) | Python | Automated instrumentation via Haystack content tracing system. |
| [LiteLLM](https://langfuse.com/docs/integrations/litellm) | Python, JS/TS (proxy only) | Use any LLM as a drop in replacement for GPT. Use Azure, OpenAI, Cohere, Anthropic, Ollama, VLLM, Sagemaker, HuggingFace, Replicate (100+ LLMs). |
| [Vercel AI SDK](https://langfuse.com/docs/integrations/vercel-ai-sdk) | JS/TS | TypeScript toolkit designed to help developers build AI-powered applications with React, Next.js, Vue, Svelte, Node.js. |
| [Mastra](https://langfuse.com/docs/integrations/mastra) | JS/TS | Open source framework for building AI agents and multi-agent systems. |
| [Mastra](https://langfuse.com/docs/integrations/mastra) | JS/TS | Open source framework for building AI agents and multi-agent systems. |
| [API](https://langfuse.com/docs/api) | | Directly call the public API. OpenAPI spec available. |
### Packages integrated with Langfuse:
@@ -259,6 +257,10 @@ Your contributions are welcome!
This repository is MIT licensed, except for the `ee` folders. See [LICENSE](LICENSE) and [docs](https://langfuse.com/docs/open-source) for more details.
## Dependencies
We deploy this code base in Docker containers based on the Linux Alpine Image ([source](https://github.com/nodejs/docker-node)). You may find the Dockerfiles in [web/Dockerfile](web/Dockerfile) and [worker/Dockerfile](worker/Dockerfile).
## ⭐️ Star History
<a href="https://star-history.com/#langfuse/langfuse&Date">
+7
View File
@@ -45,3 +45,10 @@
## Seeder
- make sure that for new features with data model changes, the database seeder is adjusted.
## API Documentation
- Whenever a file in `web/src/features/public-api/types` changes, the `fern/apis` definition probably needs to be adjusted, too.
- `nullish` types should map to `optional<nullable<T>>` in fern.
- `nullable` types should map to `nullable<T>` in fern.
- `optional` types should map to `optional<T>` in fern.
BIN
View File
Binary file not shown.
-8
View File
@@ -1,8 +0,0 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
extends: ["@repo/eslint-config/library.js"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
},
};
+3
View File
@@ -0,0 +1,3 @@
import baseConfig from "@repo/eslint-config";
export default [...baseConfig];
+10 -7
View File
@@ -19,27 +19,30 @@
},
"scripts": {
"build": "tsc",
"build:check": "tsc",
"typecheck": "dotenv -e ../../.env -- tsgo --noEmit --skipLibCheck --incremental --tsBuildInfoFile .tsbuildinfo",
"dev": "tsc --watch",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 0",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix"
"lint": "eslint . --cache --cache-location dist/.eslintcache --max-warnings 0",
"lint:fix": "eslint . --cache --cache-location dist/.eslintcache --fix"
},
"dependencies": {
"@langfuse/shared": "workspace:*",
"@opentelemetry/api": ">=1.0.0 <1.10.0",
"https-proxy-agent": "^7.0.6",
"next": "15.5.9",
"next-auth": "^4.24.12",
"next-auth": "^4.24.13",
"zod": "^3.25.62"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/node": "^24.3.0",
"@typescript-eslint/parser": "^8.48.1",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"@typescript-eslint/parser": "^8.50.1",
"@typescript/native-preview": "7.0.0-dev.20260122.3",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-config-standard": "^17.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-prettier": "^5.5.4",
"prettier": "^3.6.2",
"ts-node": "^10.9.2",
"tsc-watch": "^6.2.0",
+5 -2
View File
@@ -18,7 +18,9 @@ types:
sessionId: optional<string>
observationId: optional<string>
datasetRunId: optional<string>
name: string
name:
type: string
docs: The name of the score. Always overrides "output" for correction scores.
value:
type: CreateScoreValue
docs: The value of the score. Must be passed as string for categorical scores, and numeric for boolean and numeric scores. Boolean score values must equal either 1 or 0 (true or false)
@@ -81,10 +83,11 @@ types:
- NUMERIC
- CATEGORICAL
- BOOLEAN
- CORRECTION
CreateScoreValue:
discriminated: false
union:
- double
- string
docs: The value of the score. Must be passed as string for categorical scores, and numeric for boolean and numeric scores
docs: The value of the score. Must be passed as string for categorical scores, and numeric for boolean and numeric scores. For corrections pass value as string and set data type to CORRECTION
+152 -89
View File
@@ -1,5 +1,6 @@
types:
# Objects
# Source: web/src/features/public-api/types/traces.ts - APITrace, APIExtendedTrace, GetTraceV1Response
Trace:
properties:
id:
@@ -9,7 +10,7 @@ types:
type: datetime
docs: The timestamp when the trace was created
name:
type: optional<string>
type: nullable<string>
docs: The name of the trace
input:
type: optional<unknown>
@@ -18,28 +19,28 @@ types:
type: optional<unknown>
docs: The output data of the trace. Can be any JSON.
sessionId:
type: optional<string>
type: nullable<string>
docs: The session identifier associated with the trace
release:
type: optional<string>
type: nullable<string>
docs: The release version of the application when the trace was created
version:
type: optional<string>
type: nullable<string>
docs: The version of the trace
userId:
type: optional<string>
type: nullable<string>
docs: The user identifier associated with the trace
metadata:
type: optional<unknown>
docs: The metadata associated with the trace. Can be any JSON.
tags:
type: optional<list<string>>
docs: The tags associated with the trace. Can be an array of strings or null.
type: list<string>
docs: The tags associated with the trace.
public:
type: optional<boolean>
type: boolean
docs: Public traces are accessible via url without login
environment:
type: optional<string>
type: string
docs: The environment from which this trace originated. Can be any lowercase alphanumeric string with hyphens and underscores that does not start with 'langfuse'.
TraceWithDetails: # GET /traces
extends: Trace
@@ -48,16 +49,16 @@ types:
type: string
docs: Path of trace in Langfuse UI
latency:
type: double
type: optional<nullable<double>>
docs: Latency of trace in seconds
totalCost:
type: double
type: optional<nullable<double>>
docs: Cost of trace in USD
observations:
type: list<string>
type: optional<nullable<list<string>>>
docs: List of observation ids
scores:
type: list<string>
type: optional<nullable<list<string>>>
docs: List of score ids
TraceWithFullDetails: # GET traces/[traceID]
extends: Trace
@@ -66,10 +67,10 @@ types:
type: string
docs: Path of trace in Langfuse UI
latency:
type: double
type: optional<nullable<double>>
docs: Latency of trace in seconds
totalCost:
type: double
type: optional<nullable<double>>
docs: Cost of trace in USD
observations:
type: list<ObservationsView>
@@ -77,119 +78,121 @@ types:
scores:
type: list<ScoreV1>
docs: List of scores
# Source: web/src/features/public-api/types/sessions.ts - APISession
Session:
properties:
id: string
createdAt: datetime
projectId: string
environment:
type: optional<string>
type: string
docs: The environment from which this session originated.
SessionWithTraces:
extends: Session
properties:
traces: list<Trace>
# Source: web/src/features/public-api/types/observations.ts - APIObservation
Observation:
properties:
id:
type: string
docs: The unique identifier of the observation
traceId:
type: optional<string>
type: nullable<string>
docs: The trace ID associated with the observation
type:
type: string
docs: The type of the observation
name:
type: optional<string>
type: nullable<string>
docs: The name of the observation
startTime:
type: datetime
docs: The start time of the observation
endTime:
type: optional<datetime>
type: nullable<datetime>
docs: The end time of the observation.
completionStartTime:
type: optional<datetime>
type: nullable<datetime>
docs: The completion start time of the observation
model:
type: optional<string>
type: nullable<string>
docs: The model used for the observation
modelParameters:
type: optional<map<string, MapValue>>
type: unknown
docs: The parameters of the model used for the observation
input:
type: optional<unknown>
type: unknown
docs: The input data of the observation
version:
type: optional<string>
type: nullable<string>
docs: The version of the observation
metadata:
type: optional<unknown>
type: unknown
docs: Additional metadata of the observation
output:
type: optional<unknown>
type: unknown
docs: The output data of the observation
usage:
type: optional<Usage>
type: Usage
docs: (Deprecated. Use usageDetails and costDetails instead.) The usage data of the observation
level:
type: ObservationLevel
docs: The level of the observation
statusMessage:
type: optional<string>
type: nullable<string>
docs: The status message of the observation
parentObservationId:
type: optional<string>
type: nullable<string>
docs: The parent observation ID
promptId:
type: optional<string>
type: nullable<string>
docs: The prompt ID associated with the observation
usageDetails:
type: optional<map<string, integer>>
type: map<string, integer>
docs: The usage details of the observation. Key is the name of the usage metric, value is the number of units consumed. The total key is the sum of all (non-total) usage metrics or the total value ingested.
costDetails:
type: optional<map<string, double>>
type: map<string, double>
docs: The cost details of the observation. Key is the name of the cost metric, value is the cost in USD. The total key is the sum of all (non-total) cost metrics or the total value ingested.
environment:
type: optional<string>
type: string
docs: The environment from which this observation originated. Can be any lowercase alphanumeric string with hyphens and underscores that does not start with 'langfuse'.
ObservationsView:
extends: Observation
properties:
promptName:
type: optional<string>
type: nullable<string>
docs: The name of the prompt associated with the observation
promptVersion:
type: optional<integer>
type: nullable<integer>
docs: The version of the prompt associated with the observation
modelId:
type: optional<string>
type: nullable<string>
docs: The unique identifier of the model
inputPrice:
type: optional<double>
type: nullable<double>
docs: The price of the input in USD
outputPrice:
type: optional<double>
type: nullable<double>
docs: The price of the output in USD.
totalPrice:
type: optional<double>
type: nullable<double>
docs: The total price in USD.
calculatedInputCost:
type: optional<double>
type: nullable<double>
docs: (Deprecated. Use usageDetails and costDetails instead.) The calculated cost of the input in USD
calculatedOutputCost:
type: optional<double>
type: nullable<double>
docs: (Deprecated. Use usageDetails and costDetails instead.) The calculated cost of the output in USD
calculatedTotalCost:
type: optional<double>
type: nullable<double>
docs: (Deprecated. Use usageDetails and costDetails instead.) The calculated total cost in USD
latency:
type: optional<double>
type: nullable<double>
docs: The latency in seconds.
timeToFirstToken:
type: optional<double>
type: nullable<double>
docs: The time to the first token in seconds
Usage:
@@ -197,14 +200,16 @@ types:
properties:
input:
docs: Number of input units (e.g. tokens)
type: optional<integer>
type: integer
output:
docs: Number of output units (e.g. tokens)
type: optional<integer>
type: integer
total:
docs: Defaults to input+output if not set
type: optional<integer>
unit: optional<ModelUsageUnit>
type: integer
unit:
docs: Unit of measurement
type: nullable<string>
inputCost:
docs: USD input cost
type: optional<double>
@@ -214,6 +219,7 @@ types:
totalCost:
docs: USD total cost, defaults to input+output
type: optional<double>
# Source: web/src/features/public-api/types/score-configs.ts - APIScoreConfig
ScoreConfig:
docs: Configuration for a score
properties:
@@ -222,45 +228,56 @@ types:
createdAt: datetime
updatedAt: datetime
projectId: string
dataType: ScoreDataType
dataType: ScoreConfigDataType
isArchived:
type: boolean
docs: Whether the score config is archived. Defaults to false
minValue:
type: optional<double>
type: optional<nullable<double>>
docs: Sets minimum value for numerical scores. If not set, the minimum value defaults to -∞
maxValue:
type: optional<double>
type: optional<nullable<double>>
docs: Sets maximum value for numerical scores. If not set, the maximum value defaults to +∞
categories:
type: optional<list<ConfigCategory>>
docs: Configures custom categories for categorical scores
description: optional<string>
description:
type: optional<nullable<string>>
docs: Description of the score config
ConfigCategory:
properties:
value: double
label: string
# Source: packages/shared/src/features/scores/interfaces/api/v1/schemas.ts - APIScoreSchemaV1
BaseScoreV1:
properties:
id: string
traceId: string
name: string
source: ScoreSource
observationId: optional<string>
observationId:
type: optional<nullable<string>>
docs: The observation ID associated with the score
timestamp: datetime
createdAt: datetime
updatedAt: datetime
authorUserId: optional<string>
comment: optional<string>
metadata: optional<unknown>
authorUserId:
type: nullable<string>
docs: The user ID of the author
comment:
type: nullable<string>
docs: Comment on the score
metadata:
type: unknown
docs: Metadata associated with the score
configId:
type: optional<string>
type: nullable<string>
docs: Reference a score config on a score. When set, config and score name must be equal and value must comply to optionally defined numerical range
queueId:
type: optional<string>
type: nullable<string>
docs: The annotation queue referenced by the score. Indicates if score was initially created while processing annotation queue.
environment:
type: optional<string>
type: string
docs: The environment from which this score originated. Can be any lowercase alphanumeric string with hyphens and underscores that does not start with 'langfuse'.
NumericScoreV1:
extends: BaseScoreV1
@@ -299,29 +316,44 @@ types:
type: BooleanScoreV1
docs: "Score with BOOLEAN data type"
# Source: packages/shared/src/features/scores/interfaces/api/v2/schemas.ts - APIScoreSchemaV2
BaseScore:
properties:
id: string
traceId: optional<string>
sessionId: optional<string>
observationId: optional<string>
datasetRunId: optional<string>
traceId:
type: optional<nullable<string>>
docs: The trace ID associated with the score
sessionId:
type: optional<nullable<string>>
docs: The session ID associated with the score
observationId:
type: optional<nullable<string>>
docs: The observation ID associated with the score
datasetRunId:
type: optional<nullable<string>>
docs: The dataset run ID associated with the score
name: string
source: ScoreSource
timestamp: datetime
createdAt: datetime
updatedAt: datetime
authorUserId: optional<string>
comment: optional<string>
metadata: optional<unknown>
authorUserId:
type: nullable<string>
docs: The user ID of the author
comment:
type: nullable<string>
docs: Comment on the score
metadata:
type: unknown
docs: Metadata associated with the score
configId:
type: optional<string>
type: nullable<string>
docs: Reference a score config on a score. When set, config and score name must be equal and value must comply to optionally defined numerical range
queueId:
type: optional<string>
type: nullable<string>
docs: The annotation queue referenced by the score. Indicates if score was initially created while processing annotation queue.
environment:
type: optional<string>
type: string
docs: The environment from which this score originated. Can be any lowercase alphanumeric string with hyphens and underscores that does not start with 'langfuse'.
NumericScore:
extends: BaseScore
@@ -367,6 +399,7 @@ types:
- string
docs: The value of the score. Must be passed as string for categorical scores, and numeric for boolean and numeric scores
# Source: web/src/features/public-api/types/comments.ts - APIComment
Comment:
properties:
id: string
@@ -376,36 +409,55 @@ types:
objectType: CommentObjectType
objectId: string
content: string
authorUserId: optional<string>
authorUserId:
type: optional<nullable<string>>
docs: The user ID of the comment author
# Source: web/src/features/public-api/types/datasets.ts - APIDataset
Dataset:
properties:
id: string
name: string
description: optional<string>
metadata: optional<unknown>
description:
type: nullable<string>
docs: Description of the dataset
metadata:
type: unknown
docs: Metadata associated with the dataset
inputSchema:
type: optional<unknown>
type: nullable<unknown>
docs: JSON Schema for validating dataset item inputs
expectedOutputSchema:
type: optional<unknown>
type: nullable<unknown>
docs: JSON Schema for validating dataset item expected outputs
projectId: string
createdAt: datetime
updatedAt: datetime
# Source: web/src/features/public-api/types/datasets.ts - APIDatasetItem
DatasetItem:
properties:
id: string
status: DatasetStatus
input: optional<unknown>
expectedOutput: optional<unknown>
metadata: optional<unknown>
sourceTraceId: optional<string>
sourceObservationId: optional<string>
input:
type: unknown
docs: Input data for the dataset item
expectedOutput:
type: unknown
docs: Expected output for the dataset item
metadata:
type: unknown
docs: Metadata associated with the dataset item
sourceTraceId:
type: nullable<string>
docs: The trace ID that sourced this dataset item
sourceObservationId:
type: nullable<string>
docs: The observation ID that sourced this dataset item
datasetId: string
datasetName: string
createdAt: datetime
updatedAt: datetime
# Source: web/src/features/public-api/types/datasets.ts - APIDatasetRunItem
DatasetRunItem:
properties:
id: string
@@ -413,9 +465,12 @@ types:
datasetRunName: string
datasetItemId: string
traceId: string
observationId: optional<string>
observationId:
type: nullable<string>
docs: The observation ID associated with this run item
createdAt: datetime
updatedAt: datetime
# Source: web/src/features/public-api/types/datasets.ts - APIDatasetRun
DatasetRun:
properties:
id:
@@ -425,10 +480,10 @@ types:
type: string
docs: Name of the dataset run
description:
type: optional<string>
type: nullable<string>
docs: Description of the run
metadata:
type: optional<unknown>
type: unknown
docs: Metadata of the dataset run
datasetId:
type: string
@@ -446,6 +501,7 @@ types:
extends: DatasetRun
properties:
datasetRunItems: list<DatasetRunItem>
# Source: web/src/features/public-api/types/models.ts - APIModelDefinition
Model:
docs: |
Model definition used for transforming usage into USD cost and/or tokenization.
@@ -467,25 +523,25 @@ types:
type: string
startDate:
docs: Apply only to generations which are newer than this ISO date.
type: optional<datetime>
type: nullable<datetime>
unit:
docs: Unit used by this model.
type: optional<ModelUsageUnit>
type: optional<nullable<ModelUsageUnit>>
inputPrice:
docs: Deprecated. See 'prices' instead. Price (USD) per input unit
type: optional<double>
type: nullable<double>
outputPrice:
docs: Deprecated. See 'prices' instead. Price (USD) per output unit
type: optional<double>
type: nullable<double>
totalPrice:
docs: Deprecated. See 'prices' instead. Price (USD) per total unit. Cannot be set if input or output price is set.
type: optional<double>
type: nullable<double>
tokenizerId:
docs: Optional. Tokenizer to be applied to observations which match to this model. See docs for more details.
type: optional<string>
type: nullable<string>
tokenizerConfig:
docs: Optional. Configuration for the selected tokenizer. Needs to be JSON. See docs for more details.
type: optional<unknown>
type: unknown
isLangfuseManaged:
type: boolean
createdAt:
@@ -730,11 +786,18 @@ types:
- ANNOTATION
- API
- EVAL
ScoreConfigDataType:
enum:
- NUMERIC
- BOOLEAN
- CATEGORICAL
ScoreDataType:
enum:
- NUMERIC
- BOOLEAN
- CATEGORICAL
- CORRECTION
errors:
Error:
+3 -1
View File
@@ -278,7 +278,9 @@ types:
sessionId: optional<string>
observationId: optional<string>
datasetRunId: optional<string>
name: string
name:
type: string
docs: The name of the score. Always overrides "output" for correction scores.
environment: optional<string>
queueId:
type: optional<string>
+3 -1
View File
@@ -9,7 +9,9 @@ service:
metrics:
docs: |
Get metrics from the Langfuse project using a query object.
Consider using the [v2 metrics endpoint](/api-reference#tag/metricsv2/GET/api/public/v2/metrics) for better performance.
For more details, see the [Metrics API documentation](https://langfuse.com/docs/metrics/features/metrics-api).
method: GET
path: /metrics
@@ -20,7 +20,7 @@ service:
- `basic` - name, level, statusMessage, version, environment, bookmarked, public, userId, sessionId
- `time` - completionStartTime, createdAt, updatedAt
- `io` - input, output
- `metadata` - metadata
- `metadata` - metadata (truncated to 200 chars by default, use `expandMetadata` to get full values)
- `model` - providedModelName, internalModelId, modelParameters
- `usage` - usageDetails, costDetails, totalCost
- `prompt` - promptId, promptName, promptVersion
@@ -43,6 +43,13 @@ service:
Available groups: core, basic, time, io, metadata, model, usage, prompt, metrics.
If not specified, `core` and `basic` field groups are returned.
Example: "basic,usage,model"
expandMetadata:
type: optional<string>
docs: |
Comma-separated list of metadata keys to return non-truncated.
By default, metadata values over 200 characters are truncated.
Use this parameter to retrieve full values for specific keys.
Example: "key1,key2"
limit:
type: optional<integer>
docs: Number of items to return per page. Maximum 1000, default 50.
+4 -1
View File
@@ -16,7 +16,10 @@ service:
docs: The unique langfuse identifier of an observation, can be an event, span or generation
response: commons.ObservationsView
getMany:
docs: Get a list of observations
docs: |
Get a list of observations.
Consider using the [v2 observations endpoint](/api-reference#tag/observationsv2/GET/api/public/v2/observations) for cursor-based pagination and field selection.
method: GET
path: /observations
request:
+18 -2
View File
@@ -44,8 +44,12 @@ service:
type: optional<map<string, unknown>>
docs: Optional metadata for the project
retention:
type: integer
docs: Number of days to retain data. Must be 0 or at least 3 days. Requires data-retention entitlement for non-zero values. Optional.
type: optional<integer>
docs: |
Number of days to retain data.
Must be 0 or at least 3 days.
Requires data-retention entitlement for non-zero values.
Optional. Will retain existing retention setting if omitted.
response: Project
delete:
@@ -102,10 +106,22 @@ types:
properties:
data: list<Project>
Organization:
properties:
id:
type: string
docs: The unique identifier of the organization
name:
type: string
docs: The name of the organization
Project:
properties:
id: string
name: string
organization:
type: Organization
docs: The organization this project belongs to
metadata:
type: map<string, unknown>
docs: Metadata for the project
@@ -55,7 +55,7 @@ types:
CreateScoreConfigRequest:
properties:
name: string
dataType: commons.ScoreDataType
dataType: commons.ScoreConfigDataType
categories:
type: optional<list<commons.ConfigCategory>>
docs: Configure custom categories for categorical scores. Pass a list of objects with `label` and `value` properties. Categories are autogenerated for boolean configs and cannot be passed
+3
View File
@@ -69,6 +69,9 @@ service:
type: optional<string>
allow-multiple: true
docs: Only scores linked to traces that include all of these tags will be returned.
fields:
type: optional<string>
docs: "Comma-separated list of field groups to include in the response. Available field groups: 'score' (core score fields), 'trace' (trace properties: userId, tags, environment). If not specified, both 'score' and 'trace' are returned by default. Example: 'score' to exclude trace data, 'score,trace' to include both. Note: When filtering by trace properties (using userId or traceTags parameters), the 'trace' field group must be included, otherwise a 400 error will be returned."
response: GetScoresResponse
get-by-id:
docs: Get a score (supports both trace and session scores)
+8 -4
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.141.0",
"version": "3.149.0",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
@@ -21,6 +21,9 @@
"dx-f": "pnpm i && pnpm run infra:dev:prune && pnpm run infra:dev:up --pull always && pnpm --filter=shared run db:reset:test && pnpm --filter=shared run db:reset -f && SKIP_CONFIRM=1 pnpm --filter=shared run ch:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
"dx:skip-infra": "pnpm i && pnpm --filter=shared run db:reset:test && pnpm --filter=shared run db:reset && pnpm --filter=shared run ch:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
"build": "turbo run build",
"build:check": "turbo run build:check",
"typecheck": "turbo run typecheck",
"tc": "turbo run typecheck",
"start": "turbo run start",
"dev": "turbo run dev",
"dev:worker": "turbo run dev --filter=worker",
@@ -40,7 +43,7 @@
"husky": "^9.1.7",
"prettier": "^3.6.2",
"release-it": "^19.0.4",
"turbo": "^2.6.3"
"turbo": "^2.7.5"
},
"release-it": {
"git": {
@@ -93,10 +96,11 @@
"tar-fs": "^2.1.2",
"rollup@^4.0.0": "^4.22.4",
"@types/node-fetch": "^2.6.13",
"glob": "^10.5.0"
"glob": "^10.5.0",
"qs": "6.14.1"
},
"patchedDependencies": {
"next-auth@4.24.12": "patches/next-auth@4.24.12.patch"
"next-auth@4.24.13": "patches/next-auth@4.24.13.patch"
}
}
}
+87
View File
@@ -0,0 +1,87 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import turboConfig from "eslint-config-turbo/flat";
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
import "eslint-plugin-only-warn";
export default tseslint.config(
// Global ignores
{
name: "langfuse/ignores",
ignores: [
"**/node_modules/",
"**/dist/",
"**/build/",
"**/coverage/",
"**/.next/",
"**/.*",
"eslint.config.mjs",
],
},
// Base JS rules (same as eslint v8 library.js)
js.configs.recommended,
// Turbo monorepo rules
...turboConfig,
// Prettier (last for rule precedence)
eslintPluginPrettierRecommended,
// Global settings
{
name: "langfuse/base/globals",
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
globals: {
...globals.node,
...globals.es2021,
React: "readonly",
JSX: "readonly",
},
},
rules: {
"no-redeclare": "off",
"import/order": "off",
},
},
// TypeScript-specific - parser only + custom rules
// Note: Old library.js had no TS rules, only eslint:recommended
// Adding parser + plugin to support custom rules, but not extending recommended
{
name: "langfuse/base/typescript",
files: ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"],
plugins: {
"@typescript-eslint": tseslint.plugin,
},
languageOptions: {
parser: tseslint.parser,
},
rules: {
"no-undef": "off", // TypeScript handles this
"no-dupe-class-members": "off", // TypeScript handles this (and supports overloads)
"no-unused-vars": "off", // Use @typescript-eslint/no-unused-vars instead
"no-restricted-globals": [
"error",
{
name: "redis",
message: "Import redis explicitly from '@langfuse/shared/src/server'",
},
],
// Custom rule from eslint v8 shared/.eslintrc.js
"@typescript-eslint/no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
ignoreRestSiblings: true,
},
],
},
},
);
+2
View File
@@ -0,0 +1,2 @@
// @ts-check
export { default } from "./base.js";
-62
View File
@@ -1,62 +0,0 @@
const { resolve } = require("node:path");
const project = resolve(process.cwd(), "tsconfig.json");
// Handle eslint-config-turbo's default export
const turboConfig = require("eslint-config-turbo");
const turboConfigToUse = turboConfig.default || turboConfig;
/** @type {import("eslint").Linter.Config} */
module.exports = {
// extends: ["eslint:recommended", "prettier"require().default],
extends: ["eslint:recommended", "prettier"],
plugins: ["only-warn", "turbo"],
globals: {
React: true,
JSX: true,
},
env: {
node: true,
es6: true,
},
settings: {
"import/resolver": {
typescript: {
project,
},
},
},
ignorePatterns: [
// Ignore dotfiles
".*.js",
"node_modules/",
"dist/",
],
rules: {
"no-redeclare": "off",
"import/order": "off",
...(turboConfigToUse.rules || {}),
},
overrides: [
{
files: ["*.js?(x)", "*.ts?(x)"],
},
{
files: ["*.ts", "*.mts", "*.cts", "*.tsx"],
// no-undef doesn't make sense in TS, see:
// https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
rules: {
"no-undef": "off",
"no-restricted-globals": [
"error",
{
name: "redis",
message:
"Import redis explicitly from '@langfuse/shared/src/server' instead of using global.",
},
],
},
},
...(turboConfigToUse.overrides || []),
],
};
+82 -69
View File
@@ -1,79 +1,92 @@
const { resolve } = require("node:path");
import tseslint from "typescript-eslint";
import { FlatCompat } from "@eslint/eslintrc";
import { fileURLToPath } from "node:url";
import path from "node:path";
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
import turboConfig from "eslint-config-turbo/flat";
import "eslint-plugin-only-warn";
const project = resolve(process.cwd(), "tsconfig.json");
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const turboConfig = require("eslint-config-turbo");
const turboConfigToUse = turboConfig.default || turboConfig;
const compat = new FlatCompat({
baseDirectory: __dirname,
});
/*
* This is a custom ESLint configuration for use with
* Next.js apps.
*
* This config extends the Vercel Engineering Style Guide.
* For more information, see https://github.com/vercel/style-guide
*
*/
module.exports = {
parser: "@typescript-eslint/parser", // Set the parser to @typescript-eslint/parser
extends: [
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/strict-type-checked",
],
rules: {
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-confusing-void-expression": "off",
export default tseslint.config(
// Global ignores - include config files
{
name: "langfuse/ignores",
ignores: [
"**/node_modules/",
"**/dist/",
"**/.next/",
"**/coverage/",
"eslint.config.mjs",
],
},
parser: "@typescript-eslint/parser",
parserOptions: {
project,
},
globals: {
React: true,
JSX: true,
},
plugins: ["@typescript-eslint"],
extends: ["next/core-web-vitals"],
env: {
es6: true,
jest: true,
},
settings: {
"import/resolver": {
typescript: {
project,
},
// Next.js rules via FlatCompat (applies to all files)
...compat.extends("next/core-web-vitals"),
// Turbo rules
...turboConfig,
// Disable noisy turbo env var rule - project has many env vars not in turbo.json
{
name: "langfuse/next/turbo-overrides",
rules: {
"turbo/no-undeclared-env-vars": "off",
},
},
ignorePatterns: ["node_modules/", "dist/"],
// add rules configurations here
rules: {
...(turboConfigToUse.rules || {}),
"@typescript-eslint/consistent-type-imports": [
"warn",
{
prefer: "type-imports",
fixStyle: "inline-type-imports",
// Prettier (last)
eslintPluginPrettierRecommended,
// TypeScript config for TS files
// Note: The old config had a bug (duplicate extends) that prevented TS rules from applying
// Only adding parser + plugin + custom rules to match old behavior
{
name: "langfuse/next/typescript",
files: ["**/*.ts", "**/*.tsx"],
plugins: {
"@typescript-eslint": tseslint.plugin,
},
languageOptions: {
parser: tseslint.parser,
globals: {
React: "readonly",
JSX: "readonly",
},
],
"@typescript-eslint/no-unused-vars": [
// see: https://typescript-eslint.io/rules/no-unused-vars/#why-does-this-rule-report-variables-used-only-for-types
// since v8, vars only used for types are unused at runtime and therefore throw a warning.
// we fix those with workarounds (mostly just export the variables, as they are used as part of an API anyways)
// see: https://github.com/typescript-eslint/typescript-eslint/issues/10266
"warn",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
settings: {
"import/resolver": {
typescript: {
project: "./tsconfig.json",
},
},
],
"react/jsx-key": [
"error",
{
warnOnDuplicates: true,
},
],
},
rules: {
"no-unused-vars": "off", // Use @typescript-eslint/no-unused-vars instead
// Custom rules from old config
"@typescript-eslint/consistent-type-imports": [
"warn",
{
prefer: "type-imports",
fixStyle: "inline-type-imports",
},
],
"@typescript-eslint/no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
ignoreRestSiblings: true,
},
],
"react/jsx-key": ["error", { warnOnDuplicates: true }],
},
},
};
);
+21 -8
View File
@@ -3,18 +3,31 @@
"version": "0.0.0",
"license": "MIT",
"private": true,
"type": "module",
"exports": {
".": "./index.js",
"./base": "./base.js",
"./next": "./next.js"
},
"files": [
"library.js",
"index.js",
"base.js",
"next.js"
],
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1",
"@vercel/style-guide": "^6.0.0",
"dependencies": {
"@eslint/compat": "^2.0.0",
"@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.39.2",
"eslint-config-next": "15.5.9",
"eslint-config-prettier": "^9.1.0",
"eslint-config-turbo": "^2.6.3",
"eslint-config-prettier": "^10.1.8",
"eslint-config-turbo": "^2.7.5",
"eslint-plugin-only-warn": "^1.1.0",
"typescript": "^5.7.2"
"eslint-plugin-prettier": "^5.5.4",
"globals": "^16.0.0",
"typescript-eslint": "^8.50.1"
},
"peerDependencies": {
"eslint": "^9.39.0",
"typescript": ">=5.0.0"
}
}
-8
View File
@@ -1,8 +0,0 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
extends: ["@repo/eslint-config/library.js"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
},
};
@@ -0,0 +1 @@
ALTER TABLE scores ON CLUSTER default DROP COLUMN IF EXISTS long_string_value;
@@ -0,0 +1 @@
ALTER TABLE scores ON CLUSTER default ADD COLUMN IF NOT EXISTS long_string_value String CODEC(ZSTD(3));
@@ -0,0 +1 @@
ALTER TABLE scores DROP COLUMN IF EXISTS long_string_value;
@@ -0,0 +1 @@
ALTER TABLE scores ADD COLUMN IF NOT EXISTS long_string_value String CODEC(ZSTD(3));
@@ -75,6 +75,8 @@ clickhouse client \
-- Create observations_batch_staging table for batch processing
-- This table uses 3-minute partitions to efficiently process observations in batches
-- and merge them with traces data into the events table.
-- Partitions are automatically expired after 12 hours via TTL (ttl_only_drop_parts=1
-- ensures only complete partitions are dropped, not individual rows).
-- See LFE-7122 for implementation details.
CREATE TABLE IF NOT EXISTS observations_batch_staging
(
@@ -123,7 +125,9 @@ ORDER BY (
toDate(s3_first_seen_timestamp),
trace_id,
id
);
)
TTL s3_first_seen_timestamp + INTERVAL 12 HOUR
SETTINGS ttl_only_drop_parts = 1;
-- Create new events table for development setups.
-- We expect this to be fully immutable and eventually replace observations.
+3
View File
@@ -0,0 +1,3 @@
import baseConfig from "@repo/eslint-config";
export default [...baseConfig];
+20 -15
View File
@@ -25,6 +25,10 @@
"import": "./dist/src/server/auth/apiKeys.js",
"require": "./dist/src/server/auth/apiKeys.js"
},
"./src/utils/chatml": {
"import": "./dist/src/utils/chatml/index.js",
"require": "./dist/src/utils/chatml/index.js"
},
"./encryption": {
"import": "./dist/src/encryption/index.js",
"require": "./dist/src/encryption/index.js"
@@ -32,9 +36,11 @@
},
"scripts": {
"build": "tsc",
"build:check": "tsc",
"typecheck": "dotenv -e ../../.env -- tsc --noEmit --incremental --tsBuildInfoFile .tsbuildinfo",
"dev": "tsc --watch",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 0",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
"lint": "eslint . --cache --cache-location dist/.eslintcache --max-warnings 0",
"lint:fix": "eslint . --cache --cache-location dist/.eslintcache --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",
"db:reset": "dotenv -e ../../.env npx -- prisma migrate reset",
@@ -63,12 +69,12 @@
"@aws-sdk/s3-request-presigner": "^3.679.0",
"@azure/storage-blob": "^12.26.0",
"@clickhouse/client": "^1.13.0",
"@google-cloud/storage": "^7.17.0",
"@google-cloud/storage": "^7.18.0",
"@langchain/anthropic": "^0.3.32",
"@langchain/aws": "^0.1.15",
"@langchain/core": "^0.3.58",
"@langchain/google-genai": "^0.2.12",
"@langchain/google-vertexai": "^0.2.12",
"@langchain/core": "^0.3.80",
"@langchain/google-genai": "^0.2.18",
"@langchain/google-vertexai": "^0.2.18",
"@langchain/openai": "^0.5.13",
"@opentelemetry/api": ">=1.0.0 <1.10.0",
"@prisma/client": "^6.17.1",
@@ -90,11 +96,11 @@
"ipaddr.js": "^2.2.0",
"jsonpath-plus": "10.3.0",
"kysely": "^0.27.4",
"langchain": "^0.3.30",
"langchain": "^0.3.37",
"langfuse-langchain": "3.38.6",
"lodash": "^4.17.21",
"lodash": "^4.17.23",
"lossless-json": "^4.1.1",
"next-auth": "^4.24.12",
"next-auth": "^4.24.13",
"nodemailer": "^7.0.11",
"prisma-extension-kysely": "^3.0.0",
"safe-regex2": "^5.0.0",
@@ -106,21 +112,20 @@
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/lodash": "^4.17.10",
"@types/lodash": "^4.17.23",
"@types/node": "^24.3.0",
"@types/nodemailer": "^7.0.4",
"@types/pg": "^8.11.10",
"@types/react": "19.2.3",
"@types/uuid": "^9.0.8",
"@typescript-eslint/parser": "^8.48.1",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"@typescript-eslint/parser": "^8.50.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-config-standard": "^17.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-prettier": "^5.5.4",
"kysely-codegen": "^0.16.8",
"prettier": "^3.6.2",
"prisma": "^6.17.1",
"prisma-erd-generator": "^2.1.0",
"prisma-kysely": "^1.8.0",
"ts-node": "^10.9.2",
"tsc-watch": "^6.2.0",
+9 -20
View File
@@ -47,12 +47,13 @@ export const LegacyPrismaScoreSource = {
} as const;
export type LegacyPrismaScoreSource =
(typeof LegacyPrismaScoreSource)[keyof typeof LegacyPrismaScoreSource];
export const ScoreDataType = {
export const ScoreConfigDataType = {
CATEGORICAL: "CATEGORICAL",
NUMERIC: "NUMERIC",
BOOLEAN: "BOOLEAN",
} as const;
export type ScoreDataType = (typeof ScoreDataType)[keyof typeof ScoreDataType];
export type ScoreConfigDataType =
(typeof ScoreConfigDataType)[keyof typeof ScoreConfigDataType];
export const AnnotationQueueStatus = {
PENDING: "PENDING",
COMPLETED: "COMPLETED",
@@ -379,6 +380,10 @@ export type Comment = {
updated_at: Generated<Timestamp>;
content: string;
author_user_id: string | null;
data_field: string | null;
path: Generated<string[]>;
range_start: Generated<number[]>;
range_end: Generated<number[]>;
};
export type CommentReaction = {
id: string;
@@ -447,25 +452,10 @@ export type DatasetItem = {
dataset_id: string;
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
sys_id: Generated<string | null>;
valid_from: Generated<Timestamp>;
valid_to: Timestamp | null;
is_deleted: Generated<boolean>;
};
export type DatasetItemEvent = {
id: string;
item_id: string;
project_id: string;
dataset_id: string;
status: DatasetStatus | null;
input: unknown | null;
expected_output: unknown | null;
metadata: unknown | null;
source_trace_id: string | null;
source_observation_id: string | null;
created_at: Timestamp | null;
deleted_at: Timestamp | null;
};
export type DatasetRunItems = {
id: string;
project_id: string;
@@ -596,7 +586,7 @@ export type LegacyPrismaScore = {
queue_id: string | null;
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
data_type: Generated<ScoreDataType>;
data_type: Generated<ScoreConfigDataType>;
};
export type LegacyPrismaTrace = {
id: string;
@@ -834,7 +824,7 @@ export type ScoreConfig = {
updated_at: Generated<Timestamp>;
project_id: string;
name: string;
data_type: ScoreDataType;
data_type: ScoreConfigDataType;
is_archived: Generated<boolean>;
min_value: number | null;
max_value: number | null;
@@ -954,7 +944,6 @@ export type DB = {
cron_jobs: CronJobs;
dashboard_widgets: DashboardWidget;
dashboards: Dashboard;
dataset_item_events: DatasetItemEvent;
dataset_items: DatasetItem;
dataset_run_items: DatasetRunItems;
dataset_runs: DatasetRuns;
@@ -0,0 +1,8 @@
/*
Warnings:
- Changed the type of `data_type` on the `score_configs` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
*/
-- Rename enum
ALTER TYPE "ScoreDataType" RENAME TO "ScoreConfigDataType";
@@ -0,0 +1,2 @@
INSERT INTO background_migrations (id, name, script, args)
VALUES ('01a0c890-2094-8e2f-c773-cf4d14638fa4', '20260106_encrypt_blob_storage_secrets', 'encryptBlobStorageSecrets', '{}');
@@ -0,0 +1,14 @@
-- Add inline comment positioning columns to comments table
-- These columns enable character-based inline comments on trace/observation IO
-- data_field: which IO field the comment is on ('input' | 'output' | 'metadata')
ALTER TABLE "comments" ADD COLUMN "data_field" TEXT;
-- path: JSON Path expressions array, e.g., ["$.messages[1].text"]
ALTER TABLE "comments" ADD COLUMN "path" TEXT[] DEFAULT '{}';
-- range_start: start offsets per path (inclusive), UTF-16 code units
ALTER TABLE "comments" ADD COLUMN "range_start" INTEGER[] DEFAULT '{}';
-- range_end: end offsets per path (exclusive), UTF-16 code units
ALTER TABLE "comments" ADD COLUMN "range_end" INTEGER[] DEFAULT '{}';
@@ -0,0 +1,9 @@
/*
Warnings:
- You are about to drop the column `sys_id` on the `dataset_items` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "dataset_items" DROP COLUMN "sys_id";
@@ -0,0 +1,3 @@
-- DropForeignKey
ALTER TABLE "dataset_item_events" DROP CONSTRAINT "dataset_item_events_dataset_id_project_id_fkey";
@@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the `dataset_item_events` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropTable
DROP TABLE "dataset_item_events";
+12 -32
View File
@@ -13,14 +13,6 @@ datasource db {
shadowDatabaseUrl = env("SHADOW_DATABASE_URL")
}
generator erd {
provider = "prisma-erd-generator"
ignoreTables = ["_prisma_migrations", "Session", "Account", "Example"]
disabled = true
ignoreEnums = true
output = "database.svg"
}
generator kysely {
provider = "prisma-kysely"
@@ -457,7 +449,7 @@ model LegacyPrismaScore {
queueId String? @map("queue_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
dataType ScoreDataType @default(NUMERIC) @map("data_type")
dataType ScoreConfigDataType @default(NUMERIC) @map("data_type")
scoreConfig ScoreConfig? @relation(fields: [configId], references: [id], onDelete: SetNull)
@@unique([id, projectId]) // used for upserts via prisma
@@ -488,7 +480,7 @@ model ScoreConfig {
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
name String
dataType ScoreDataType @map("data_type")
dataType ScoreConfigDataType @map("data_type")
isArchived Boolean @default(false) @map("is_archived")
minValue Float? @map("min_value")
maxValue Float? @map("max_value")
@@ -506,7 +498,7 @@ model ScoreConfig {
@@map("score_configs")
}
enum ScoreDataType {
enum ScoreConfigDataType {
CATEGORICAL
NUMERIC
BOOLEAN
@@ -606,7 +598,6 @@ model Dataset {
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
datasetItems DatasetItem[]
datasetRuns DatasetRuns[]
datasetItemEvents DatasetItemEvent[]
@@id([id, projectId])
@@unique([projectId, name])
@@ -629,7 +620,6 @@ model DatasetItem {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
// dataset version cols
sysId String? @default(dbgenerated()) @map("sys_id")
validFrom DateTime @default(now()) @map("valid_from")
validTo DateTime? @map("valid_to")
isDeleted Boolean @default(false) @map("is_deleted")
@@ -645,25 +635,6 @@ model DatasetItem {
@@map("dataset_items")
}
model DatasetItemEvent {
id String @id @default(cuid())
itemId String @map("item_id")
projectId String @map("project_id")
datasetId String @map("dataset_id")
dataset Dataset @relation(fields: [datasetId, projectId], references: [id, projectId], onDelete: Cascade)
status DatasetStatus?
input Json?
expectedOutput Json? @map("expected_output")
metadata Json?
sourceTraceId String? @map("source_trace_id")
sourceObservationId String? @map("source_observation_id")
createdAt DateTime? @map("created_at")
deletedAt DateTime? @map("deleted_at")
@@index([projectId, datasetId, itemId, createdAt])
@@map("dataset_item_events")
}
enum DatasetStatus {
ACTIVE
ARCHIVED
@@ -722,6 +693,15 @@ model Comment {
authorUserId String? @map("author_user_id") // no fk constraint, user can be deleted
reactions CommentReaction[]
// Inline comment positioning (all must be set together or all null/empty)
// dataField: which IO field the comment is on ('input' | 'output' | 'metadata')
// path: Array of JSON Path expressions, e.g., ["$.messages[1].text"]
// rangeStart/rangeEnd: parallel arrays for start/end offsets per path (exclusive end, UTF-16 code units)
dataField String? @map("data_field")
path String[] @default([]) @map("path")
rangeStart Int[] @default([]) @map("range_start")
rangeEnd Int[] @default([]) @map("range_end")
@@index([projectId, objectType, objectId])
@@map("comments")
}
@@ -0,0 +1,285 @@
/**
* Media Seeding Utilities
*
* Seeds test traces with media attachments (images, PDFs, audio) for testing
* the JSON Beta view's media rendering capabilities.
*
* Integrated into the seeder flow - runs automatically during `dx` or `db:seed:examples`.
*/
import crypto from "crypto";
import fs from "fs";
import path from "path";
import { Readable } from "stream";
import { prisma } from "../../src/db";
import { env } from "../../src/env";
import { logger, StorageServiceFactory } from "../../src/server";
// Test file paths (relative to monorepo root)
const TEST_FILES_DIR = path.join(
__dirname,
"../../../../web/src/__tests__/static",
);
interface MediaFile {
name: string;
contentType: string;
filePath: string;
}
const MEDIA_FILES: Record<string, MediaFile> = {
image: {
name: "langfuse-logo.png",
contentType: "image/png",
filePath: path.join(TEST_FILES_DIR, "langfuse-logo.png"),
},
pdf: {
name: "bitcoin.pdf",
contentType: "application/pdf",
filePath: path.join(TEST_FILES_DIR, "bitcoin.pdf"),
},
audio: {
name: "sounds-of-mars.wav",
contentType: "audio/wav",
filePath: path.join(
TEST_FILES_DIR,
"sounds-of-mars-one-small-step-earth.wav",
),
},
};
// Deterministic trace IDs for media test traces
export const MEDIA_TEST_TRACE_IDS = {
imageOnly: "seed-media-image-only",
allTypes: "seed-media-all-types",
allTypesChatML: "seed-media-all-types-chatml",
} as const;
/**
* Derive media ID from SHA256 hash (first 22 chars, URL-safe)
*/
function getMediaIdFromHash(sha256Hash: string): string {
const urlSafeHash = sha256Hash.replaceAll("+", "-").replaceAll("/", "_");
return urlSafeHash.slice(0, 22);
}
/**
* Get bucket path for a media file
*/
function getBucketPath(
projectId: string,
mediaId: string,
contentType: string,
): string {
const extensionMap: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"application/pdf": "pdf",
"audio/wav": "wav",
"audio/mpeg": "mp3",
};
const extension = extensionMap[contentType] || "bin";
const prefix = env.LANGFUSE_S3_MEDIA_UPLOAD_PREFIX || "";
return `${prefix}${projectId}/${mediaId}.${extension}`;
}
/**
* Upload a media file to storage and create database records
*/
async function uploadAndCreateMediaRecord(
projectId: string,
traceId: string,
field: "input" | "output" | "metadata",
mediaFile: MediaFile,
): Promise<void> {
// Check if bucket is configured
if (!env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET) {
logger.warn(
"[seed-media] LANGFUSE_S3_MEDIA_UPLOAD_BUCKET not configured, skipping media seeding",
);
return;
}
// Check if file exists
if (!fs.existsSync(mediaFile.filePath)) {
logger.warn(
`[seed-media] Test file not found: ${mediaFile.filePath}, skipping`,
);
return;
}
const fileBytes = fs.readFileSync(mediaFile.filePath);
const sha256Hash = crypto
.createHash("sha256")
.update(fileBytes)
.digest("base64");
const mediaId = getMediaIdFromHash(sha256Hash);
const bucketPath = getBucketPath(projectId, mediaId, mediaFile.contentType);
// Check if media already exists
const existingMedia = await prisma.media.findUnique({
where: {
projectId_sha256Hash: {
projectId,
sha256Hash,
},
},
});
if (existingMedia && existingMedia.uploadHttpStatus === 200) {
logger.debug(
`[seed-media] Media already exists for ${mediaFile.name}, creating TraceMedia link only`,
);
// Just create the TraceMedia link
await prisma.$queryRaw`
INSERT INTO "trace_media" ("id", "project_id", "trace_id", "media_id", "field")
VALUES (${crypto.randomUUID()}, ${projectId}, ${traceId}, ${existingMedia.id}, ${field})
ON CONFLICT DO NOTHING;
`;
return;
}
// Upload to storage
try {
const storageClient = StorageServiceFactory.getInstance({
bucketName: env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET,
accessKeyId: env.LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID,
secretAccessKey: env.LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY,
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,
});
await storageClient.uploadFile({
fileName: bucketPath,
fileType: mediaFile.contentType,
data: Readable.from(fileBytes),
});
logger.debug(`[seed-media] Uploaded ${mediaFile.name} to ${bucketPath}`);
} catch (error) {
logger.error(`[seed-media] Failed to upload ${mediaFile.name}:`, error);
return;
}
// Create Media record
await prisma.$queryRaw`
INSERT INTO "media" (
"id",
"project_id",
"sha_256_hash",
"bucket_path",
"bucket_name",
"content_type",
"content_length",
"uploaded_at",
"upload_http_status"
)
VALUES (
${mediaId},
${projectId},
${sha256Hash},
${bucketPath},
${env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET},
${mediaFile.contentType},
${BigInt(fileBytes.length)},
${new Date()},
${200}
)
ON CONFLICT ("project_id", "sha_256_hash")
DO UPDATE SET
"bucket_name" = ${env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET},
"bucket_path" = ${bucketPath},
"content_type" = ${mediaFile.contentType},
"content_length" = ${BigInt(fileBytes.length)},
"uploaded_at" = ${new Date()},
"upload_http_status" = ${200}
`;
// Create TraceMedia link
await prisma.$queryRaw`
INSERT INTO "trace_media" ("id", "project_id", "trace_id", "media_id", "field")
VALUES (${crypto.randomUUID()}, ${projectId}, ${traceId}, ${mediaId}, ${field})
ON CONFLICT DO NOTHING;
`;
logger.info(
`[seed-media] Created media record for ${mediaFile.name} -> ${field}`,
);
}
/**
* Seed media test traces for a project
*
* Creates two test traces with media attachments:
* 1. Image only (in input)
* 2. All types (image in input, PDF in output, audio in metadata)
*/
export async function seedMediaTraces(projectId: string): Promise<void> {
logger.info(`[seed-media] Seeding media traces for project ${projectId}`);
// Check if bucket is configured
if (!env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET) {
logger.warn(
"[seed-media] LANGFUSE_S3_MEDIA_UPLOAD_BUCKET not configured, skipping media seeding",
);
return;
}
// Trace 1: Image only (input)
const trace1Id = MEDIA_TEST_TRACE_IDS.imageOnly;
await uploadAndCreateMediaRecord(
projectId,
trace1Id,
"input",
MEDIA_FILES.image,
);
// Trace 2: All media types
const trace2Id = MEDIA_TEST_TRACE_IDS.allTypes;
await uploadAndCreateMediaRecord(
projectId,
trace2Id,
"input",
MEDIA_FILES.image,
);
await uploadAndCreateMediaRecord(
projectId,
trace2Id,
"output",
MEDIA_FILES.pdf,
);
await uploadAndCreateMediaRecord(
projectId,
trace2Id,
"metadata",
MEDIA_FILES.audio,
);
// Trace 3: All media types with ChatML format (pretty-rendered)
const trace3Id = MEDIA_TEST_TRACE_IDS.allTypesChatML;
await uploadAndCreateMediaRecord(
projectId,
trace3Id,
"input",
MEDIA_FILES.image,
);
await uploadAndCreateMediaRecord(
projectId,
trace3Id,
"output",
MEDIA_FILES.pdf,
);
await uploadAndCreateMediaRecord(
projectId,
trace3Id,
"metadata",
MEDIA_FILES.audio,
);
logger.info("[seed-media] Media seeding completed");
}
@@ -9,7 +9,8 @@ import {
PrismaClient,
type Project,
ScoreConfigCategoryDomain,
ScoreDataType,
ScoreDataTypeEnum,
type ScoreDataTypeType,
} from "../../src/index";
import { getDisplaySecretKey, hashSecretKey, logger } from "../../src/server";
import { redis } from "../../src/server/redis/redis";
@@ -30,6 +31,7 @@ import {
generateEvalTraceId,
} from "./utils/seed-helpers";
import { seedDatasetVersions } from "./seed-dataset-versions";
import { seedMediaTraces } from "./seed-media";
const options = {
environment: { type: "string" },
@@ -349,6 +351,9 @@ async function main() {
await createDashboardsAndWidgets([project1, project2]);
await seedDatasetVersions(prisma, [project1.id, project2.id]);
// Seed media test traces (uploads to MinIO + creates Media/TraceMedia records)
await seedMediaTraces(project1.id);
await prisma.llmSchema.createMany({
data: [
{
@@ -753,7 +758,7 @@ async function generateConfigsForProject(projects: Project[]) {
{
name: string;
id: string;
dataType: ScoreDataType;
dataType: ScoreDataTypeType;
categories: ScoreConfigCategoryDomain[] | null;
}[]
> = new Map();
@@ -803,7 +808,7 @@ async function generateConfigs(project: Project) {
const configNameAndId: {
name: string;
id: string;
dataType: ScoreDataType;
dataType: ScoreDataTypeType;
categories: ScoreConfigCategoryDomain[] | null;
}[] = [];
@@ -811,7 +816,7 @@ async function generateConfigs(project: Project) {
{
id: `config-${v4()}`,
name: "manual-score",
dataType: ScoreDataType.NUMERIC,
dataType: ScoreDataTypeEnum.NUMERIC,
projectId: project.id,
isArchived: false,
},
@@ -819,7 +824,7 @@ async function generateConfigs(project: Project) {
id: `config-${v4()}`,
projectId: project.id,
name: "Accuracy",
dataType: ScoreDataType.CATEGORICAL,
dataType: ScoreDataTypeEnum.CATEGORICAL,
categories: [
{ label: "Incorrect", value: 0 },
{ label: "Partially Correct", value: 1 },
@@ -831,7 +836,7 @@ async function generateConfigs(project: Project) {
id: `config-${v4()}`,
projectId: project.id,
name: "Toxicity",
dataType: ScoreDataType.BOOLEAN,
dataType: ScoreDataTypeEnum.BOOLEAN,
categories: [
{ label: "True", value: 1 },
{ label: "False", value: 0 },
@@ -880,7 +885,7 @@ async function generateQueuesForProject(
{
name: string;
id: string;
dataType: ScoreDataType;
dataType: ScoreDataTypeType;
categories: ScoreConfigCategoryDomain[] | null;
}[]
>,
@@ -904,7 +909,7 @@ async function generateQueues(
configIdsAndNames: {
name: string;
id: string;
dataType: ScoreDataType;
dataType: ScoreDataTypeType;
categories: ScoreConfigCategoryDomain[] | null;
}[],
) {
@@ -195,7 +195,11 @@ export class ClickHouseQueryBuilder {
start_time AS event_ts,
0 AS is_deleted,
'' AS usage_pricing_tier_id,
'' AS usage_pricing_tier_name
'' AS usage_pricing_tier_name,
map() AS tool_definitions,
[] AS tool_calls,
[] AS tool_call_names
FROM numbers(${totalObservations});
`;
}
@@ -250,7 +254,8 @@ export class ClickHouseQueryBuilder {
timestamp AS updated_at,
timestamp AS event_ts,
0 AS is_deleted,
NULL AS execution_trace_id
NULL AS execution_trace_id,
'' AS long_string_value
FROM numbers(${totalScores});
`;
}
@@ -759,6 +759,8 @@ export class DataGenerator {
);
// 6. GENERATION - Final summary generation
const toolObservation = observations[observations.length - 1];
const toolName = toolObservation.name || "WebSearchTool";
observations.push(
createObservation({
id: `${traceId}-generation`,
@@ -800,6 +802,14 @@ export class DataGenerator {
output: this.randomInt(35, 45) / 100000,
total: this.randomInt(50, 70) / 100000,
},
tool_definitions: {
WebSearchTool: "Search the web for information",
CalculatorTool: "Perform mathematical calculations",
WeatherForecastTool: "Get weather forecasts and data",
EmailSenderTool: "Send emails to recipients",
},
tool_calls: ["call_workflow_1"],
tool_call_names: [toolName],
}),
);
@@ -1121,6 +1131,16 @@ export class DataGenerator {
updated_at: end,
event_ts: start,
is_deleted: 0,
tool_definitions: d.tool
? {
"billing.lookup":
"Look up billing records and transaction history",
"billing.refund":
"Issue refunds for duplicate or erroneous charges",
}
: undefined,
tool_calls: d.tool ? [`call_${index}`] : undefined,
tool_call_names: d.tool ? [d.tool.name] : undefined,
};
if (!d.tool) return [baseGen];
@@ -1157,6 +1177,8 @@ export class DataGenerator {
updated_at: start - 5,
event_ts: start - 40,
is_deleted: 0,
tool_definitions: undefined,
tool_calls: undefined,
};
return [toolObs, baseGen];
@@ -1184,6 +1206,7 @@ export class DataGenerator {
config_id: null,
data_type: "NUMERIC",
string_value: null,
long_string_value: "",
queue_id: null,
created_at: baseTs,
updated_at: baseTs,
@@ -1203,6 +1226,7 @@ export class DataGenerator {
environment: "default",
name: "safe",
value: safeVal,
long_string_value: "",
source: "API",
comment: "Content safety",
metadata: {},
@@ -1230,6 +1254,7 @@ export class DataGenerator {
observation_id: null,
environment: "default",
name: "resolved",
long_string_value: "",
value: 1,
source: "API",
comment: "Conversation resolved",
@@ -7,8 +7,10 @@ import { DataGenerator } from "./data-generators";
import { ClickHouseQueryBuilder } from "./clickhouse-builder";
import { FrameworkTraceLoader } from "./framework-traces/framework-trace-loader";
import { EVAL_TRACE_COUNT, SEED_DATASETS } from "./postgres-seed-constants";
import { MEDIA_TEST_TRACE_IDS } from "../seed-media";
import {
clickhouseClient,
createTrace,
DatasetRunItemRecordInsertType,
logger,
ObservationRecordInsertType,
@@ -338,6 +340,9 @@ export class SeederOrchestrator {
// create traces from real examples for each framework source
await this.createFrameworkTraces(projectIds);
// Create traces for media attachment testing
await this.createMediaTestTraces(projectIds);
// Log completion statistics (commented out to reduce terminal noise)
await this.logStatistics();
@@ -448,4 +453,106 @@ export class SeederOrchestrator {
}
}
}
/**
* Creates test traces for media attachment testing (JSON Beta view).
* Use for: Testing media rendering in the trace detail view.
*/
async createMediaTestTraces(projectIds: string[]): Promise<void> {
logger.info(
`Creating media test traces for ${projectIds.length} projects.`,
);
const now = Date.now();
for (const projectId of projectIds) {
logger.info(`Processing media test traces for project ${projectId}`);
const traces: TraceRecordInsertType[] = [
// Trace 1: Image only (in input)
createTrace({
id: MEDIA_TEST_TRACE_IDS.imageOnly,
project_id: projectId,
name: "Media Test: Image Only",
timestamp: now,
input: JSON.stringify({
message: "This trace has an image attachment in input",
description: "Used for testing media rendering in JSON Beta view",
}),
output: JSON.stringify({ status: "success" }),
metadata: { test_type: "media", media_types: "image" },
tags: ["media-test", "image"],
environment: "default",
}),
// Trace 2: All media types
createTrace({
id: MEDIA_TEST_TRACE_IDS.allTypes,
project_id: projectId,
name: "Media Test: All Types",
timestamp: now + 1000,
input: JSON.stringify({
message: "This trace has an image in input",
description: "Testing image attachment",
}),
output: JSON.stringify({
message: "This trace has a PDF in output",
description: "Testing PDF attachment",
}),
metadata: {
message: "This trace has audio in metadata",
description: "Testing audio attachment",
test_type: "media",
media_types: "image,pdf,audio",
},
tags: ["media-test", "all-types"],
environment: "default",
}),
// Trace 3: All media types with ChatML format (pretty-rendered)
createTrace({
id: MEDIA_TEST_TRACE_IDS.allTypesChatML,
project_id: projectId,
name: "Media Test: All Types (ChatML)",
timestamp: now + 2000,
input: JSON.stringify([
{
role: "system",
content:
"You are a helpful assistant that can analyze images, documents, and audio files.",
},
{
role: "user",
content:
"Please analyze the attached image and describe what you see.",
},
]),
output: JSON.stringify([
{
role: "assistant",
content:
"I can see the Langfuse logo in the image. It appears to be a modern, clean design with distinctive branding elements. The attached PDF contains additional documentation about the Bitcoin whitepaper.",
},
]),
metadata: {
message: "This trace has audio in metadata",
description: "Testing audio attachment with ChatML format",
test_type: "media",
media_types: "image,pdf,audio",
format: "chatml",
},
tags: ["media-test", "all-types", "chatml"],
environment: "default",
}),
];
try {
await this.queryBuilder.executeTracesInsert(traces);
logger.info(
`✓ Created ${traces.length} media test traces for project ${projectId}`,
);
} catch (error) {
logger.error(`✗ Media test traces insert failed:`, error);
throw error;
}
}
}
}
-1
View File
@@ -1,4 +1,3 @@
/* eslint-disable no-unused-vars */
// disable lint as this is exported and used in packages
export enum ModelUsageUnit {
Characters = "CHARACTERS",
@@ -3,7 +3,6 @@ import { FilterState } from "../types";
import { z } from "zod/v4";
export enum TriggerEventSource {
// eslint-disable-next-line no-unused-vars
Prompt = "prompt",
}
@@ -93,6 +93,10 @@ export const ObservationSchema = z.object({
// pricing tier information
usagePricingTierId: z.string().nullable(),
usagePricingTierName: z.string().nullable(),
// tool data
toolDefinitions: z.record(z.string(), z.string()).nullable(),
toolCalls: z.array(z.string()).nullable(),
toolCallNames: z.array(z.string()).nullable(),
});
export type Observation = z.infer<typeof ObservationSchema>;
+42 -1
View File
@@ -10,15 +10,19 @@ export const ScoreSourceEnum = {
export const ScoreSourceDomain = z.enum(ScoreSourceArray);
export type ScoreSourceType = z.infer<typeof ScoreSourceDomain>;
export const CORRECTION_NAME = "output" as const;
export const ScoreDataTypeArray = [
"NUMERIC",
"CATEGORICAL",
"BOOLEAN",
"CORRECTION",
] as const;
export const ScoreDataTypeEnum = {
NUMERIC: "NUMERIC",
CATEGORICAL: "CATEGORICAL",
BOOLEAN: "BOOLEAN",
CORRECTION: "CORRECTION",
} as const;
export const ScoreDataTypeDomain = z.enum(ScoreDataTypeArray);
export type ScoreDataTypeType = z.infer<typeof ScoreDataTypeDomain>;
@@ -38,6 +42,11 @@ export const BooleanData = z.object({
dataType: z.literal("BOOLEAN"),
});
const CorrectionData = z.object({
stringValue: z.null(),
dataType: z.literal("CORRECTION"),
});
// Only used for backwards compatibility with old score API schemas
export const ScoreSchemaExclReferencesAndDates = z.object({
// Core identifiers
@@ -70,11 +79,43 @@ const ScoreFoundationSchema = ScoreSchemaExclReferencesAndDates.and(
sessionId: z.string().nullable(),
datasetRunId: z.string().nullable(),
observationId: z.string().nullable(),
longStringValue: z.string().default(""),
}),
);
export const ScoreSchema = ScoreFoundationSchema.and(
z.discriminatedUnion("dataType", [NumericData, CategoricalData, BooleanData]),
z.discriminatedUnion("dataType", [
NumericData,
CategoricalData,
BooleanData,
CorrectionData,
]),
);
export type ScoreDomain = z.infer<typeof ScoreSchema>;
export type ScoreByDataType<T extends ScoreDataTypeType> = ScoreDomain & {
dataType: T;
};
export type ScoresByDataTypes<T extends readonly ScoreDataTypeType[]> =
T extends readonly (infer U)[]
? U extends ScoreDataTypeType
? ScoreByDataType<U>
: never
: never;
// Aggregatable score types - used in most read queries to exclude CORRECTION scores
export const AGGREGATABLE_SCORE_TYPES = [
"NUMERIC",
"BOOLEAN",
"CATEGORICAL",
] as const satisfies readonly ScoreDataTypeType[];
export type AggregatableScoreDataType =
(typeof AGGREGATABLE_SCORE_TYPES)[number];
// Type helper for functions that return only aggregatable scores
export type AggregatableScore = ScoresByDataTypes<
typeof AGGREGATABLE_SCORE_TYPES
>;
@@ -3,13 +3,14 @@ import { orderBy } from "../interfaces/orderBy";
import z from "zod/v4";
export enum TableViewPresetTableName {
Traces = "traces", // eslint-disable-line no-unused-vars
Observations = "observations", // eslint-disable-line no-unused-vars
Scores = "scores", // eslint-disable-line no-unused-vars
Sessions = "sessions", // eslint-disable-line no-unused-vars
Datasets = "datasets", // eslint-disable-line no-unused-vars
Traces = "traces",
Observations = "observations",
Scores = "scores",
Sessions = "sessions",
Datasets = "datasets",
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const TableViewPresetDomainSchema = z.object({
id: z.string(),
projectId: z.string().nullable(),
+6 -1
View File
@@ -60,6 +60,11 @@ const EnvSchema = z.object({
// Optional to allow for server-setting fallbacks
CLICKHOUSE_ASYNC_INSERT_MAX_DATA_SIZE: z.string().optional(),
CLICKHOUSE_ASYNC_INSERT_BUSY_TIMEOUT_MS: z.coerce.number().int().optional(),
CLICKHOUSE_ASYNC_INSERT_BUSY_TIMEOUT_MIN_MS: z.coerce
.number()
.int()
.min(50)
.optional(),
CLICKHOUSE_LIGHTWEIGHT_DELETE_MODE: z
.enum(["alter_update", "lightweight_update", "lightweight_update_force"])
.default("alter_update"),
@@ -192,7 +197,7 @@ const EnvSchema = z.object({
}
return map;
} catch (err) {
} catch {
return new Map<string, number>();
}
}),
@@ -0,0 +1,7 @@
import { BaseError } from "./BaseError";
export class NotImplementedError extends BaseError {
constructor(description = "Not Implemented") {
super("NotImplementedError", 501, description, true);
}
}
+1
View File
@@ -8,3 +8,4 @@ export { ApiError } from "./ApiError";
export { InternalServerError } from "./InternalServerError";
export { LangfuseConflictError } from "./ConflictError";
export { ServiceUnavailableError } from "./ServiceUnavailableError";
export { NotImplementedError } from "./NotImplementedError";
+38
View File
@@ -73,6 +73,14 @@ export const eventsTableCols: ColumnDefinition[] = [
internal: "e.session_id",
nullable: true,
},
{
name: "Trace Name",
id: "traceName",
type: "stringOptions",
internal: "e.trace_name",
options: [], // to be added at runtime
nullable: true,
},
{
name: "Level",
id: "level",
@@ -235,4 +243,34 @@ export const eventsTableCols: ColumnDefinition[] = [
type: "string",
internal: "", // handled by comment filter helpers
},
{
name: "Has Parent Observation",
id: "hasParentObservation",
type: "boolean",
internal: "e.parent_span_id != ''",
},
{
name: "Experiment Dataset ID",
id: "experimentDatasetId",
type: "stringOptions",
internal: "e.experiment_dataset_id",
options: [], // to be added at runtime
nullable: true,
},
{
name: "Experiment ID",
id: "experimentId",
type: "stringOptions",
internal: "e.experiment_id",
options: [], // to be added at runtime
nullable: true,
},
{
name: "Experiment Name",
id: "experimentName",
type: "stringOptions",
internal: "e.experiment_name",
options: [], // to be added at runtime
nullable: true,
},
];
@@ -1,10 +1,11 @@
import { JSONPath } from "jsonpath-plus";
import { set } from "lodash";
import set from "lodash/set";
import type {
FieldMappingConfig,
SourceField,
AddToDatasetMapping,
} from "./addToDatasetTypes";
import { parseJsonPrioritised } from "../../utils/json";
type ObservationData = {
input: unknown;
@@ -37,9 +38,14 @@ export function testJsonPath(props: { jsonPath: string; data: unknown }): {
*/
export function evaluateJsonPath(data: unknown, jsonPath: string): unknown {
try {
const parsed = typeof data === "string" ? JSON.parse(data) : data;
const results = JSONPath({ path: jsonPath, json: parsed });
return results?.[0];
const parsed = typeof data === "string" ? parseJsonPrioritised(data) : data;
const result = JSONPath({
path: jsonPath,
json: parsed as string | object,
wrap: false,
});
return result;
} catch {
return undefined;
}
@@ -4,7 +4,6 @@ import { orderBy } from "../../interfaces/orderBy";
import { BatchTableNames } from "../../interfaces/tableNames";
import { TracingSearchType } from "../../interfaces/search";
/* eslint-disable no-unused-vars */
export enum BatchActionType {
Create = "create",
Delete = "delete",
@@ -8,17 +8,17 @@ import { BatchTableNames } from "../../interfaces/tableNames";
import { TracingSearchType } from "../../interfaces/search";
export enum BatchExportStatus {
QUEUED = "QUEUED", // eslint-disable-line no-unused-vars
PROCESSING = "PROCESSING", // eslint-disable-line no-unused-vars
COMPLETED = "COMPLETED", // eslint-disable-line no-unused-vars
FAILED = "FAILED", // eslint-disable-line no-unused-vars
CANCELLED = "CANCELLED", // eslint-disable-line no-unused-vars
QUEUED = "QUEUED",
PROCESSING = "PROCESSING",
COMPLETED = "COMPLETED",
FAILED = "FAILED",
CANCELLED = "CANCELLED",
}
export enum BatchExportFileFormat {
JSON = "JSON", // eslint-disable-line no-unused-vars
CSV = "CSV", // eslint-disable-line no-unused-vars
JSONL = "JSONL", // eslint-disable-line no-unused-vars
JSON = "JSON",
CSV = "CSV",
JSONL = "JSONL",
}
// Use shared BatchTableNames enum for consistency across batch operations
+63 -7
View File
@@ -2,19 +2,73 @@ import { z } from "zod/v4";
const MAX_COMMENT_LENGTH = 5000;
const COMMENT_OBJECT_TYPES = [
export const COMMENT_OBJECT_TYPES = [
"TRACE",
"OBSERVATION",
"SESSION",
"PROMPT",
] as const;
export const CreateCommentData = z.object({
projectId: z.string(),
content: z.string().trim().min(1).max(MAX_COMMENT_LENGTH),
objectId: z.string(),
objectType: z.enum(COMMENT_OBJECT_TYPES),
});
export const COMMENT_DATA_FIELDS = ["input", "output", "metadata"] as const;
// JSON Path validation
// TODO: simplify
// Allows: $ (root), $.foo, $[0], $.foo[0].bar, $['key-with-dash'], etc.
export const jsonPathSchema = z
.string()
.regex(
/^\$(\.[a-zA-Z_][a-zA-Z0-9_-]*|\[\d+\]|\['[^']+'\]|\["[^"]+"\])*$/,
"Invalid JSON Path syntax",
);
export const CreateCommentData = z
.object({
projectId: z.string(),
content: z.string().trim().min(1).max(MAX_COMMENT_LENGTH),
objectId: z.string(),
objectType: z.enum(COMMENT_OBJECT_TYPES),
// Optional inline positioning (parallel arrays)
dataField: z.enum(COMMENT_DATA_FIELDS).nullish(),
path: z.array(jsonPathSchema).nullish(),
rangeStart: z.array(z.number().int().min(0)).nullish(),
rangeEnd: z.array(z.number().int().min(1)).nullish(),
})
.refine(
(data) => {
const hasDataField = data.dataField != null;
const hasPath = data.path != null && data.path.length > 0;
const hasRangeStart =
data.rangeStart != null && data.rangeStart.length > 0;
const hasRangeEnd = data.rangeEnd != null && data.rangeEnd.length > 0;
// All must be set together, or all must be null/empty
if (hasDataField || hasPath || hasRangeStart || hasRangeEnd) {
return (
hasDataField &&
hasPath &&
hasRangeStart &&
hasRangeEnd &&
data.path!.length === data.rangeStart!.length &&
data.path!.length === data.rangeEnd!.length
);
}
return true;
},
{
message:
"dataField, path, rangeStart, rangeEnd must all be set together with matching lengths, or all be null/empty",
},
)
.refine(
(data) => {
// Validate each range: start must be < end
if (data.rangeStart && data.rangeEnd) {
return data.rangeStart.every((start, i) => start < data.rangeEnd![i]);
}
return true;
},
{ message: "Each rangeStart must be less than corresponding rangeEnd" },
);
export const DeleteCommentData = z.object({
projectId: z.string(),
@@ -22,3 +76,5 @@ export const DeleteCommentData = z.object({
objectId: z.string(),
objectType: z.enum(COMMENT_OBJECT_TYPES),
});
export type CreateCommentInput = z.infer<typeof CreateCommentData>;
@@ -68,7 +68,7 @@ function parseJsonDefault(selectedColumn: unknown, jsonSelector: string) {
export function extractValueFromObject(
obj: Record<string, unknown>,
mapping: z.infer<typeof variableMapping>,
parseJson?: (selectedColumn: unknown, jsonSelector: string) => unknown, // eslint-disable-line no-unused-vars
parseJson?: (selectedColumn: unknown, jsonSelector: string) => unknown,
): { value: string; error: Error | null } {
let selectedColumn = obj[mapping.selectedColumnId];
@@ -14,9 +14,8 @@ export const SingleChatMessageSchema = PromptChatMessageSchema;
export type SingleChatMessage = z.infer<typeof SingleChatMessageSchema>;
export enum PromptType {
// eslint-disable-next-line no-unused-vars
Chat = "chat",
// eslint-disable-next-line no-unused-vars
Text = "text",
}
@@ -13,7 +13,7 @@ import {
*/
export const filterAndValidateDbScoreConfigList = (
scoreConfigs: ScoreConfigDbType[],
onParseError?: (error: z.ZodError) => void, // eslint-disable-line no-unused-vars
onParseError?: (error: z.ZodError) => void,
): ScoreConfigDomain[] =>
scoreConfigs.reduce((acc, ts) => {
const result = ScoreConfigSchema.safeParse(ts);
@@ -10,6 +10,9 @@ import {
const operators = ["<", ">", "<=", ">=", "!=", "="] as const;
export const SCORE_FIELD_GROUPS = ["score", "trace"] as const;
export type ScoreFieldGroup = (typeof SCORE_FIELD_GROUPS)[number];
/**
* Endpoints
*/
@@ -41,6 +44,17 @@ export const GetScoresQuery = z.object({
message: "Each score ID must be a string",
})
.nullish(),
fields: z
.string()
.nullish()
.transform((v) => {
if (!v) return null;
return v
.split(",")
.map((f) => f.trim())
.filter((f) => SCORE_FIELD_GROUPS.includes(f as ScoreFieldGroup));
})
.pipe(z.array(z.enum(SCORE_FIELD_GROUPS)).nullable()),
});
// POST /scores
@@ -69,6 +83,11 @@ export const PostScoresBody = applyScoreValidation(
dataType: z.literal("BOOLEAN"),
configId: z.string().nullish(),
}),
z.object({
value: z.string(), // Corrected output text
dataType: z.literal("CORRECTION"),
configId: z.undefined().nullish(), // Cannot have config
}),
z.object({
value: z.union([z.string(), z.number()]),
dataType: z.undefined(),
@@ -11,7 +11,7 @@ import { GetScoreResponseDataV1 } from "./endpoints";
*/
export const filterAndValidateV1GetScoreList = (
scores: unknown[],
// eslint-disable-next-line no-unused-vars
onParseError?: (error: z.ZodError) => void,
): z.infer<typeof GetScoreResponseDataV1>[] =>
scores.reduce(
@@ -13,18 +13,20 @@ export const GetScoresQueryV2 = GetScoresQuery.extend({
traceId: z.string().nullish(),
datasetRunId: z.string().nullish(),
});
export const GetScoreResponseDataV2 = z.intersection(
APIScoreSchemaV2,
z.object({
trace: z
.object({
userId: z.string().nullish(),
tags: z.array(z.string()).nullish(),
environment: z.string().nullish(),
})
.nullish(),
}),
);
export const GetScoreResponseDataV2 = z
.intersection(
APIScoreSchemaV2,
z.object({
trace: z
.object({
userId: z.string().nullish(),
tags: z.array(z.string()).nullish(),
environment: z.string().nullish(),
})
.nullish(),
}),
)
.or(APIScoreSchemaV2);
export const GetScoresResponseV2 = z.object({
data: z.array(GetScoreResponseDataV2),
@@ -6,6 +6,11 @@ import {
} from "../../../../../domain";
import z from "zod/v4";
const CorrectionData = z.object({
stringValue: z.string(),
dataType: z.literal("CORRECTION"),
});
/**
* Foundation schema for scores API v2 i.e. trace, observation AND session scores
*
@@ -25,7 +30,12 @@ const ScoreFoundationSchemaV2 = ScoreSchemaExclReferencesAndDates.extend({
});
export const APIScoreSchemaV2 = ScoreFoundationSchemaV2.and(
z.discriminatedUnion("dataType", [NumericData, CategoricalData, BooleanData]),
z.discriminatedUnion("dataType", [
NumericData,
CategoricalData,
BooleanData,
CorrectionData,
]),
);
export type APIScoreV2 = z.infer<typeof APIScoreSchemaV2>;
@@ -9,7 +9,7 @@ import { GetScoreResponseDataV2 } from "./endpoints";
*/
export const filterAndValidateV2GetScoreList = (
scores: unknown[],
// eslint-disable-next-line no-unused-vars
onParseError?: (error: z.ZodError) => void,
): z.infer<typeof GetScoreResponseDataV2>[] =>
scores.reduce(
@@ -1,8 +1,16 @@
import z from "zod/v4";
import { APIScoreSchemaV1, APIScoreV1 } from "../api/v1/schemas";
import { ScoreDomain, ScoreSchema } from "../../../../domain";
import {
ScoreDomain,
ScoreSchema,
ScoreDataTypeType,
ScoresByDataTypes,
} from "../../../../domain";
type ValidatedScore<IncludeHasMetadata extends boolean> = ScoreDomain & {
type ValidatedScore<
IncludeHasMetadata extends boolean,
DataTypes extends readonly ScoreDataTypeType[],
> = ScoresByDataTypes<DataTypes> & {
hasMetadata: IncludeHasMetadata extends true ? boolean : never;
};
@@ -21,35 +29,50 @@ export const validateDbScore = (score: unknown): ScoreDomain =>
/**
* Use this function when pulling a list of scores from the database before using in the application to ensure type safety.
* All scores are expected to pass the validation. If a score fails validation, it will be logged to Otel.
* @param scores
* @returns list of validated scores
* This function filters scores by the specified data types and validates them.
* @param scores - List of scores to filter and validate
* @param dataTypes - Array of data types to filter by (required)
* @param includeHasMetadata - Whether to include hasMetadata field
* @param onParseError - Optional callback for parse errors
* @returns list of validated and filtered scores
*/
export const filterAndValidateDbScoreList = <
IncludeHasMetadata extends boolean,
DataTypes extends readonly ScoreDataTypeType[],
>({
scores,
dataTypes,
includeHasMetadata = false as IncludeHasMetadata,
onParseError,
}: {
scores: InputScore[];
dataTypes: DataTypes;
includeHasMetadata?: IncludeHasMetadata;
// eslint-disable-next-line no-unused-vars
onParseError?: (error: z.ZodError) => void;
}): ValidatedScore<IncludeHasMetadata>[] => {
return scores.reduce((acc, ts) => {
const result = ScoreSchema.safeParse(ts);
if (result.success) {
const score = { ...result.data };
if (includeHasMetadata) {
Object.assign(score, { hasMetadata: ts.hasMetadata ?? false });
}): ValidatedScore<IncludeHasMetadata, DataTypes>[] => {
return scores.reduce(
(acc, ts) => {
// Filter by dataType first
if (!dataTypes.includes(ts.dataType)) {
return acc;
}
acc.push(score as ValidatedScore<IncludeHasMetadata>);
} else {
console.error("Score parsing error: ", result.error);
onParseError?.(result.error);
}
return acc;
}, [] as ValidatedScore<IncludeHasMetadata>[]);
const result = ScoreSchema.safeParse(ts);
if (result.success) {
const score = { ...result.data };
if (includeHasMetadata) {
Object.assign(score, { hasMetadata: ts.hasMetadata ?? false });
}
acc.push(score as ValidatedScore<IncludeHasMetadata, DataTypes>);
} else {
console.error("Score parsing error: ", result.error);
onParseError?.(result.error);
}
return acc;
},
[] as ValidatedScore<IncludeHasMetadata, DataTypes>[],
);
};
type ValidatedAPITraceScore<IncludeHasMetadata extends boolean> = APIScoreV1 & {
@@ -73,7 +96,7 @@ export const filterAndValidateDbTraceScoreList = <
}: {
scores: InputScore[];
includeHasMetadata?: IncludeHasMetadata;
// eslint-disable-next-line no-unused-vars
onParseError?: (error: z.ZodError) => void;
}): ValidatedAPITraceScore<IncludeHasMetadata>[] => {
return scores.reduce((acc, ts) => {
@@ -18,6 +18,12 @@ export const ScoreBodyWithoutConfig = applyScoreValidation(
dataType: z.literal("CATEGORICAL"),
}),
),
PostScoreBodyFoundationSchema.merge(
z.object({
value: z.string(),
dataType: z.literal("CORRECTION"),
}),
),
PostScoreBodyFoundationSchema.merge(
z.object({
value: z.number().refine((val) => val === 0 || val === 1, {
@@ -1,5 +1,8 @@
import { ScoreDataType } from "@prisma/client";
import { MetadataDomain, ScoreSourceType } from "../../../../domain";
import {
AggregatableScoreDataType,
MetadataDomain,
ScoreSourceType,
} from "../../../../domain";
export type BaseAggregate = {
comment?: string | null;
@@ -27,7 +30,7 @@ export type ScoreAggregate = Record<string, AggregatedScoreData>;
export type ScoreSimplified = {
id: string;
name: string;
dataType: ScoreDataType;
dataType: AggregatableScoreDataType;
source: ScoreSourceType;
value?: number | null;
comment?: string | null;
+2
View File
@@ -16,6 +16,8 @@ export * from "./utils/objects";
export * from "./utils/typeChecks";
export * from "./utils/prompts";
export * from "./utils/jsonSchemaValidation";
export * from "./utils/chatml";
export * from "./utils/math";
export * from "./features/entitlements/plans";
export * from "./interfaces/rate-limits";
export * from "./tableDefinitions/typeHelpers";
+7 -7
View File
@@ -4,11 +4,11 @@
* to avoid coupling between different batch operation types.
*/
export enum BatchTableNames {
Scores = "scores", // eslint-disable-line no-unused-vars
Sessions = "sessions", // eslint-disable-line no-unused-vars
Traces = "traces", // eslint-disable-line no-unused-vars
Observations = "observations", // eslint-disable-line no-unused-vars
DatasetRunItems = "dataset_run_items", // eslint-disable-line no-unused-vars
DatasetItems = "dataset_items", // eslint-disable-line no-unused-vars
AuditLogs = "audit_logs", // eslint-disable-line no-unused-vars
Scores = "scores",
Sessions = "sessions",
Traces = "traces",
Observations = "observations",
DatasetRunItems = "dataset_run_items",
DatasetItems = "dataset_items",
AuditLogs = "audit_logs",
}
+38
View File
@@ -232,6 +232,36 @@ export const observationsTableCols: ColumnDefinition[] = [
type: "string",
internal: "", // handled by comment filter helpers
},
{
name: "Available Tool Names",
id: "toolNames",
type: "arrayOptions",
internal: "", // ClickHouse only - uses mapKeys(tool_definitions)
options: [], // to be added at runtime
nullable: true,
},
{
name: "Called Tool Names",
id: "calledToolNames",
type: "arrayOptions",
internal: "", // ClickHouse only - uses tool_call_names
options: [], // to be added at runtime
nullable: true,
},
{
name: "Available Tools",
id: "toolDefinitions",
type: "number",
internal: "", // ClickHouse only
nullable: true,
},
{
name: "Tool Calls",
id: "toolCalls",
type: "number",
internal: "", // ClickHouse only
nullable: true,
},
];
// to be used client side, insert options for use in filter-builder
@@ -247,6 +277,8 @@ export type ObservationOptions = {
promptName: Array<SingleValueOption>;
tags: Array<SingleValueOption>;
type: Array<SingleValueOption>;
toolNames: Array<SingleValueOption>;
calledToolNames: Array<SingleValueOption>;
};
export function observationsTableColsWithOptions(
@@ -283,6 +315,12 @@ export function observationsTableColsWithOptions(
if (col.id === "type") {
return formatColumnOptions(col, options?.type ?? []);
}
if (col.id === "toolNames") {
return formatColumnOptions(col, options?.toolNames ?? []);
}
if (col.id === "calledToolNames") {
return formatColumnOptions(col, options?.calledToolNames ?? []);
}
return col;
});
}
@@ -0,0 +1,66 @@
import { ClickHouseLogLevel, Logger } from "@clickhouse/client";
import { logger as winstonLogger } from "../logger";
/**
* ClickHouseLogger bridges the ClickHouse client Logger interface to our Winston logger
* This ensures ClickHouse client logs are formatted consistently with our application logs
* and include OpenTelemetry trace context for correlation with spans in DataDog
*/
export class ClickHouseLogger implements Logger {
trace({ module, message, args }: LogParams): void {
winstonLogger.info(`[${module}] ${message}`, args);
}
debug({ module, message, args }: LogParams): void {
winstonLogger.info(`[${module}] ${message}`, args);
}
info({ module, message, args }: LogParams): void {
winstonLogger.info(`[${module}] ${message}`, args);
}
warn({ module, message, args, err }: WarnLogParams): void {
winstonLogger.warn(`[${module}] ${message}`, {
...args,
...(err ? { error: err.message, stack: err.stack } : {}),
});
}
error({ module, message, args, err }: ErrorLogParams): void {
winstonLogger.error(`[${module}] ${message}`, {
...args,
error: err.message,
stack: err.stack,
});
}
}
// Types from @clickhouse/client documentation
interface LogParams {
module: string;
message: string;
args?: Record<string, unknown>;
}
type ErrorLogParams = LogParams & { err: Error };
type WarnLogParams = LogParams & { err?: Error };
/**
* Map Winston log level to ClickHouse log level
*/
export const mapLogLevel = (level: string): ClickHouseLogLevel => {
switch (level.toLowerCase()) {
case "error":
return ClickHouseLogLevel.ERROR;
case "warn":
return ClickHouseLogLevel.WARN;
case "info":
return ClickHouseLogLevel.INFO;
case "debug":
return ClickHouseLogLevel.DEBUG;
case "trace":
return ClickHouseLogLevel.TRACE;
default:
return ClickHouseLogLevel.OFF;
}
};
@@ -3,6 +3,7 @@ import { env } from "../../env";
import { NodeClickHouseClientConfigOptions } from "@clickhouse/client/dist/config";
import { getCurrentSpan } from "../instrumentation";
import { propagation, context } from "@opentelemetry/api";
import { ClickHouseLogger, mapLogLevel } from "./clickhouse-logger";
export type ClickhouseClientType = ReturnType<typeof createClient>;
@@ -107,6 +108,10 @@ export class ClickHouseClientManager {
idle_socket_ttl: env.CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL,
},
max_open_connections: env.CLICKHOUSE_MAX_OPEN_CONNECTIONS,
log: {
LoggerClass: ClickHouseLogger,
level: mapLogLevel(env.LANGFUSE_LOG_LEVEL ?? "info"),
},
clickhouse_settings: {
// Overwrite async insert settings to tune throughput
...(env.CLICKHOUSE_ASYNC_INSERT_MAX_DATA_SIZE
@@ -121,6 +126,12 @@ export class ClickHouseClientManager {
env.CLICKHOUSE_ASYNC_INSERT_BUSY_TIMEOUT_MS,
}
: {}),
...(env.CLICKHOUSE_ASYNC_INSERT_BUSY_TIMEOUT_MIN_MS
? {
async_insert_busy_timeout_min_ms:
env.CLICKHOUSE_ASYNC_INSERT_BUSY_TIMEOUT_MIN_MS,
}
: {}),
...(env.CLICKHOUSE_LIGHTWEIGHT_DELETE_MODE !== "alter_update"
? {
lightweight_delete_mode: env.CLICKHOUSE_LIGHTWEIGHT_DELETE_MODE,
@@ -134,7 +145,7 @@ export class ClickHouseClientManager {
...(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
http_headers_progress_interval_ms: "10000", // UInt64, should be passed as a string
}
: {}),
},
@@ -3,7 +3,7 @@ import { instrumentAsync } from "../instrumentation";
const executionWrapper = async <T, Y>(
input: T,
fn: (input: T) => Promise<Y>, // eslint-disable-line no-unused-vars
fn: (input: T) => Promise<Y>,
span?: opentelemetry.Span,
attributePrefix?: string,
): Promise<[Y, number]> => {
@@ -26,7 +26,7 @@ export const measureAndReturn = async <T, Y>(args: {
operationName: string;
projectId: string;
input: T;
fn: (input: T) => Promise<Y>; // eslint-disable-line no-unused-vars
fn: (input: T) => Promise<Y>;
}): Promise<Y> => {
return instrumentAsync(
{
@@ -39,10 +39,9 @@ export const measureAndReturn = async <T, Y>(args: {
// When we want do to multiple executions for A/B testing or canary releases,
// or some other form of a more complex wrapper we used to try-catch
// using the wrapper function and fallback to a simple f(input) when it failed.
const [[existingResult, _existingDuration]] = // eslint-disable-line no-unused-vars
await Promise.all([
executionWrapper(input, fn, currentSpan, "existing"),
]);
const [[existingResult, _existingDuration]] = await Promise.all([
executionWrapper(input, fn, currentSpan, "existing"),
]);
return existingResult;
},
@@ -0,0 +1,128 @@
import { queryClickhouse } from "../repositories";
// ============================================================================
// Types
// ============================================================================
export type QueryStatus = "running" | "completed" | "failed" | "not_found";
// ============================================================================
// Utilities
// ============================================================================
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ============================================================================
// Query Status Polling
// ============================================================================
/**
* Polls ClickHouse to determine the status of a query by its query_id.
* First checks system.processes for running queries, then system.query_log for completed/failed.
*/
export async function pollQueryStatus(
queryId: string,
tags?: Record<string, string>,
): Promise<QueryStatus> {
const tagsWithDefaults = {
feature: "query-tracking",
operation: "pollQueryStatus",
...tags,
};
// First check if still running in system.processes
const running = await queryClickhouse<{ query_id: string }>({
query: `
SELECT query_id
FROM clusterAllReplicas('default', 'system.processes')
WHERE query_id = {queryId: String}
LIMIT 1
`,
params: { queryId },
clickhouseConfigs: {
request_timeout: 60_000,
},
clickhouseSettings: {
skip_unavailable_shards: 1,
},
tags: tagsWithDefaults,
});
if (running.length > 0) {
return "running";
}
// Check query_log for completion status
const result = await queryClickhouse<{
type: string;
exception_code: string;
}>({
query: `
SELECT type, exception_code
FROM clusterAllReplicas('default', 'system.query_log')
WHERE query_id = {queryId: String}
ORDER BY event_time_microseconds DESC
LIMIT 1
`,
params: { queryId },
clickhouseConfigs: {
request_timeout: 60_000,
},
clickhouseSettings: {
skip_unavailable_shards: 1,
},
tags: tagsWithDefaults,
});
if (result.length === 0) {
return "not_found";
}
const { type, exception_code } = result[0];
if (type === "QueryStart") {
return "running";
}
if (
type === "ExceptionBeforeStart" ||
type === "ExceptionWhileProcessing" ||
parseInt(exception_code, 10) !== 0
) {
return "failed";
}
if (type === "QueryFinish") {
return "completed";
}
throw new Error(`Unknown query log type: ${type}`);
}
/**
* Gets the error message for a failed query from system.query_log.
*/
export async function getQueryError(
queryId: string,
): Promise<string | undefined> {
const result = await queryClickhouse<{ exception_message: string }>({
query: `
SELECT exception as exception_message
FROM clusterAllReplicas('default', 'system.query_log')
WHERE query_id = {queryId: String}
AND type != 'QueryStart'
AND exception != ''
ORDER BY event_time_microseconds DESC
LIMIT 1
`,
params: { queryId },
clickhouseSettings: {
skip_unavailable_shards: 1,
},
tags: {
feature: "query-tracking",
operation: "getQueryError",
},
});
return result[0]?.exception_message;
}
@@ -1,7 +1,5 @@
import { env } from "../../env";
/* eslint-disable no-unused-vars */
export enum OperationType {
READ = "READ",
WRITE = "WRITE",
+8
View File
@@ -11,6 +11,7 @@ export * from "./services/PromptService/types";
export * from "./services/traces-ui-table-service";
export * from "./services/InMemoryFilterService";
export * from "./services/DatasetService";
export * from "./services/commentFilterService";
export * from "./datasets/schemaValidation";
export * from "./datasets/schemaTypes";
export * from "./evalJobConfigCache";
@@ -34,11 +35,14 @@ export * from "./otel/utils";
export * from "./clickhouse/client";
export * from "./clickhouse/schemaUtils";
export * from "./clickhouse/schema";
export * from "./clickhouse/queryTracking";
export * from "./repositories/definitions";
export * from "../utils/IORepresentation/chatML/types";
export * from "../server/ingestion/types";
export * from "../server/ingestion/modelMatch";
export * from "./ingestion/processEventBatch";
export * from "../server/ingestion/validateAndInflateScore";
export * from "./ingestion/extractToolsBackend";
export * from "../server/queries/public-api-filter-builder";
export * from "../server/pricing-tiers";
export * from "./redis/redis";
@@ -71,10 +75,14 @@ export * from "./redis/meteringDataPostgresExportQueue";
export * from "./redis/notificationQueue";
export * from "./webhooks/validation";
export * from "./webhooks/ipBlocking";
export * from "./webhooks/redirectHandler";
export * from "./redis/experimentCreateQueue";
export * from "./redis/dlqRetryQueue";
export * from "./redis/entityChangeQueue";
export * from "./redis/eventPropagationQueue";
export * from "./redis/batchProjectCleanerQueue";
export * from "./redis/batchDataRetentionCleanerQueue";
export * from "./redis/mediaRetentionCleanerQueue";
export * from "./redis/otelProjectTracking";
export * from "./redis/s3SlowdownTracking";
export * from "./auth/types";
@@ -0,0 +1,387 @@
import { z } from "zod/v4";
/**
* ClickHouse storage schema for tool definitions.
*
* Based on ToolDefinitionSchema from packages/shared/src/utils/IORepresentation/chatML/types.ts
* `parameters` stored as JSON string instead of z.record
*/
export const ClickhouseToolDefinitionSchema = z.object({
name: z.string(),
description: z.string().optional(),
parameters: z.string().optional(), // JSON string of parameters schema
});
export type ClickhouseToolDefinition = z.infer<
typeof ClickhouseToolDefinitionSchema
>;
/**
* ClickHouse storage schema for tool calls (invocations).
*
* Based on ToolCallSchema from packages/shared/src/utils/IORepresentation/chatML/types.ts
* Adapted for ClickHouse Array(JSON) storage:
* - `arguments` stored as JSON string (base may have parsed object)
* - `index` optional field included for parallel tool call ordering
*/
export const ClickhouseToolArgumentSchema = z.object({
id: z.string(),
name: z.string(),
arguments: z.string(), // JSON string of call arguments
type: z.string().optional(),
index: z.number().optional(),
});
export type ClickhouseToolArgument = z.infer<
typeof ClickhouseToolArgumentSchema
>;
/**
* Flatten tool definition from nested or flat format.
* Handles both OpenAI Chat Completions {type, function: {name, ...}} and flat {name, ...}.
*/
function flattenToolDefinition(tool: unknown): {
name?: string;
description?: string;
parameters?: unknown;
} {
if (!tool || typeof tool !== "object") return {};
const t = tool as Record<string, unknown>;
// Handle nested {type, function: {name, ...}} format (OpenAI Chat Completions)
const toolData = (t.function as Record<string, unknown> | undefined) ?? t;
return {
name: toolData.name as string | undefined,
description: (toolData.description ?? toolData.desc) as string | undefined,
parameters:
toolData.parameters ??
toolData.parameters_json_schema ??
toolData.inputSchema,
};
}
/**
* Flatten tool call from nested or flat format.
* Handles OpenAI {function: {name, arguments}} and flat {name, arguments}.
*/
function flattenToolCall(call: unknown): {
id?: string;
name?: string;
arguments?: string;
type?: string;
index?: number;
} {
if (!call || typeof call !== "object") return {};
const c = call as Record<string, unknown>;
// Handle nested {function: {name, arguments}} format
const func = c.function as Record<string, unknown> | undefined;
const name = (func?.name ?? c.name ?? c.toolName) as string | undefined;
const rawArgs = func?.arguments ?? c.arguments ?? c.args;
const args =
typeof rawArgs === "string" ? rawArgs : JSON.stringify(rawArgs ?? {});
return {
id: (c.id ?? c.toolCallId ?? c.call_id) as string | undefined,
name,
arguments: args,
type: (c.type ?? (func ? "function" : undefined)) as string | undefined,
index: c.index as number | undefined,
};
}
/**
* Helper to add a tool definition, deduplicating by name.
*/
function addToolDefinition(
definitions: ClickhouseToolDefinition[],
tool: unknown,
): void {
const flattened = flattenToolDefinition(tool);
if (!flattened.name) return; // Skip invalid tools
const normalized: ClickhouseToolDefinition = {
name: flattened.name,
description: flattened.description,
parameters: flattened.parameters
? JSON.stringify(flattened.parameters)
: undefined,
};
// Deduplicate by name
if (!definitions.some((t) => t.name === normalized.name)) {
definitions.push(normalized);
}
}
/**
* Helper to add a tool call/argument.
*/
function addToolArgument(args: ClickhouseToolArgument[], call: unknown): void {
const flattened = flattenToolCall(call);
if (!flattened.name) return; // Skip invalid calls
args.push({
id: flattened.id ?? "",
name: flattened.name,
arguments: flattened.arguments ?? "{}",
type: flattened.type,
index: flattened.index,
});
}
/**
* Extract tool definitions from raw input data (top-level tools array).
*/
function extractToolsFromRawInput(
input: unknown,
definitions: ClickhouseToolDefinition[],
): void {
if (!input || typeof input !== "object") return;
const obj = Array.isArray(input)
? { messages: input }
: (input as Record<string, unknown>);
// Top-level tools array (OpenAI request format)
if (Array.isArray(obj.tools)) {
for (const tool of obj.tools) {
addToolDefinition(definitions, tool);
}
}
// Messages array with tools on individual messages
if (Array.isArray(obj.messages)) {
for (const msg of obj.messages) {
if (msg && typeof msg === "object") {
// Standard tools array on message
if (Array.isArray((msg as any).tools)) {
for (const tool of (msg as any).tools) {
addToolDefinition(definitions, tool);
}
}
// LangGraph format: tool definition in role:"tool" message content
// TODO: This should be handled by langgraph adapter preprocessing in the future
if (
(msg as any).role === "tool" &&
(msg as any).content?.type === "function" &&
(msg as any).content?.function
) {
addToolDefinition(definitions, (msg as any).content);
}
}
}
}
}
/**
* Extract tool calls from raw output data.
*/
function extractToolCallsFromRawOutput(
output: unknown,
args: ClickhouseToolArgument[],
): void {
if (!output) return;
// Array of messages
if (Array.isArray(output)) {
for (const msg of output) {
if (msg && typeof msg === "object") {
extractToolCallsFromMessage(msg as Record<string, unknown>, args);
}
}
return;
}
if (typeof output !== "object") return;
const obj = output as Record<string, unknown>;
// Direct tool_calls at top level
if (Array.isArray(obj.tool_calls)) {
for (const call of obj.tool_calls) {
addToolArgument(args, call);
}
}
// OpenAI choices format: {choices: [{message: {tool_calls: [...]}}]}
if (Array.isArray(obj.choices)) {
for (const choice of obj.choices) {
if (choice && typeof choice === "object") {
const c = choice as Record<string, unknown>;
const message = c.message as Record<string, unknown> | undefined;
if (message && Array.isArray(message.tool_calls)) {
for (const call of message.tool_calls) {
addToolArgument(args, call);
}
}
}
}
}
// Anthropic tool_use in content array: {content: [{type: "tool_use", ...}]}
if (Array.isArray(obj.content)) {
for (const part of obj.content) {
if (
part &&
typeof part === "object" &&
(part as Record<string, unknown>).type === "tool_use"
) {
const p = part as Record<string, unknown>;
addToolArgument(args, {
id: p.id,
name: p.name,
arguments: JSON.stringify(p.input ?? {}),
type: "tool_use",
});
}
}
}
// LangChain additional_kwargs: {additional_kwargs: {tool_calls: [...]}}
const additionalKwargs = obj.additional_kwargs as
| Record<string, unknown>
| undefined;
if (additionalKwargs && Array.isArray(additionalKwargs.tool_calls)) {
for (const call of additionalKwargs.tool_calls) {
addToolArgument(args, call);
}
}
}
/**
* Extract tool calls from a single message object.
*/
function extractToolCallsFromMessage(
msg: Record<string, unknown>,
args: ClickhouseToolArgument[],
): void {
if (Array.isArray(msg.tool_calls)) {
for (const call of msg.tool_calls) {
addToolArgument(args, call);
}
}
// Anthropic content array
if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (
part &&
typeof part === "object" &&
(part as Record<string, unknown>).type === "tool_use"
) {
const p = part as Record<string, unknown>;
addToolArgument(args, {
id: p.id,
name: p.name,
arguments: JSON.stringify(p.input ?? {}),
type: "tool_use",
});
}
}
}
}
/**
* Parse input that might be a JSON string or already an object.
*/
function parseIfString(data: unknown): unknown {
if (typeof data === "string") {
try {
return JSON.parse(data);
} catch {
return data; // Return original if not valid JSON
}
}
return data;
}
/**
* Extract tool definitions and arguments from observation input/output.
* Extracts directly from raw input/output formats.
*
* @param input - Raw observation input (object or JSON string)
* @param output - Raw observation output (object or JSON string)
* @returns Object with toolDefinitions and toolArguments arrays
*/
export function extractToolsFromObservation(
input: unknown,
output: unknown,
): {
toolDefinitions: ClickhouseToolDefinition[];
toolArguments: ClickhouseToolArgument[];
} {
try {
const toolDefinitions: ClickhouseToolDefinition[] = [];
const toolArguments: ClickhouseToolArgument[] = [];
const parsedInput = parseIfString(input);
const parsedOutput = parseIfString(output);
extractToolsFromRawInput(parsedInput, toolDefinitions);
extractToolCallsFromRawOutput(parsedOutput, toolArguments);
// Deduplicate tool arguments by id
const seenIds = new Set<string>();
const uniqueArgs = toolArguments.filter((arg) => {
const key = arg.id || `${arg.name}-${arg.arguments}`;
if (seenIds.has(key)) return false;
seenIds.add(key);
return true;
});
return { toolDefinitions, toolArguments: uniqueArgs };
} catch (error) {
console.error("Tool extraction error:", error);
return { toolDefinitions: [], toolArguments: [] };
}
}
/**
* Convert array of tool definitions to Map format for ClickHouse.
* Key: tool name, Value: JSON string of {description, parameters}
*/
export function convertDefinitionsToMap(
definitions: ClickhouseToolDefinition[],
): Record<string, string> {
const map: Record<string, string> = {};
for (const def of definitions) {
// Last definition wins if duplicate names (shouldn't happen after dedup)
map[def.name] = JSON.stringify({
description: def.description ?? "",
parameters: def.parameters ?? "",
});
}
return map;
}
/**
* Convert array of tool calls to parallel arrays for ClickHouse.
*
* Returns:
* - tool_calls: Array of JSON strings containing {id, arguments, type, index} (NO name)
* - tool_call_names: Array of names in the same order as tool_calls
*
* This structure enables efficient filtering by name using has(tool_call_names, 'name')
* without needing to parse JSON.
*/
export function convertCallsToArrays(args: ClickhouseToolArgument[]): {
tool_calls: string[];
tool_call_names: string[];
} {
const tool_calls: string[] = [];
const tool_call_names: string[] = [];
for (const arg of args) {
tool_call_names.push(arg.name);
tool_calls.push(
JSON.stringify({
id: arg.id,
arguments: arg.arguments ?? "{}",
type: arg.type ?? "",
index: arg.index ?? 0,
}),
);
}
return { tool_calls, tool_call_names };
}
@@ -32,7 +32,9 @@ export async function findModel(p: ModelMatchProps): Promise<ModelWithPrices> {
traceScope: "model-match",
},
async (span) => {
logger.debug(`Finding model for ${JSON.stringify(p)}`);
if (logger.isLevelEnabled("debug")) {
logger.debug(`Finding model for ${JSON.stringify(p)}`);
}
const cachedResult = await getModelWithPricesFromRedis(p);
if (cachedResult) {
span.setAttribute("model_match_source", "redis");
+14 -2
View File
@@ -1,4 +1,4 @@
import lodash from "lodash";
import isEmpty from "lodash/isEmpty";
import { z } from "zod/v4";
import { NonEmptyString, jsonSchema } from "../../utils/zod";
@@ -58,7 +58,7 @@ export const usage = MixedUsage.nullish()
}
// if the object is empty, we return undefined
if (lodash.isEmpty(v)) {
if (isEmpty(v)) {
return undefined;
}
@@ -93,6 +93,11 @@ const RawUsageDetails = z.record(z.string(), z.unknown()).transform((val) => {
for (const [key, value] of Object.entries(val)) {
if (typeof value === "number" && Number.isInteger(value) && value >= 0) {
result[key] = value;
} else if (typeof value === "string") {
const parsed = parseInt(value, 10);
if (!isNaN(parsed) && parsed >= 0) {
result[key] = parsed;
}
}
}
@@ -534,6 +539,13 @@ const createAllIngestionSchemas = ({
configId: z.string().nullish(),
}),
),
BaseScoreBody.merge(
z.object({
value: z.string(),
dataType: z.literal("CORRECTION"),
configId: z.undefined().nullish(), // Cannot have config
}),
),
BaseScoreBody.merge(
z.object({
value: z.union([z.string(), z.number()]),
@@ -1,10 +1,13 @@
import {
ScoreBodyWithoutConfig,
ScoreConfigDomain,
ScoreDomain,
type ScoreDataTypeType,
type ScoreDomain,
ScorePropsAgainstConfig,
ScoreDataTypeEnum,
CORRECTION_NAME,
} from "../../../src";
import { prisma, ScoreDataType } from "../../db";
import { prisma } from "../../db";
import { InvalidRequestError, LangfuseNotFoundError } from "../../errors";
import { validateDbScoreConfigSafe } from "../../features/scoreConfigs/validation";
import { ScoreEventType } from "./types";
@@ -68,10 +71,10 @@ export async function validateAndInflateScore(
return inflateScoreBody(params);
}
function inferDataType(value: string | number): ScoreDataType {
function inferDataType(value: string | number): ScoreDataTypeType {
return typeof value === "number"
? ScoreDataType.NUMERIC
: ScoreDataType.CATEGORICAL;
? ScoreDataTypeEnum.NUMERIC
: ScoreDataTypeEnum.CATEGORICAL;
}
function mapStringValueToNumericValue(
@@ -91,18 +94,19 @@ function inflateScoreBody(
const relevantDataType = config?.dataType ?? body.dataType;
const scoreProps = {
...body,
longStringValue: "",
source: body.source ?? "API",
id: scoreId,
projectId,
};
if (typeof body.value === "number") {
if (relevantDataType && relevantDataType === ScoreDataType.BOOLEAN) {
if (relevantDataType && relevantDataType === ScoreDataTypeEnum.BOOLEAN) {
return {
...scoreProps,
value: body.value,
stringValue: body.value === 1 ? "True" : "False",
dataType: ScoreDataType.BOOLEAN,
dataType: ScoreDataTypeEnum.BOOLEAN,
};
}
@@ -110,7 +114,30 @@ function inflateScoreBody(
...scoreProps,
value: body.value,
stringValue: null,
dataType: ScoreDataType.NUMERIC,
dataType: ScoreDataTypeEnum.NUMERIC,
};
}
if (relevantDataType && relevantDataType === ScoreDataTypeEnum.CORRECTION) {
// CORRECTION scores can only be associated with traces or observations
if (body.sessionId) {
throw new InvalidRequestError(
"CORRECTION scores cannot be associated with sessions. Please associate with a trace or observation instead.",
);
}
if (body.datasetRunId) {
throw new InvalidRequestError(
"CORRECTION scores cannot be associated with dataset runs. Please associate with a trace or observation instead.",
);
}
return {
...scoreProps,
value: 0,
name: CORRECTION_NAME,
longStringValue: body.value,
stringValue: null,
dataType: ScoreDataTypeEnum.CORRECTION,
};
}
@@ -118,7 +145,7 @@ function inflateScoreBody(
...scoreProps,
value: config ? mapStringValueToNumericValue(config, body.value) : 0,
stringValue: body.value,
dataType: ScoreDataType.CATEGORICAL,
dataType: ScoreDataTypeEnum.CATEGORICAL,
};
}
@@ -142,11 +169,13 @@ function resolveScoreValueAnnotation(
body: ScoreDomain,
): string | number | null {
switch (body.dataType) {
case ScoreDataType.NUMERIC:
case ScoreDataType.BOOLEAN:
case ScoreDataTypeEnum.NUMERIC:
case ScoreDataTypeEnum.BOOLEAN:
return body.value;
case ScoreDataType.CATEGORICAL:
case ScoreDataTypeEnum.CATEGORICAL:
return body.stringValue;
case ScoreDataTypeEnum.CORRECTION:
throw new Error("CORRECTION type not supported in annotation drawer");
}
}
@@ -23,7 +23,7 @@ export type SpanCtx = {
startNewTrace?: boolean; // Start a new trace, severing any parent trace relationships
};
type AsyncCallbackFn<T> = (span: opentelemetry.Span) => Promise<T>; // eslint-disable-line no-unused-vars
type AsyncCallbackFn<T> = (span: opentelemetry.Span) => Promise<T>;
export async function instrumentAsync<T>(
ctx: SpanCtx,
@@ -67,7 +67,7 @@ export async function instrumentAsync<T>(
);
}
type SyncCallbackFn<T> = (span: opentelemetry.Span) => T; // eslint-disable-line no-unused-vars
type SyncCallbackFn<T> = (span: opentelemetry.Span) => T;
export function instrumentSync<T>(
ctx: SpanCtx,
@@ -92,21 +92,18 @@ type FetchLLMCompletionParams = LLMCompletionParams & {
};
export async function fetchLLMCompletion(
// eslint-disable-next-line no-unused-vars
params: LLMCompletionParams & {
streaming: true;
},
): Promise<IterableReadableStream<Uint8Array>>;
export async function fetchLLMCompletion(
// eslint-disable-next-line no-unused-vars
params: LLMCompletionParams & {
streaming: false;
},
): Promise<string>;
export async function fetchLLMCompletion(
// eslint-disable-next-line no-unused-vars
params: LLMCompletionParams & {
streaming: false;
structuredOutputSchema: ZodSchema;
@@ -114,7 +111,6 @@ export async function fetchLLMCompletion(
): Promise<Record<string, unknown>>;
export async function fetchLLMCompletion(
// eslint-disable-next-line no-unused-vars
params: LLMCompletionParams & {
streaming: false;
tools: LLMToolDefinition[];
@@ -385,6 +381,9 @@ export async function fetchLLMCompletion(
maxRetries,
location,
authOptions,
...(modelParams.maxReasoningTokens !== undefined && {
maxReasoningTokens: modelParams.maxReasoningTokens,
}),
...(modelParams.providerOptions && {
additionalModelRequestFields: modelParams.providerOptions,
}),
@@ -404,7 +403,6 @@ export async function fetchLLMCompletion(
}),
});
} else {
// eslint-disable-next-line no-unused-vars
const _exhaustiveCheck: never = modelParams.adapter;
throw new Error(
`This model provider is not supported: ${_exhaustiveCheck}`,
@@ -457,7 +455,8 @@ export async function fetchLLMCompletion(
} catch (e) {
const responseStatusCode =
(e as any)?.response?.status ?? (e as any)?.status ?? 500;
const message = e instanceof Error ? e.message : String(e);
const rawMessage = e instanceof Error ? e.message : String(e);
const message = extractCleanErrorMessage(rawMessage);
// Check for non-retryable error patterns in message
const nonRetryablePatterns = [
@@ -525,3 +524,34 @@ function processOpenAIBaseURL(params: {
return url.replace("{model}", modelName);
}
function extractCleanErrorMessage(rawMessage: string): string {
// Try to parse JSON error format (common in Google/Vertex AI errors)
// Example: '[{"error":{"code":404,"message":"Model not found..."}}]'
try {
// Check if the message starts with [ or { indicating JSON
const trimmed = rawMessage.trim();
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
const parsed = JSON.parse(trimmed);
// Handle array format: [{"error": {"message": "..."}}]
if (Array.isArray(parsed) && parsed[0]?.error?.message) {
return parsed[0].error.message;
}
// Handle object format: {"error": {"message": "..."}}
if (parsed?.error?.message) {
return parsed.error.message;
}
// Handle direct message format: {"message": "..."}
if (parsed?.message) {
return parsed.message;
}
}
} catch {
// Not valid JSON, return as-is
}
return rawMessage;
}
+3 -1
View File
@@ -6,7 +6,6 @@ import {
} from "../../interfaces/customLLMProviderConfigSchemas";
import { JSONObjectSchema } from "../../utils/zod";
/* eslint-disable no-unused-vars */
// disable lint as this is exported and used in web/worker
export const LLMJSONSchema = z.record(z.string(), z.any());
@@ -284,6 +283,7 @@ export const ZodModelConfig = z.object({
max_tokens: z.coerce.number().optional(),
temperature: z.coerce.number().optional(),
top_p: z.coerce.number().optional(),
maxReasoningTokens: z.coerce.number().optional(),
providerOptions: JSONObjectSchema.optional(),
});
@@ -433,6 +433,7 @@ export const vertexAIModels = [
"gemini-2.5-flash",
"gemini-2.5-pro",
"gemini-3-pro-preview",
"gemini-3-flash-preview",
"gemini-2.5-flash-preview-09-2025",
"gemini-2.5-flash-lite",
"gemini-2.5-flash-lite-preview-09-2025",
@@ -450,6 +451,7 @@ export const googleAIStudioModels = [
"gemini-2.5-flash",
"gemini-2.5-pro",
"gemini-3-pro-preview",
"gemini-3-flash-preview",
"gemini-2.5-flash-lite",
"gemini-2.5-flash-lite-preview-09-2025",
"gemini-2.0-flash",
@@ -1,4 +1,3 @@
/* eslint-disable no-unused-vars */
import { LangfuseOtelSpanAttributes } from "./attributes";
import { type ObservationType, ObservationTypeDomain } from "../../";
@@ -194,7 +194,12 @@ export class OtelIngestionProcessor {
const scopeAttributes = this.extractScopeAttributes(scopeSpan);
for (const span of scopeSpan?.spans ?? []) {
const spanAttributes = this.extractSpanAttributes(span);
const traceId = this.parseId(span.traceId);
// For LiteLLM spans, use langfuse.trace.id from attributes if provided
const isLiteLLMSpan = scopeSpan?.scope?.name === "litellm";
const traceId =
isLiteLLMSpan && spanAttributes["langfuse.trace.id"]
? (spanAttributes["langfuse.trace.id"] as string)
: this.parseId(span.traceId);
const spanId = this.parseId(span.spanId);
const parentSpanId = span?.parentSpanId
? this.parseId(span.parentSpanId)
@@ -355,6 +360,9 @@ export class OtelIngestionProcessor {
// Properties
tags: this.extractTags(spanAttributes),
public: this.extractPublic(spanAttributes),
traceName:
spanAttributes?.[LangfuseOtelSpanAttributes.TRACE_NAME] ??
null,
userId: this.extractUserId(spanAttributes),
sessionId: this.extractSessionId(spanAttributes),
@@ -629,7 +637,12 @@ export class OtelIngestionProcessor {
const events: IngestionEventType[] = [];
const attributes = this.extractSpanAttributes(span);
const traceId = this.parseId(span.traceId?.data ?? span.traceId);
// For LiteLLM spans, use langfuse.trace.id from attributes if provided
const isLiteLLMSpan = scopeSpan?.scope?.name === "litellm";
const traceId =
isLiteLLMSpan && attributes["langfuse.trace.id"]
? (attributes["langfuse.trace.id"] as string)
: this.parseId(span.traceId?.data ?? span.traceId);
const parentObservationId = span?.parentSpanId
? this.parseId(span.parentSpanId?.data ?? span.parentSpanId)
: null;
@@ -1187,6 +1200,10 @@ export class OtelIngestionProcessor {
]),
);
// TODO: Map gen_ai.tool.definitions to input.tools for backend extraction
// const toolDefs = attributes["gen_ai.tool.definitions"] || attributes["model_request_parameters"]?.function_tools;
// if (toolDefs && input && typeof input === "object") { input = { ...input, tools: toolDefs }; }
// Langfuse
input =
domain === "trace" && attributes[LangfuseOtelSpanAttributes.TRACE_INPUT]
@@ -1378,7 +1395,7 @@ export class OtelIngestionProcessor {
if (typeof eventsArray === "string") {
try {
events = JSON.parse(eventsArray);
} catch (e) {
} catch {
events = [];
}
}
@@ -1589,7 +1606,7 @@ export class OtelIngestionProcessor {
unknown
>;
}
} catch (e) {
} catch {
// Continue with nested metadata extraction
}
}
@@ -1740,7 +1757,7 @@ export class OtelIngestionProcessor {
return this.sanitizeModelParams(
JSON.parse(attributes["llm.invocation_parameters"] as string),
);
} catch (e) {
} catch {
// fallthrough
}
}
@@ -1750,7 +1767,7 @@ export class OtelIngestionProcessor {
return this.sanitizeModelParams(
JSON.parse(attributes["model_config"] as string),
);
} catch (e) {
} catch {
// fallthrough
}
}
@@ -1875,34 +1892,38 @@ export class OtelIngestionProcessor {
usageDetails["output_reasoning_tokens"] =
typeof parsed === "number" ? parsed : JSON.parse(value).intValue;
}
} else if (providerMetadata) {
// Fall back to providerMetadata
}
// Add additional usage details from provider metadata
if (providerMetadata) {
const parsed = JSON.parse(providerMetadata as string);
if ("openai" in parsed) {
const openaiMetadata = parsed["openai"] as Record<string, number>;
usageDetails["input_cached_tokens"] =
usageDetails["input_cached_tokens"] ??=
openaiMetadata["cachedPromptTokens"];
usageDetails["accepted_prediction_tokens"] =
usageDetails["accepted_prediction_tokens"] ??=
openaiMetadata["acceptedPredictionTokens"];
usageDetails["rejected_prediction_tokens"] =
usageDetails["rejected_prediction_tokens"] ??=
openaiMetadata["rejectedPredictionTokens"];
usageDetails["output_reasoning_tokens"] =
usageDetails["output_reasoning_tokens"] ??=
openaiMetadata["reasoningTokens"];
}
// "ai.response.providerMetadata": "{\"anthropic\":{\"cacheCreationInputTokens\":0,\"cacheReadInputTokens\":0}}"
if ("anthropic" in parsed) {
const openaiMetadata = parsed["anthropic"] as Record<
// "ai.response.providerMetadata": {"anthropic":{"usage":{"input_tokens":7,"cache_creation_input_tokens":2089,"cache_read_input_tokens":16399,"cache_creation":{"ephemeral_5m_input_tokens":2089,"ephemeral_1h_input_tokens":0},"output_tokens":445,"service_tier":"standard"},"cacheCreationInputTokens":2089,"stopSequence":null,"container":null,"contextManagement":null}}
if ("anthropic" in parsed && "usage" in parsed["anthropic"]) {
const anthropicMetadata = parsed["anthropic"]["usage"] as Record<
string,
number
>;
usageDetails["input_cache_creation"] =
openaiMetadata["cacheCreationInputTokens"];
usageDetails["input_cache_read"] =
openaiMetadata["cacheReadInputTokens"];
usageDetails["input_cache_creation"] ??=
anthropicMetadata["cache_creation_input_tokens"];
usageDetails["input_cached_tokens"] ??=
anthropicMetadata["cache_read_input_tokens"];
}
// Bedrock provider metadata extraction
// "ai.response.providerMetadata": "{\"bedrock\":{\"usage\":{\"cacheReadInputTokens\":4482,\"cacheWriteInputTokens\":0,\"cacheCreationInputTokens\":0}}}"
if ("bedrock" in parsed) {
@@ -1912,22 +1933,22 @@ export class OtelIngestionProcessor {
const usage = bedrockMetadata["usage"] as Record<string, number>;
if (usage["cacheReadInputTokens"] !== undefined) {
usageDetails["input_cache_read"] =
usageDetails["input_cache_read"] ??=
usage["cacheReadInputTokens"];
}
if (usage["cacheWriteInputTokens"] !== undefined) {
usageDetails["input_cache_write"] =
usageDetails["input_cache_write"] ??=
usage["cacheWriteInputTokens"];
}
if (usage["cacheCreationInputTokens"] !== undefined) {
usageDetails["input_cache_creation"] =
usageDetails["input_cache_creation"] ??=
usage["cacheCreationInputTokens"];
}
}
}
}
// Subtract cached token count from total input
// Subtract cached token count from total input and output
usageDetails["input"] = Math.max(
(usageDetails["input"] ?? 0) -
(usageDetails["input_cached_tokens"] ?? 0) -
@@ -1936,6 +1957,12 @@ export class OtelIngestionProcessor {
0,
);
usageDetails["output"] = Math.max(
(usageDetails["output"] ?? 0) -
(usageDetails["output_reasoning_tokens"] ?? 0),
0,
);
return usageDetails;
} catch {
// Fallthrough
@@ -2076,7 +2103,7 @@ export class OtelIngestionProcessor {
if (Array.isArray(parsedTags)) {
return parsedTags.map((tag) => String(tag));
}
} catch (e) {
} catch {
// Continue with other methods
}
}
@@ -2135,7 +2162,7 @@ export class OtelIngestionProcessor {
if (experimentMetadataStr && typeof experimentMetadataStr === "string") {
try {
experimentMetadata = JSON.parse(experimentMetadataStr);
} catch (e) {
} catch {
// If parsing fails, treat as empty
}
}
@@ -2152,7 +2179,7 @@ export class OtelIngestionProcessor {
) {
try {
experimentItemMetadata = JSON.parse(experimentItemMetadataStr);
} catch (e) {
} catch {
// If parsing fails, treat as empty
}
}
@@ -1,4 +1,3 @@
/* eslint-disable no-unused-vars */
export enum LangfuseOtelSpanAttributes {
// Langfuse-Trace attributes
TRACE_NAME = "langfuse.trace.name",
@@ -47,7 +47,7 @@ function evaluateCondition(
default:
return false;
}
} catch (error) {
} catch {
return false; // Fail-safe: condition fails on error
}
}
@@ -468,27 +468,22 @@ export class FilterList {
this.filters.push(...filter);
}
// eslint-disable-next-line no-unused-vars
find(predicate: (filter: Filter) => boolean) {
return this.filters.find(predicate);
}
// eslint-disable-next-line no-unused-vars
filter(predicate: (filter: Filter) => boolean) {
return new FilterList(this.filters.filter(predicate));
}
// eslint-disable-next-line no-unused-vars
map(predicate: (filter: Filter) => Filter) {
return new FilterList(this.filters.map(predicate));
}
// eslint-disable-next-line no-unused-vars
some(predicate: (filter: Filter) => boolean) {
return this.filters.some(predicate);
}
// eslint-disable-next-line no-unused-vars
forEach(callback: (filter: Filter) => void) {
this.filters.forEach(callback);
}
@@ -49,6 +49,11 @@ const EVENTS_FIELDS = {
promptName: 'e.prompt_name as "prompt_name"',
promptVersion: 'e.prompt_version as "prompt_version"',
// Tool fields
toolDefinitions: 'e.tool_definitions as "tool_definitions"',
toolCalls: 'e.tool_calls as "tool_calls"',
toolCallNames: 'e.tool_call_names as "tool_call_names"',
// I/O & metadata fields
input: "e.input",
output: "e.output",
@@ -99,10 +104,14 @@ const FIELD_SETS = {
"internalModelId",
"userId",
"sessionId",
"toolDefinitions",
"toolCalls",
"toolCallNames",
],
calculated: ["latency", "timeToFirstToken"],
io: ["input", "output"],
metadata: ["metadata"],
tools: ["toolDefinitions", "toolCalls", "toolCallNames"],
eventTs: ["eventTs"],
// getById field sets (reuse the same fields - all queries use `FROM events e`)
@@ -120,6 +129,9 @@ const FIELD_SETS = {
"level",
"statusMessage",
"version",
"toolDefinitions",
"toolCalls",
"toolCallNames",
],
byIdModel: [
"providedModelName",
@@ -307,7 +319,7 @@ abstract class AbstractQueryBuilder {
when<T extends AbstractQueryBuilder>(
this: T,
condition: boolean,
// eslint-disable-next-line no-unused-vars
fn: (builder: T) => T,
): T {
return condition ? fn(this) : this;
@@ -397,7 +409,7 @@ abstract class BaseEventsQueryBuilder<
protected projectId: string | NoProjectIdType;
constructor(
protected fields: TFields, // eslint-disable-line no-unused-vars
protected fields: TFields,
options: { projectId: string | NoProjectIdType },
) {
super();
@@ -492,6 +504,8 @@ export class EventsQueryBuilder extends BaseEventsQueryBuilder<
typeof EVENTS_FIELDS
> {
private ioFields: { truncated: boolean; charLimit?: number } | null = null;
// Metadata expansion config: null = use truncated (default), string[] = expand specific keys, empty array = expand all
private metadataExpansionKeys: string[] | null = null;
/**
* Constructor
@@ -508,6 +522,23 @@ export class EventsQueryBuilder extends BaseEventsQueryBuilder<
super(EVENTS_FIELDS, options);
}
/**
* Select metadata with expanded values for specified keys.
* Expands truncated metadata values by coalescing with metadata_long_values.
*
* @param keys - Keys to expand. Only the specified keys will have their full values returned.
*
* @example
* // Expand specific keys only
* builder.selectMetadataExpanded(['transcript', 'transitions'])
*/
selectMetadataExpanded(keys: string[]): this {
this.metadataExpansionKeys = keys;
// Also ensure metadata is in the select fields (will be replaced in buildSelectClause)
this.selectFields.add("metadata");
return this;
}
/**
* Add SELECT fields from predefined field sets
*/
@@ -535,10 +566,22 @@ export class EventsQueryBuilder extends BaseEventsQueryBuilder<
*/
protected buildSelectClause(): string {
// Build field expressions from field keys
// If ioFields are configured, exclude regular input/output from selectFields
const fieldsToProcess = this.ioFields
? [...this.selectFields].filter((f) => f !== "input" && f !== "output")
: [...this.selectFields];
// Exclude fields that have custom handling (IO, metadata)
let fieldsToExclude: string[] = [];
if (this.ioFields) {
fieldsToExclude.push("input", "output");
}
// Only exclude metadata if we have actual keys to expand
if (
this.metadataExpansionKeys !== null &&
this.metadataExpansionKeys.length > 0
) {
fieldsToExclude.push("metadata");
}
const fieldsToProcess = [...this.selectFields].filter(
(f) => !fieldsToExclude.includes(f),
);
const fieldExpressions: string[] = fieldsToProcess.flatMap((fieldKey) => {
const fieldExpr = EVENTS_FIELDS[fieldKey as keyof typeof EVENTS_FIELDS];
@@ -556,6 +599,30 @@ export class EventsQueryBuilder extends BaseEventsQueryBuilder<
}
}
// Add metadata field with expansion if configured
if (
this.metadataExpansionKeys !== null &&
this.metadataExpansionKeys.length > 0 &&
this.selectFields.has("metadata")
) {
// Add keys to params for safe parameterized query
this.params.expandMetadataKeys = this.metadataExpansionKeys;
fieldExpressions.push(`mapFromArrays(
e.metadata_names,
arrayMap(
(name, prefix, hash) -> if(
has({expandMetadataKeys: Array(String)}, name) AND isNotNull(hash) AND mapContains(e.metadata_long_values, hash),
e.metadata_long_values[hash],
prefix
),
e.metadata_names,
e.metadata_prefixes,
e.metadata_hashes
)
) as metadata`);
}
return `SELECT\n ${fieldExpressions.join(",\n ")}`;
}
@@ -134,16 +134,21 @@ const matchAndVerifyTracesUiColumn = (
uiTableDefinitions: UiColumnMappings,
) => {
// tries to match the column name to the clickhouse table name
logger.debug(`Filter to match: ${JSON.stringify(filter)}`);
const uiTable = uiTableDefinitions.find(
(col) =>
col.uiTableName === filter.column || col.uiTableId === filter.column, // matches on the NAME of the column in the UI.
);
if (!uiTable) {
throw new QueryBuilderError(
`Column ${filter.column} does not match a UI / CH table mapping.`,
);
const errorMessage = `Column ${filter.column} does not match a UI / CH table mapping.`;
logger.error(errorMessage, {
filterColumn: filter.column,
filterType: filter.type,
availableColumns: uiTableDefinitions.map(
(col) => col.uiTableId ?? col.uiTableName,
),
});
throw new QueryBuilderError(errorMessage);
}
if (!isValidTableName(uiTable.clickhouseTableName)) {

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