Compare commits

...
146 Commits
Author SHA1 Message Date
Hassieb Pakzad 6cd98d2cd8 chore: release v3.114.0
Codespell / Check for spelling errors (push) Waiting to run
CI/CD / pre-job (push) Waiting to run
CI/CD / lint (push) Blocked by required conditions
CI/CD / prettier-check (push) Blocked by required conditions
CI/CD / test-docker-build (push) Blocked by required conditions
CI/CD / tests-web-sync (node24, pg12) (push) Blocked by required conditions
CI/CD / tests-web-sync (node24, pg15) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg12, mode) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg15, mode) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg12, mode-azure) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg15, mode-azure) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg12, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg15, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg12, mode) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg15, mode) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg12, mode-azure) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg15, mode-azure) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg12, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg15, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / e2e-tests (push) Blocked by required conditions
CI/CD / e2e-server-tests (push) Blocked by required conditions
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2025-10-01 11:24:25 +02:00
Hassieb PakzadandGitHub 23300f9db8 fix(otel): completion start time parsing to handle JSON stringified values (#9455) 2025-10-01 09:15:08 +00:00
NimarandGitHub 5427d3f74f fix(trace-ui): make row label sticky for long cells (#9454)
* feat(trace-ui): make row label sticky for long cells

* simplify
2025-10-01 08:19:13 +00:00
Max DeichmannandGitHub a43fbc8a74 chore: upgrade golang migrate 4.19.0 (#9444)
* chore: do not upgrade to postgrs 18

* chore: do not upgrade to postgrs 18

* chore: do not upgrade to postgrs 18

* chore: upgrade golang-migrate
2025-10-01 07:22:25 +00:00
8d9c059572 fix: add current datetime to ai filter prompt (#9445)
* Add current datetime to AI prompt context

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

* Refactor datetime formatting for AI prompt

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-09-30 21:01:21 +00:00
Hassieb PakzadandGitHub 37002f39bd test(llm-connections): add tests for all adapters (#9423)
* test(llm-connections): add tests for all adapters

* test(llm-connections): add tests for all adapters

* fix

* fix

* push

* push
2025-09-30 17:13:39 +00:00
c7de984fb1 fix: Accept any 2xx status for webhooks (#9439)
feat: Accept 2xx status codes for webhook success

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-09-30 17:02:49 +00:00
NimarandGitHub 1d3ad16710 chore: upgrade turborepo to 2.5.8 (#9337)
* chore: upgrade turborepo to 2.5.8

* add comments

* comments

* fix for turbo plugin default exports
2025-09-30 16:58:15 +00:00
17a358a1c2 feat(trace-ui): natural language filters (#9347)
* add fetchCompletion route

* use fetchLLMCompletion function

* chore(llm): add execution context to fetchLLMCompletion function

* add router

* chore: refactor to add resolveBedrockCredentials

* feat(env): add optional AWS Bedrock configuration

* chore: revert langfuse execution context for fetch llm completion

* frontend poc

* use bedrock keys

* chore(organization): add aiFeaturesEnabled column

* feat(organization): update organization update procedure to include optional aiFeaturesEnabled

* add route integrated

* add env vars

* update

* add proejct id

* fixup: ai feature toggle

* fully integrated

* fix: organization ai feature settings

* update

* fix ts

* chore: remove env variables from shared

* chore: rename environment variables

* chore: enhance LLM completion context

* chore: fix typing

* typing

* no more token delegate

* add project id

* fix: filter completion should never throw an error, fallback to empty json

* chore: set up bedrock credentials for development

* chore: remove TokenCountDelegate from types

* fix: integrate organization AI feature checks in filter builder

* fix: use project hook over org hook

* send traces to langfuse cloud

* fix build

* fix buuild

* validate filters

* chore: eslint

* add debug

* don't greedy match

* add tooltip

* fix parsing

* update AI feature text

* fix lockfile

* chore(router): gate setting/using ai features on self hosted deployments

* chore: replace env variable usage with useLangfuseCloudRegion hook across components

* chore: integrate useLangfuseCloudRegion hook to conditionally render AI features in filter builder and AI feature switch components

* docs: update REVIEW.md with Langfuse Cloud environment usage guidelines

* link

* chore: add LANGFUSE_AWS_BEDROCK_MODEL to .env.dev.example and update its definition in env.mjs

* Update useFilterState.ts

---------

Co-authored-by: Marlies Mayerhofer <74332854+marliessophie@users.noreply.github.com>
Co-authored-by: Leo <5489276+leoweigand@users.noreply.github.com>
2025-09-30 15:19:04 +00:00
Fabian ReinoldandGitHub 7e76fbfcb4 feat(auth): add support for Keycloak scope and ID token configuration (#9359)
* feat(auth): add support for Keycloak scope and ID token configuration

* fix(auth): update ID token envvar handling to use default value
2025-09-30 14:16:40 +00:00
Hassieb PakzadandGitHub aa5b5fb8a9 fix(otel): preferential parsing of environment key from span attributes (#9432) 2025-09-30 13:12:27 +00:00
Steffen SchmitzandGitHub c084d19c2d fix: skip JSON parsing for completion start time strings (#9418) 2025-09-30 11:43:31 +00:00
marliessophieandGitHub 23e765fc35 style(dataset-item): detail view to show item IO and past runs in two tabs (#9419) 2025-09-30 09:03:02 +00:00
Nimar f60286f13d chore: release v3.113.0
Codespell / Check for spelling errors (push) Waiting to run
CI/CD / pre-job (push) Waiting to run
CI/CD / lint (push) Blocked by required conditions
CI/CD / prettier-check (push) Blocked by required conditions
CI/CD / test-docker-build (push) Blocked by required conditions
CI/CD / tests-web-sync (node24, pg12) (push) Blocked by required conditions
CI/CD / tests-web-sync (node24, pg15) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg12, mode) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg15, mode) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg12, mode-azure) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg15, mode-azure) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg12, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg15, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg12, mode) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg15, mode) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg12, mode-azure) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg15, mode-azure) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg12, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg15, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / e2e-tests (push) Blocked by required conditions
CI/CD / e2e-server-tests (push) Blocked by required conditions
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2025-09-30 09:02:29 +02:00
NimarandGitHub 0f43240c35 feat(playground): add support for claude sonnet 4.5 (#9410)
* feat(playground): add support for claude sonnet 4.5

* fix id

* cuid2

* upgrade langchain
2025-09-30 06:59:51 +00:00
Max DeichmannandGitHub dc4e94a68b chore: do not upgrade to postgres 18 (#9407)
* chore: do not upgrade to postgrs 18

* chore: do not upgrade to postgrs 18

* chore: do not upgrade to postgrs 18
2025-09-29 20:00:33 +00:00
NimarandGitHub fd98cd4cd7 chore: filter browser extensions from sentry errors (#9405) 2025-09-29 17:59:58 +00:00
NimarandGitHub a1f09a2e30 fix(prompts): don't throw on prompt deletion message (#9387)
* vibe

* cleanup
2025-09-29 17:23:10 +00:00
decac0cd5a fix(billing): Fix invoice table decimal display (#9399)
Fix: Ensure consistent USD formatting in billing table

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: michael <michael@langfuse.com>
2025-09-29 18:36:17 +02:00
NimarandGitHub 17e600f65e chore: enable browser logs in terminal (#8935) 2025-09-29 15:15:53 +00:00
Steffen SchmitzandGitHub 0540828e21 chore: use otel ingestion queue by default (#9397) 2025-09-29 13:46:06 +00:00
Max DeichmannandGitHub 4f9d7b0e60 chore: update clickhouse writer batch size (#9396) 2025-09-29 13:15:14 +00:00
Leo WeigandandGitHub a940fa40d9 fix: always respect to in date range filters (#9393) 2025-09-29 12:37:50 +00:00
Steffen SchmitzandGitHub ab2007a627 perf: avoid updates on session ingest (#9392) 2025-09-29 11:58:17 +00:00
marliessophieandGitHub 9b8868ac97 feat(score-configs): support mutability (#9218)
* chore(score-configs): extract score domain types and unify validation

chore: fix types

chore: add documentation

chore: types

chore: rename types

chore: rename types

chore: fix test type imports

chore: validation tweaks

chore: types

chore: types

* chore: remove dead code

* chore: handle custom errors for score categories

* chore: simplify

* feat(score-configs): support mutability (#9143)

* fix(scores): refactor validateConfigAgainstBody to support context-based validation

* fix: chore

* fix: chore

* chore(scores): display removed categorical score values as outdated and mark stale

* fix(scores): enrich categories with outdated status and streamline return logic

* fix(scores): handle empty categories in enrichCategories function

* fix(scores): handle empty categories in enrichCategories function
2025-09-29 11:11:27 +00:00
Marc KlingenandGitHub 4f6e917cd7 feat(ui): add search bar to members table (#9339)
* feat: add search bar to members table

* push
2025-09-25 14:56:24 +02:00
NimarandGitHub da51c01f35 fix(otel-mapper): null model props shouldn't cause map to generation (#9308)
* fix(otel-mapper): empty model properties shouldn't cause map to generation type

* fix test name

* fix the bug

* cleanup
2025-09-25 07:49:29 +00:00
Michael FröhlichandGitHub 4aeea98b56 fix(billing): Fix base-fee aggregation for legacy billing (#9328)
Fix base-fee aggregation for legacy billing
2025-09-24 18:18:00 +00:00
Steffen SchmitzandGitHub 917e35a1b3 perf: remove unused join for job exec status aggregation (#9230) 2025-09-23 10:29:01 +00:00
marliessophieandGitHub 2e0a548e6f feat(datasets): support filtering on per-run-basis in compare table (#9098)
* fixup(datasets-compare): support front-end column filtering

- Added column filtering capabilities to the DatasetCompareRunsTable and DatasetRunAggregateColumnHelpers.
- Introduced useColumnFilterState hook for managing filter state with URL persistence.
- Updated DatasetRunItemsByRunTable to accept multiple dataset run IDs.
- Enhanced PopoverFilterBuilder to support different button variants for filter actions.
- Refactored related components to integrate new filtering features.

* chore: remove fetching of run items data in compare view

* chore: move filter logic into useDatasetRunAggregateColumns

* chore: extend useColumnFilterState

* chore(scores): add ScoreDataType and ScoreDataTypeDomain

* chore: move state out of useDatasetRunAggregateColumns

* chore(dataset-run-items): enhance DatasetRunItem types and converters for optional IO support

* chore: refactor compare data fetching to support one API route

* feat: support filtering on compare view

* feat: add FilteredRunPills component for enhanced filtering display in compare view

* chore: eslint

* chore: simplify and refactor

* chore: push

* fix import

* chore: remove console log

* chore: drop first approach of pulling dataset item ids from postgres

* chore: rename trpc route

* tests: adjust non filter tests

* chore: rename dataset repository helper functions

* chore: add intersection query

* chore: push

* chore: debounce updateRunFilters

* chore: add filter synchronization for run IDs in DatasetCompareRunsTable
2025-09-23 08:56:47 +00:00
Hassieb PakzadandGitHub f9222c526f feat(otel): add llm.response.model parsing (#9261) 2025-09-23 06:56:07 +00:00
Hassieb PakzadandGitHub 5cb61d6a96 fix(llm-tools-and-schemas): allow periods in names (#9296) 2025-09-23 06:55:58 +00:00
Hassieb PakzadandGitHub 463ada23ca fix(models): drop preview from gemini-2.5-flash-lite (#9299) 2025-09-22 18:20:23 +00:00
b2d22a0720 fix(billing): Add delimiting line to support emails (#9292)
Fix typo in support chat auto-reply

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: michael <michael@langfuse.com>
2025-09-22 13:39:02 +00:00
Marc KlingenandGitHub fd1f1786da chore(cloud): limit org members to 2 on free plan to be in line with pricing/packaging (#9291) 2025-09-22 15:17:31 +02:00
Max DeichmannandGitHub 56598597df chore: remove hosting of ..-in-the-middle packages (#9288)
push
2025-09-22 11:04:10 +00:00
ea71c40ecb fix(billing): Hide promotion code for hobby plan users (#9286)
feat: Hide discount view for Hobby plan users

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: michael <michael@langfuse.com>
2025-09-22 12:05:27 +02:00
Max DeichmannandGitHub 20cc20f908 chore: enable turborepo (#9275)
* chore: enable turborepo

* chore: enable turborepo

* chore: enable turborepo
2025-09-22 09:08:49 +00:00
Michael FröhlichandGitHub 6326a6d788 fix(billing): Avoid 500s through uninitialized stripe client in dev environment (#9276)
make getUsage endpoint nullable to aoid 500s for developers in dev mode without stripe secret
2025-09-21 11:07:30 +00:00
Max DeichmannandGitHub 286f7ea29f chore: upgrade axios (#9265)
chore: upgrade zxios
2025-09-19 18:49:08 +00:00
Michael FröhlichandGitHub 700921f69f feat(billing): Add option to add discount code to active subscriptions from settings menu (#9235)
* Add dialog to add discounts to running subscriptions

* reset opId
2025-09-19 16:51:52 +02:00
Leo WeigandandGitHub 9e3379f05f feat: improved time range filtering (#9252)
* feat: better relative date range selects

* fix: date picker styles

fixes LFE-6702

* add TimeRangePicker

* use TimeRangePicker in traces view

* update dashboards and tables to use TimeRangePicker

* missed fixes due to updated time range utils

* simplify

* update remaining

* remove old timestamp filter

* address comment

* silence codespell false-positive

* fix badge heights
2025-09-19 14:05:55 +00:00
marliessophieandGitHub bc178010c1 fix(dataset-runs): during loading states render skeletons for charts (#9258) 2025-09-19 13:55:43 +00:00
marliessophieandGitHub 47a1cb24a3 style(experiments-compare): sort scores alphabetically in cell (#9253) 2025-09-19 12:43:38 +00:00
Valery MeleshkinandGitHub 312d97f509 fix: ensure cases when CH outputs an error as a row are handled (#9238)
ClickHouse has a quirk when it comes to handling exceptions mid response.
It will simply output a row with "exception" key inside, which is indistinguishable from
a query like `SELECT "my lovely string" AS exception;` may return.

This PR makes the best effort to convert such rows into errors and throws them.

See:
 - https://github.com/ClickHouse/clickhouse-js/issues/332
 - https://github.com/ClickHouse/ClickHouse/issues/75175

Ideally this should get fixed in the future versions of ClickHouse.
2025-09-19 12:12:04 +00:00
A. WilcoxandGitHub 7776462c9b style: Fix grammar of org invite email subject (#9239) 2025-09-19 11:34:34 +00:00
NimarandGitHub aa8eabbc19 fix(otel-ingest): don't overwrite trace metadata->attributes from new observation (#9247)
* fix(otel-ingest): don't overwrite trace metadata from new observation

* update test

* skip test, not good

* skip test because it's not getting the entire trace
2025-09-19 11:29:12 +00:00
Valery MeleshkinandGitHub f4caacabdb fix: improve error message for CH memory issues on public API (#9191)
* fix: improve error message for CH memory issues on public API

* fix(ui): surface ClickHouse error message providing a bit of advice to the user.
2025-09-18 22:49:39 +00:00
NimarandGitHub 7892ff521d fix(trpc): show toast on trpc error to user (#9231) 2025-09-18 17:14:44 +00:00
Michael FröhlichandGitHub 45215cb4ff feat(billing): Allow past subscribers see their invoices (#9232)
* Allow users on hobby plan to see past invoices

* Fix linter errors
2025-09-18 14:42:01 +00:00
Michael FröhlichandGitHub 5404119473 fix(billing): automatically cancel and invoice stripe subscriptions when an org is deleted (#9228)
* cancel stripe subscription on invoice delete

* Update organizationRouter.ts
2025-09-18 16:15:58 +02:00
Hassieb PakzadandGitHub 7769cb9d2a chore: run sdk-api-spec-bot on changes in fern dir only (#9229) 2025-09-18 15:59:34 +02:00
Hassieb PakzadandGitHub 0b68c410cf chore(sdk-api-spec-bot): auto add reviewer (#9227) 2025-09-18 15:46:55 +02:00
Hassieb PakzadandGitHub efa6fe7e6a chore: update sdk-api-spec bot for js sdk (#9226) 2025-09-18 15:37:11 +02:00
Michael FröhlichandGitHub 16f41f809a feat(billing): show active promotion codes in billing settings #9210 (#9221)
* add label for discounts

* remove logging statement
2025-09-18 13:16:57 +00:00
Hassieb PakzadandGitHub a7d59d7bb2 chore: update sdk-api-spec bot (#9222) 2025-09-18 14:57:59 +02:00
Michael FröhlichandGitHub 291abf3205 chore(billing): Update price label for core plan (#9216)
update core price to 29 USD
2025-09-18 13:22:34 +02:00
Leo WeigandandGitHub 5df5a7cd48 chore(single-trace): make tree a little more dense and allow click everywhere (#9183)
* fix(single-trace): tree slightly more compact, allow click anywhere

* simplify tree node layout and make more robust

* make lint pass
2025-09-18 10:14:55 +00:00
marliessophieandGitHub ac2936d316 fix(experiment-form-ui): prevent event bubbling from embedded form submission handlers (#9215) 2025-09-18 10:12:22 +00:00
Steffen SchmitzandGitHub 0335dcd18d fix: include session scores in posthog export (#9214) 2025-09-18 10:06:20 +00:00
27cfbb14a1 feat: show last authentication method on login (#9194)
* Refactor: Add last used auth method persistence

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

* Refactor: Use useLocalStorage hook for auth method persistence

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

* Refactor SSOButtons to use parent-managed last used method

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

* Refactor: Conditionally show "Last used" badge on sign-in

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

* refactor styles

* pass lint

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-09-18 10:02:03 +00:00
marliessophieandGitHub 4c5828dade fix(experiments-ui): resolve composed prompts for prompt-experiment setup (#9211)
* fix(experiments): resolve composed prompts for prompt-experiment setup

* chore: guard
2025-09-18 09:49:10 +00:00
2c9afcc58b feat(billing): new billing setup (#9053)
* Michael/lfe 6645 (#9046)

* refactor billing settings and cancel button

* refactor billing components

* chore(stripe): bump stripe from 17.4.0 to 18.5.0.basil (#9050)

* bump stripe api version to 18.5.0.basil

* update stripeAPIHandler to 18.5.0

* update cloud billing router and add log statements

* Show stripe buttons only with active subscription

* remove logging statements

* feat(billing): show plan end in billing settings (#9052)

* update cloudConfigSchema with metadata field for scheduled cancellations

* add function to unpack cancellation metadata in subscription.update webhook

* store cancellation metadata in db

* add  and  aggegregation option to LocalIsoDate component

* add BillingCurrentPlanLabel component to indicate cancelled plans

* add title prop to StripeCustomerPortalButton for better accessibility

* remove text from label

* fix(billing): cancel pending cancellations on plan switches (#9076)

cancel pending cancellations on plan switches

* feat(billing): distinguish between new subscriptions and legacy subscriptions (#9078)

* add orderKeys to distinguish between upgrades and downgrades of subcriptions

* add optional activeUsageProductId to cloudConfig schema

* add legacy Plan flag on cloudConfig.stripe schema

* set usageProductId in webhook callback

* feat(billing): Add new billing logic and update stripe integration (#9096)

* update stripe usage product id to match sandbox

* update to cloudBillinRouter.createCheckoutSession to deal with legacy and new prices

* add orderKeys and replace them with monthly price to distinguish upgrade and downgrade paths

* update cloudBillingRouter.changePlan mutation to deal with different upgrade paths

* update stripeWebHookApiHandler to listen for subscriptionSchedule events

* update cloudConfig schema to store subscription schedule metadata

* extract formatLocalIsoDate into own function

* add useBillingInformation hook for common ui-billing operations

* Add BillingScheduleNotification component to indicate to user when their subscription is cancelled or scheduled to change

* refactor billing component to use new hook

* add managed cancellation button

* fix planSwitch info overrides on schedule.update callbacks

* remove logging statements

* add buttons to isolate strip cancelation, plan continuation, and switching

* refacor billingSwitchPlanDialog

* fix linter errors

* fix bug where released schedules where released

* fix Billing Portal ui issue

* add support for legacy plans to enable testing on staging

* clear cancellationInfo and planSwitchScheduleInfo on customer.subscription.deleted webhook

* extract condition into variable

* fix typo

* Add invoice table

* fix unstable ref causing rerenders

* fix uneccessary cast

* feat(billing): invoice table in billing settings (#9134)

* Add invoice table

* fix unstable ref causing rerenders

* fix uneccessary cast

* update webhooks to allow externally triggered subscription schedules

* nit

* feat(billing): Rewrite Billing Service to Reduce Complexity and Minimize Data Drift Risks (#9169)

* refactor code for more effective refetches

* Refactor cloudBillingRouter into BillingService

* Add Idempotency keys for unique stripe ops, add logging and auditLogs, Refactor for cleaner dx

* Add env checks to stripe webhook handler

* Add env checks to stripe webhook handler

* add review comments

* standardize logger statements

* Add new readme

* update plan switch/cance/reactivate explanation messages

* Add fallbacks in org resolution to webhook, to deal with susbcriptions created from the dashboard

* fix linter errors

* remove comment

* fix build errors

* implement review comments

* add discount column to invoice table

* update orderkey of core plan to reflect new price

* fix(billing): apply existing discounts to new subscription phase when updating via subscription schedule (#9199)

* safeguard against illegal invoice.createPreview call and apply discounts on subscription schedule change

* fix typo

* add docstrings to stripeIdempotencyKey.ts

* update productId for prod

---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2025-09-18 11:11:02 +02:00
Nimar d401d42377 chore: release v3.112.0
Codespell / Check for spelling errors (push) Waiting to run
CI/CD / pre-job (push) Waiting to run
CI/CD / lint (push) Blocked by required conditions
CI/CD / prettier-check (push) Blocked by required conditions
CI/CD / test-docker-build (push) Blocked by required conditions
CI/CD / tests-web-sync (node24, pg12) (push) Blocked by required conditions
CI/CD / tests-web-sync (node24, pg15) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg12, mode) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg15, mode) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg12, mode-azure) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg15, mode-azure) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg12, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg15, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg12, mode) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg15, mode) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg12, mode-azure) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg15, mode-azure) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg12, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg15, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / e2e-tests (push) Blocked by required conditions
CI/CD / e2e-server-tests (push) Blocked by required conditions
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2025-09-18 10:51:49 +02:00
marliessophieandGitHub 322c767a33 fix(datasets): update score filters to handle empty run lists in DatasetRunsTable and DatasetCompare components (#9209) 2025-09-18 08:14:56 +00:00
Hassieb Pakzad 53726d58bf Revert "chore: update fern python generator (#9203)"
This reverts commit 63feb943a4.
2025-09-17 22:54:54 +02:00
Hassieb PakzadandGitHub 63feb943a4 chore: update fern python generator (#9203) 2025-09-17 22:18:27 +02:00
Hassieb PakzadandGitHub 6379c7c54b chore: update sdk-api-spec bot (#9197) 2025-09-17 18:39:48 +02:00
Hassieb PakzadandGitHub 69b3b81771 chore: update sdk-api-spec bot (#9195) 2025-09-17 18:30:59 +02:00
marliessophieandGitHub 668d1b6933 fix(llm): OpenAI llm completion reasoning model checks and token adjustments (#9174) 2025-09-17 15:48:42 +00:00
NimarandGitHub d7069df433 fix(trace-ui): don't render markdown if content too big (#9190)
* add key to json viewer

* don't render markdown if there are too many characters

* bump posthog

* cleanup

* restore
2025-09-17 17:55:08 +02:00
Hassieb PakzadandGitHub 83d3009331 chore: add sdk-api-spec bot (#9193) 2025-09-17 17:40:03 +02:00
Hassieb PakzadandGitHub 688659c597 chore: add sdk-api-spec bot (#9192) 2025-09-17 17:26:40 +02:00
marliessophieandGitHub 63ec00efe9 feat(api): GET /scores support for filtering by sessionId (#9176)
* feat(api): GET /scores support for filtering by sessionId

* chore: test
2025-09-17 15:14:42 +00:00
Steffen SchmitzandGitHub a11b5ffa92 chore: switch docker compose cluster to bitnami legacy (#9188) 2025-09-17 17:01:08 +02:00
Hassieb PakzadandGitHub 72bf6a75c1 chore: add sdk-api-spec bot (#9187) 2025-09-17 16:50:01 +02:00
Hassieb PakzadandGitHub 92b11374d9 chore: add sdk-api-spec bot (#9184)
* chore: add sdk-api-spec bot

* Update web/package.json
2025-09-17 16:42:34 +02:00
marliessophieandGitHub 828c3ff7be fix(llm-completion): add transformations for providers requiring a user message (#9162) 2025-09-16 16:27:52 +00:00
7b35cb0e99 docs: mention SLACK_STATE_SECRET env var as a required one (#9121)
* docs: mention SLACK_STATE_SECRET env var as a required one

Signed-off-by: ajatprabha <ajat.prabha.leo@gmail.com>

* Update README.md

---------

Signed-off-by: ajatprabha <ajat.prabha.leo@gmail.com>
Co-authored-by: Nimar <l.nimar.b@gmail.com>
2025-09-16 16:14:58 +00:00
Leo WeigandandGitHub 8d5c445dc5 fix(tables): cmd+click opens traces and observations in a new tab (#9161) 2025-09-16 16:00:26 +00:00
Marc KlingenandGitHub c752af2201 chore(cloud): remove eval count limit from hobby/free plan (#9158) 2025-09-16 16:38:05 +02:00
Steffen SchmitzandGitHub 03f6204797 chore: add debugging log for invalid start_time in batch exports (#9150) 2025-09-16 07:51:54 +00:00
Steffen SchmitzandGitHub c33708e5db fix: fetch environment options from raw data (#9141)
* fix: fetch environment options from raw data

* chore: drop stuff

* chore: patch

* chore: always add default

* chore: adjust mapping logic for correctness

* chore: drop materialized views to fill project_environments

* chore: remove migration files
2025-09-16 07:30:36 +00:00
Marc KlingenandGitHub 4c2c3d21c7 chore: move readme images to github assets (#9142) 2025-09-15 16:51:55 +02:00
Hassieb PakzadandGitHub 9a09fa2ad0 fix(ingestion-service): skip tokenization for obs level ERROR (#9133) 2025-09-15 13:11:05 +00:00
Steffen SchmitzandGitHub 43e0644c64 perf: drop final on check trace exists (#9117)
* perf: drop final on check trace exists

* chore: only apply final on eval check if non-id filter is used

* chore: skip full query if not needed

* chore: revert query changes

* chore: simplify condition

* chore: add metrics
2025-09-15 11:11:12 +00:00
Marc KlingenandGitHub c5be145a53 docs: add note re otel to ingestion endpoint api reference (#9135) 2025-09-15 10:29:09 +00:00
NimarandGitHub 3a002f03d5 chore: upgrade sentry v8 -> v10 (#8934)
* chore: upgrade sentry v8 -> v10

* fix source maps

* make pnpm-lockfile single quoted as is on main

* move sentry config to new file
2025-09-15 09:53:23 +00:00
NimarandGitHub c785156220 fix(datasets): graphs not readable in dark mode (#9132) 2025-09-15 09:05:47 +00:00
Max Deichmann 6f8627ed86 chore: release v3.111.0
Codespell / Check for spelling errors (push) Waiting to run
CI/CD / pre-job (push) Waiting to run
CI/CD / lint (push) Blocked by required conditions
CI/CD / prettier-check (push) Blocked by required conditions
CI/CD / test-docker-build (push) Blocked by required conditions
CI/CD / tests-web-sync (node24, pg12) (push) Blocked by required conditions
CI/CD / tests-web-sync (node24, pg15) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg12, mode) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg15, mode) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg12, mode-azure) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg15, mode-azure) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg12, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg15, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg12, mode) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg15, mode) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg12, mode-azure) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg15, mode-azure) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg12, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg15, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / e2e-tests (push) Blocked by required conditions
CI/CD / e2e-server-tests (push) Blocked by required conditions
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2025-09-15 08:27:37 +02:00
6a7034a5ad chore: Check tracing configuration for data retention (#9045)
* Refactor: Rename hasAnyTrace to hasTracingConfigured

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

* chore: upgrade posthog repository

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-09-14 16:16:12 +00:00
Max DeichmannandGitHub 29443bcdbd chore: upgrade posthog repository (#9126) 2025-09-14 16:11:51 +00:00
Steffen SchmitzandGitHub 9f066a285f feat: export non-trace scores via posthog integration (#9103) 2025-09-12 15:15:34 +00:00
Steffen SchmitzandGitHub 664bd3596e chore: increase default throughput on create eval queue (#9108) 2025-09-12 14:16:41 +00:00
Leo WeigandandGitHub bad27d2625 fix(table): tags wrapping and being cropped (#9107) 2025-09-12 13:52:48 +00:00
Leo WeigandandGitHub 4da02c1ebe fix(design-system): hover card layering (#9106) 2025-09-12 13:17:21 +00:00
Max DeichmannandGitHub 65bd0bf5c1 perf: move more endpoints to read only (#9105) 2025-09-12 14:38:05 +02:00
Marc KlingenandGitHub b1ee21fc34 docs: update dependency list in readme (#9102)
* docs: update readme

* push
2025-09-12 12:20:29 +02:00
Hassieb PakzadandGitHub b894e8a620 fix(quickstart): update langchain js import (#9093) 2025-09-11 16:48:32 +00:00
Max DeichmannandGitHub aa39af94fb chore: move reads to read replica (#9091)
* chore: mve reads to read replica

* chore: mve reads to read replica
2025-09-11 16:42:05 +00:00
b1880ddb82 feat(support):Include ticket id in email subject (#8991)
* feat: Add unique ID to support thread titles

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

* add nanoid package

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: froemic <m.froehlich1994@gmail.com>
Co-authored-by: Michael Fröhlich <15179255+FroeMic@users.noreply.github.com>
2025-09-11 15:12:22 +00:00
Valery MeleshkinandGitHub 19e9da214e fix(otel): Make sure we do not process empty otel events (#9082)
* fix: Make sure we do not process empty otel events

* chore: fix a typo

* chore: addressing review comments.
2025-09-11 13:36:17 +00:00
Hassieb PakzadandGitHub 2e698c8ec0 chore: bump langchain anthropic (#9087) 2025-09-11 15:14:34 +02:00
Marc KlingenandGitHub 46feb262ec chore(cloud): switch to in-app support for sso setup to replace email (#9086) 2025-09-11 12:41:17 +00:00
steffen911 23b830074a chore: release v3.110.0
Codespell / Check for spelling errors (push) Waiting to run
CI/CD / pre-job (push) Waiting to run
CI/CD / lint (push) Blocked by required conditions
CI/CD / prettier-check (push) Blocked by required conditions
CI/CD / test-docker-build (push) Blocked by required conditions
CI/CD / tests-web-sync (node24, pg12) (push) Blocked by required conditions
CI/CD / tests-web-sync (node24, pg15) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg12, mode) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg15, mode) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg12, mode-azure) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg15, mode-azure) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg12, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg15, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg12, mode) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg15, mode) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg12, mode-azure) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg15, mode-azure) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg12, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg15, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / e2e-tests (push) Blocked by required conditions
CI/CD / e2e-server-tests (push) Blocked by required conditions
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2025-09-11 14:12:21 +02:00
Steffen SchmitzandGitHub fa5908a749 feat: add api endpoint to configure blob storage integration (#9047)
* feat: add api endpoint to configure blob storage integration

* chore: add test suite and generated API docs

* chore: add implementation

* chore: simplify delete endpoint

* chore: PUT route cleanups
2025-09-11 12:05:12 +00:00
Steffen SchmitzandGitHub 26045d089c perf: add a global concurrency limit to the createEvalQueue (#9077) 2025-09-11 12:09:28 +02:00
Steffen SchmitzandGitHub edfb15c010 feat: add gcp vertex tool inputs and outputs on otel spans (#9074) 2025-09-11 08:15:28 +00:00
marliessophieandGitHub 1f76ae650f fix(peek): safe parse runs url parameter in compare data hook (#9073) 2025-09-11 07:44:16 +00:00
36dc608b58 chore(worker): Move nodemon to tsx for reloads from packages/shared (#8801)
---------

Co-authored-by: Max Deichmann <m.deichmann@tum.de>
2025-09-11 00:36:02 +02:00
Jannik MaierhöferandGitHub bc0735729f fix(ui): update JS/TS SDK v4 notification (#9064) 2025-09-10 18:58:53 +00:00
Steffen SchmitzGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
88e553734e chore: avoid otel alerts about public key mismatch on worker (#9063)
* chore: avoid otel alerts about public key mismatch on worker

* Update worker/src/queues/otelIngestionQueue.ts

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

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-09-10 18:58:10 +00:00
Jannik MaierhöferandGitHub bd5495321e fix(ui): remove slack support from pro tier (#9059) 2025-09-10 17:33:04 +00:00
e8ebfb8e3e feat(ui): add notification card for JS SDK v4 GA (#9022)
* feat(ui): add notification card for JS SDK v4 GA

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

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

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

---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2025-09-10 16:47:34 +00:00
Leo WeigandandGitHub 8c076b6b50 fix(tooltip): portal content (#9055) 2025-09-10 16:24:13 +00:00
Hassieb PakzadandGitHub 2cdc3cab7f fix(otel): add root span metadata to trace (#9054) 2025-09-10 16:04:37 +00:00
Leo WeigandandGitHub 729c2132af feat(sessions): add trace detail peek view and clean up layout (#9042)
* chore: add richer seed data for sessions

* session styling improvements

* support peek view in session detail

* fix formatting switch padding

* add usePreserveRelativeScroll hook

* button improvements

* move buttons and trace detail link

* columns adjustment

* finalise card layout

* prettify aggr metrics header

* tidy up paddings

* refactor to new peek view hook

* a bit more tidying up

* fix types
2025-09-10 12:44:23 +00:00
marliessophieandGitHub d3627bf225 feat(dataset-run-items): pin dataset item id column in ui table (#9040) 2025-09-10 12:27:28 +00:00
Max Deichmann 05b87199ec chore: release v3.109.0
Codespell / Check for spelling errors (push) Waiting to run
CI/CD / pre-job (push) Waiting to run
CI/CD / lint (push) Blocked by required conditions
CI/CD / prettier-check (push) Blocked by required conditions
CI/CD / test-docker-build (push) Blocked by required conditions
CI/CD / tests-web-sync (node24, pg12) (push) Blocked by required conditions
CI/CD / tests-web-sync (node24, pg15) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg12, mode) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg15, mode) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg12, mode-azure) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg15, mode-azure) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg12, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-web-async (node24, pg15, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg12, mode) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg15, mode) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg12, mode-azure) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg15, mode-azure) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg12, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / tests-worker (node24, pg15, mode-redis-cluster) (push) Blocked by required conditions
CI/CD / e2e-tests (push) Blocked by required conditions
CI/CD / e2e-server-tests (push) Blocked by required conditions
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2025-09-10 13:52:40 +02:00
a618d53451 fix: require admin api key for background migration retries in OSS (#9028)
* feat: Add admin API key authentication for retrying migrations

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

* fix: deny background migrations on LF cloud

* fix: deny background migrations on LF cloud

* fix: deny background migrations on LF cloud

* fix: deny background migrations on LF cloud

* fix: deny background migrations on LF cloud

* fix: deny background migrations on LF cloud

* fix: deny background migrations on LF cloud

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-09-10 09:09:55 +00:00
Matthew LapointeandGitHub 9907c39861 chore: ip allowlist for webhooks (#8977) 2025-09-10 08:49:22 +00:00
marliessophieandGitHub 4c8594e47a feat(dataset-run-items-ui): support score filters in UI table (#8993)
* feat(dataset-run-items-ui): support score filters in UI table

* chore: add dataset run item scores to seeder

* chore: make datasetid optional

* chore: drop dataset item id from scores CTE
2025-09-10 08:15:06 +00:00
marliessophieandGitHub a69609229a style: increase tooltip and sidebar toggle z-index (#9035) 2025-09-10 07:51:44 +00:00
Max DeichmannGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
217ae648d6 chore: disallow background migrations on langfuse cloud (#9027)
* security: rename protectedprocedure

* fix: deny background migrations on LF cloud

* Update web/src/features/background-migrations/server/background-migrations-router.ts

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

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-09-09 21:53:55 +00:00
Max DeichmannandGitHub 72e5b51398 fix: rename protectedprocedure (#9026)
security: rename protectedprocedure
2025-09-09 21:37:44 +00:00
0cbbf94568 chore(cloud): allow selection of HIPAA cloud region on sign-in/sign-up page (#9024)
* feat: Add HIPAA region and improve region selection

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

* Refactor: Remove unused state and simplify region logic

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

* Fix: Update BAA link to HIPAA security page

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

* Refactor AuthCloudRegionSwitch component for clarity

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

* delete weird file and prettier

* replace contact support on sign in with mailto link

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-09-09 22:28:08 +02:00
marliessophieandGitHub 61f4c3ac95 feat(tables-ui): pin columns in annotation and prompt metrics tables (#9021) 2025-09-09 17:50:59 +00:00
cc8f521741 fix(auth): on unsuccessful password-reset attempts (wrong OTP) delete verification tokens in db to reduce enumeration risk (#9009)
* Refactor: Use signIn with redirect: false for OTP verification

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

* nit

* revert button changes, not the focus of this pr

* push

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-09-09 17:17:06 +00:00
Marc KlingenandGitHub 540f8622e3 chore: fix password reset setup docs link (#9020) 2025-09-09 18:06:48 +02:00
marliessophieandGitHub a7e3dec6f0 chore(peek): remove polling logic and dependency on table state from peek implementation (#8885)
* fixup(peek): isolate data fetching for peek view to depend on trpc and be independent of table state

* fix: rely only on url for peek state management

* fixup: drop complex peek logic

* refactor: replace createPeekHandler with usePeekNavigation for improved URL management in table components

* chore: all peek logic fixed except compare view and expand

* chore: refactor navigation logic

* chore: docs

* chore: clear url params also in row click navigation
2025-09-09 15:43:42 +00:00
Hassieb PakzadandGitHub 5760d51c08 fix(otel): handle event observation type (#9012) 2025-09-09 09:52:17 +00:00
LanceandGitHub 59a75ce36c fix: add explicit session_id alias in ClickHouse session_data CTE (#9008) 2025-09-09 09:38:23 +00:00
Max DeichmannandGitHub 9a6a79f458 chore: add email to auth span (#9006) 2025-09-09 08:45:40 +02:00
marliessophieandGitHub 2598f44036 fix(dataset-router): correct datasetItemIds value assignment in dataset filtering logic (#8997) 2025-09-08 14:23:02 +00:00
Steffen SchmitzandGitHub f7e7b132dd feat: allow custom select filters in dashboard widget flow (#8995) 2025-09-08 14:13:10 +00:00
Hassieb PakzadGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
521be4e0b6 fix(quickstart): update js examples (#8918)
* fix(quickstart): update js examples

* Update web/src/features/prompts/components/prompt-detail.tsx

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

* push

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-09-08 12:29:07 +00:00
Steffen SchmitzandGitHub a13b5d0d32 fix: use set utility for seen traces to fix cluster slot detection (#8990)
* fix: use set utility for seen traces to fix cluster slot detection

* chore: add review instruction
2025-09-08 12:08:26 +00:00
marliessophieandGitHub ebe557881c style(dataset-table-ui): reposition columns for run items making IO more prominent; drop repetitive columns (#8983)
* style(dataset-table-ui): reposition columns for run items making IO more prominent; drop repetitive columns

* chore: avoid triple negation

* chore: types

* fix: seeder

* fix: seeder
2025-09-08 09:55:57 +00:00
marliessophieandGitHub 5e8a9e3e80 feat(dataset-run-metrics): add total cost column to dataset runs table (#8970) 2025-09-08 08:38:16 +00:00
marliessophieandGitHub ab115cdc19 feat(table-ui): pin all select columns for y-/x-overflow (#8981) 2025-09-08 07:50:14 +00:00
Henrique CadioliandGitHub 18deee6434 chore: decouple upload file from url signing in storage service (#8878) 2025-09-08 07:37:32 +00:00
marliessophieandGitHub 977724a801 fix(dataset-run-items): depend on stable trpc properties (#8973) 2025-09-05 15:54:32 +00:00
marliessophieandGitHub 434c9be8a6 feat(dataset-runs-ui): fix select and name columns on y-/x-scroll (#8967)
* chore(table): rename isPinned to isFixedPosition

* chore(table): do not allow re-ordering fixed position columns

* refactor(table): update column visibility and order keys for dataset runs

* feat(dataset-runs): fix first two columns on y-/x-scroll

* chore: fixup

* chore: add comment
2025-09-05 14:49:07 +00:00
Michael FröhlichandGitHub 06bb3bbf43 fix(support): improve context in initial support email (#8966)
update plain Router
2025-09-05 14:04:04 +00:00
marliessophieandGitHub 736f4762bc fix(table-column-visibilty): show fixed position columns as unmoveable in visibility drawer (#8968)
* chore(table): rename isPinned to isFixedPosition

* chore(table): do not allow re-ordering fixed position columns
2025-09-05 13:57:23 +00:00
Leo WeigandandGitHub 4341b37ef3 fix(trace-detail): nested observations not expanded in timeline (#8965) 2025-09-05 13:03:04 +00:00
341 changed files with 21222 additions and 8130 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
[codespell]
skip = .git,*.pdf,*.svg,package-lock.json,*.prisma,pnpm-lock.yaml
ignore-words-list = afterall,vertx,notIn,alue
ignore-words-list = afterall,vertx,notIn,alue,allTime
+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.18.3/migrate.linux-amd64.tar.gz | tar xvz && \
RUN curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.0/migrate.linux-amd64.tar.gz | tar xvz && \
chmod +x migrate && \
mv migrate /usr/local/bin/migrate
-6
View File
@@ -85,9 +85,3 @@ ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
# speeds up local development by not executing init scripts on server startup
NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
# Use the following settings to enforce running the new AMTs during the tests
LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES="true"
LANGFUSE_EXPERIMENT_INSERT_INTO_TRACES_TABLE="false"
LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT="true"
LANGFUSE_EXPERIMENT_USE_OTEL_INGESTION_QUEUE="true"
+13 -1
View File
@@ -87,4 +87,16 @@ NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
# Slack credentials for development
SLACK_CLIENT_ID=your_slack_client_id
SLACK_CLIENT_SECRET=your_slack_client_secret
SLACK_STATE_SECRET=your_slack_state_secret
SLACK_STATE_SECRET=your_slack_state_secret
# Langfuse AI feature credentials for development
LANGFUSE_AI_FEATURES_PUBLIC_KEY="pk-lf-123456789"
LANGFUSE_AI_FEATURES_SECRET_KEY="sk-lf-123456789"
LANGFUSE_AI_FEATURES_HOST="https://staging.langfuse.com"
# Bedrock credentials for development
AWS_ACCESS_KEY_ID="A123456789"
AWS_SECRET_ACCESS_KEY="SAK123456789"
LANGFUSE_AWS_BEDROCK_REGION="eu-west-1"
LANGFUSE_AWS_BEDROCK_MODEL="eu.anthropic.claude-3-5-sonnet-20240620-v1:0"
+2 -2
View File
@@ -6,7 +6,7 @@ on:
workflow_dispatch:
inputs:
service:
description: 'Service to be deployed'
description: "Service to be deployed"
type: choice
options:
- all
@@ -15,7 +15,7 @@ on:
- worker
required: true
environment:
description: 'Environment to deploy to'
description: "Environment to deploy to"
type: choice
options:
- staging
+16 -5
View File
@@ -148,7 +148,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.18.3/migrate.linux-amd64.tar.gz | tar xvz
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.0/migrate.linux-amd64.tar.gz | tar xvz
sudo mv migrate /usr/bin/migrate
which migrate
- uses: pnpm/action-setup@v3
@@ -225,7 +225,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.18.3/migrate.linux-amd64.tar.gz | tar xvz
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.0/migrate.linux-amd64.tar.gz | tar xvz
sudo mv migrate /usr/bin/migrate
which migrate
- uses: pnpm/action-setup@v3
@@ -320,7 +320,7 @@ jobs:
pnpm install
- name: Install golang-migrate for Clickhouse migrations
run: |
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.0/migrate.linux-amd64.tar.gz | tar xvz
sudo mv migrate /usr/bin/migrate
which migrate
- name: Load default env
@@ -350,6 +350,17 @@ jobs:
run: pnpm --filter=worker... run build
- name: run tests
run: pnpm --filter=worker run test
env:
LANGFUSE_LLM_CONNECTION_OPENAI_KEY: ${{ secrets.LANGFUSE_LLM_CONNECTION_OPENAI_KEY }}
LANGFUSE_LLM_CONNECTION_ANTHROPIC_KEY: ${{ secrets.LANGFUSE_LLM_CONNECTION_ANTHROPIC_KEY }}
LANGFUSE_LLM_CONNECTION_AZURE_KEY: ${{ secrets.LANGFUSE_LLM_CONNECTION_AZURE_KEY }}
LANGFUSE_LLM_CONNECTION_AZURE_BASE_URL: ${{ secrets.LANGFUSE_LLM_CONNECTION_AZURE_BASE_URL }}
LANGFUSE_LLM_CONNECTION_AZURE_MODEL: ${{ secrets.LANGFUSE_LLM_CONNECTION_AZURE_MODEL }}
LANGFUSE_LLM_CONNECTION_BEDROCK_ACCESS_KEY_ID: ${{ secrets.LANGFUSE_LLM_CONNECTION_BEDROCK_ACCESS_KEY_ID }}
LANGFUSE_LLM_CONNECTION_BEDROCK_SECRET_ACCESS_KEY: ${{ secrets.LANGFUSE_LLM_CONNECTION_BEDROCK_SECRET_ACCESS_KEY }}
LANGFUSE_LLM_CONNECTION_BEDROCK_REGION: ${{ secrets.LANGFUSE_LLM_CONNECTION_BEDROCK_REGION }}
LANGFUSE_LLM_CONNECTION_VERTEXAI_KEY: ${{ secrets.LANGFUSE_LLM_CONNECTION_VERTEXAI_KEY }}
LANGFUSE_LLM_CONNECTION_GOOGLEAISTUDIO_KEY: ${{ secrets.LANGFUSE_LLM_CONNECTION_GOOGLEAISTUDIO_KEY }}
e2e-tests:
runs-on: ubuntu-latest
@@ -381,7 +392,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.18.3/migrate.linux-amd64.tar.gz | tar xvz
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.0/migrate.linux-amd64.tar.gz | tar xvz
sudo mv migrate /usr/bin/migrate
which migrate
- name: Run + migrate
@@ -437,7 +448,7 @@ jobs:
pnpm install
- name: Install golang-migrate for Clickhouse migrations
run: |
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.0/migrate.linux-amd64.tar.gz | tar xvz
sudo mv migrate /usr/bin/migrate
which migrate
- name: Load default env
+149
View File
@@ -0,0 +1,149 @@
name: SDK API Spec Generation
on:
push:
branches:
- main
paths:
- 'fern/**'
workflow_dispatch:
concurrency:
group: sdk-api-spec-${{ github.ref }}
cancel-in-progress: true
jobs:
generate-sdk-api-specs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Generate SDK specs
env:
FERN_TOKEN: ${{ secrets.FERN_TOKEN }}
run: npx fern-api generate --api server --force
- name: Update Python SDK
env:
GH_ACCESS_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
run: |
# Clone langfuse-python repository
git clone https://github.com/langfuse/langfuse-python.git ../langfuse-python
cd ../langfuse-python
git config user.name "langfuse-bot"
git config user.email "langfuse-bot@langfuse.com"
echo "$GH_ACCESS_TOKEN" | gh auth login --with-token
gh auth setup-git
# Delete existing API contents
rm -rf langfuse/api/*
# Copy generated Python SDK files
cp -r ../langfuse/generated/python/* langfuse/api/
# Remove unnecessary integration tests created by Fern
# (could not find the right option to prevent this in generation step)
rm -rf langfuse/api/tests
# Install poetry and format
pip install poetry
poetry install --all-extras
poetry run ruff format langfuse/api
# Check for changes and show diff for debugging
if git diff --quiet; then
echo "No changes detected in Python SDK"
exit 0
fi
# Close existing api-spec-bot PRs
gh pr list --author langfuse-bot --state open --json number --jq '.[].number' | xargs -I {} gh pr close {}
# Get the GitHub username of the original commit author
cd ../langfuse
ORIGINAL_AUTHOR=$(gh api repos/langfuse/langfuse/commits/${GITHUB_SHA} --jq '.author.login')
cd ../langfuse-python
# Create new branch and push changes
BRANCH_NAME="api-spec-bot-${GITHUB_SHA::7}"
git checkout -b "$BRANCH_NAME"
git add .
git commit -m "feat(api): update API spec from langfuse/langfuse ${GITHUB_SHA::7}"
git push origin "$BRANCH_NAME"
# Create PR with original author as reviewer
gh pr create --title "feat(api): update API spec from langfuse/langfuse ${GITHUB_SHA::7}" --body "" --reviewer "$ORIGINAL_AUTHOR"
- name: Update TypeScript SDK
env:
GH_ACCESS_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
run: |
# Clone langfuse-js repository
git clone https://github.com/langfuse/langfuse-js.git ../langfuse-js
cd ../langfuse-js
# Configure git
git config user.name "langfuse-bot"
git config user.email "langfuse-bot@langfuse.com"
echo "$GH_ACCESS_TOKEN" | gh auth login --with-token
gh auth setup-git
# Delete existing API contents
rm -rf packages/core/src/api/*
# Copy generated TypeScript SDK files
cp -r ../langfuse/generated/typescript/* packages/core/src/api/
# Patch compilation error inducing output
# Without this patch, the JS SDK will not build due to
# "Error: packages/core build: src/api/core/auth/index.ts(1,10): error TS1205: Re-exporting a type when 'isolatedModules' is enabled requires using 'export type'."
if [ -f "packages/core/src/api/core/auth/index.ts" ]; then
if grep -q '^export { AuthProvider } from "./AuthProvider.js";$' packages/core/src/api/core/auth/index.ts; then
sed -i '1s/^export { AuthProvider }/export { type AuthProvider }/' packages/core/src/api/core/auth/index.ts
fi
fi
# Install dependencies and format
npm install -g pnpm
pnpm install
pnpm format
# Check for changes and show diff for debugging
if git diff --quiet; then
echo "No changes detected in TypeScript SDK"
exit 0
fi
# Close existing api-spec-bot PRs
gh pr list --author langfuse-bot --state open --json number --jq '.[].number' | xargs -I {} gh pr close {}
# Get the GitHub username of the original commit author
cd ../langfuse
ORIGINAL_AUTHOR=$(gh api repos/langfuse/langfuse/commits/${GITHUB_SHA} --jq '.author.login')
cd ../langfuse-js
# Create new branch and push changes
BRANCH_NAME="api-spec-bot-${GITHUB_SHA::7}"
git checkout -b "$BRANCH_NAME"
git add .
git commit -m "feat(api): update API spec from langfuse/langfuse ${GITHUB_SHA::7}" --no-verify
git push origin "$BRANCH_NAME"
# Create PR with original author as reviewer
gh pr create --title "feat(api): update API spec from langfuse/langfuse ${GITHUB_SHA::7}" --body "" --reviewer "$ORIGINAL_AUTHOR"
+104 -51
View File
@@ -1,4 +1,4 @@
![Langfuse GitHub Banner](https://langfuse.com/images/docs/github-readme/github-banner.png)
<img width="4856" height="1000" alt="github-banner" src="https://github.com/user-attachments/assets/6f435ef3-1194-4e26-87af-aa13826bbb5f" />
<div align="center">
<div>
@@ -76,7 +76,7 @@ Langfuse is an **open source LLM engineering** platform. It helps teams collabor
## ✨ Core Features
![Langfuse Overview](https://langfuse.com/images/docs/github-readme/github-feature-overview.png)
<img width="4856" height="1944" alt="Langfuse Overview" src="https://github.com/user-attachments/assets/5dac68ef-d546-49fb-b06f-cfafc19282e3" />
- [LLM Application Observability](https://langfuse.com/docs/tracing): Instrument your app and start ingesting traces to Langfuse, thereby tracking LLM calls and other relevant logic in your app such as retrieval, embedding, or agent actions. Inspect and debug complex logs and user sessions. Try the interactive [demo](https://langfuse.com/docs/demo) to see this in action.
@@ -92,7 +92,7 @@ Langfuse is an **open source LLM engineering** platform. It helps teams collabor
## 📦 Deploy Langfuse
![Langfuse Deployment Options](https://langfuse.com/images/docs/github-readme/github-deployment-options.png)
<img width="4856" height="1322" alt="Langfuse Deployment Options" src="https://github.com/user-attachments/assets/98f020c7-7a20-4264-a201-65c41a52a5d5" />
### Langfuse Cloud
@@ -127,7 +127,7 @@ See [self-hosting documentation](https://langfuse.com/self-hosting) to learn mor
## 🔌 Integrations
![Langfuse Integrations](https://langfuse.com/images/docs/github-readme/github-integrations.png)
<img width="4856" height="1322" alt="github-integrations" src="https://github.com/user-attachments/assets/e41ea0fb-742d-41ce-bf94-1d4fb95750cd" />
### Main Integrations:
@@ -215,7 +215,7 @@ main()
See your language model calls and other application logic in Langfuse.
![Example trace in Langfuse](https://langfuse.com/images/docs/github-readme/github-example-trace.png)
<img width="1787" height="674" alt="Example trace in Langfuse" src="https://github.com/user-attachments/assets/f796eb78-dfb5-4570-b236-bdb4b67d4d55" />
_[Public example trace in Langfuse](https://cloud.langfuse.com/project/cloramnkj0002jz088vzn1ja4/traces/2cec01e3-3dc2-472f-afcf-3b968cf0c1f4?timestamp=2025-02-10T14%3A27%3A30.275Z&observation=cb5ff844-07ef-41e6-b8e2-6c64344bc13b)_
@@ -268,52 +268,105 @@ This repository is MIT licensed, except for the `ee` folders. See [LICENSE](LICE
Top open-source Python projects that use Langfuse, ranked by stars ([Source](https://github.com/langfuse/langfuse-docs/blob/main/components-mdx/dependents)):
| Repository | Stars |
| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----: |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/127165244?s=40&v=4" width="20" height="20" alt=""> &nbsp; [langgenius](https://github.com/langgenius) / [dify](https://github.com/langgenius/dify) | 54865 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/158137808?s=40&v=4" width="20" height="20" alt=""> &nbsp; [open-webui](https://github.com/open-webui) / [open-webui](https://github.com/open-webui/open-webui) | 51531 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/131470832?s=40&v=4" width="20" height="20" alt=""> &nbsp; [lobehub](https://github.com/lobehub) / [lobe-chat](https://github.com/lobehub/lobe-chat) | 49003 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/85702467?s=40&v=4" width="20" height="20" alt=""> &nbsp; [langflow-ai](https://github.com/langflow-ai) / [langflow](https://github.com/langflow-ai/langflow) | 39093 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/130722866?s=40&v=4" width="20" height="20" alt=""> &nbsp; [run-llama](https://github.com/run-llama) / [llama_index](https://github.com/run-llama/llama_index) | 37368 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/139558948?s=40&v=4" width="20" height="20" alt=""> &nbsp; [chatchat-space](https://github.com/chatchat-space) / [Langchain-Chatchat](https://github.com/chatchat-space/Langchain-Chatchat) | 32486 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/128289781?s=40&v=4" width="20" height="20" alt=""> &nbsp; [FlowiseAI](https://github.com/FlowiseAI) / [Flowise](https://github.com/FlowiseAI/Flowise) | 32448 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/31035808?s=40&v=4" width="20" height="20" alt=""> &nbsp; [mindsdb](https://github.com/mindsdb) / [mindsdb](https://github.com/mindsdb/mindsdb) | 26931 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/119600397?s=40&v=4" width="20" height="20" alt=""> &nbsp; [twentyhq](https://github.com/twentyhq) / [twenty](https://github.com/twentyhq/twenty) | 24195 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/60330232?s=40&v=4" width="20" height="20" alt=""> &nbsp; [PostHog](https://github.com/PostHog) / [posthog](https://github.com/PostHog/posthog) | 22618 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/121462774?s=40&v=4" width="20" height="20" alt=""> &nbsp; [BerriAI](https://github.com/BerriAI) / [litellm](https://github.com/BerriAI/litellm) | 15151 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/179202840?s=40&v=4" width="20" height="20" alt=""> &nbsp; [mediar-ai](https://github.com/mediar-ai) / [screenpipe](https://github.com/mediar-ai/screenpipe) | 11037 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/105877416?s=40&v=4" width="20" height="20" alt=""> &nbsp; [formbricks](https://github.com/formbricks) / [formbricks](https://github.com/formbricks/formbricks) | 9386 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/76263028?s=40&v=4" width="20" height="20" alt=""> &nbsp; [anthropics](https://github.com/anthropics) / [courses](https://github.com/anthropics/courses) | 8385 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/78410652?s=40&v=4" width="20" height="20" alt=""> &nbsp; [GreyDGL](https://github.com/GreyDGL) / [PentestGPT](https://github.com/GreyDGL/PentestGPT) | 7374 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/152537519?s=40&v=4" width="20" height="20" alt=""> &nbsp; [superagent-ai](https://github.com/superagent-ai) / [superagent](https://github.com/superagent-ai/superagent) | 5391 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/137907881?s=40&v=4" width="20" height="20" alt=""> &nbsp; [promptfoo](https://github.com/promptfoo) / [promptfoo](https://github.com/promptfoo/promptfoo) | 4976 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/157326433?s=40&v=4" width="20" height="20" alt=""> &nbsp; [onlook-dev](https://github.com/onlook-dev) / [onlook](https://github.com/onlook-dev/onlook) | 4141 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/7250217?s=40&v=4" width="20" height="20" alt=""> &nbsp; [Canner](https://github.com/Canner) / [WrenAI](https://github.com/Canner/WrenAI) | 2526 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/11855343?s=40&v=4" width="20" height="20" alt=""> &nbsp; [pingcap](https://github.com/pingcap) / [autoflow](https://github.com/pingcap/autoflow) | 2061 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/85268109?s=40&v=4" width="20" height="20" alt=""> &nbsp; [MLSysOps](https://github.com/MLSysOps) / [MLE-agent](https://github.com/MLSysOps/MLE-agent) | 1161 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/158137808?s=40&v=4" width="20" height="20" alt=""> &nbsp; [open-webui](https://github.com/open-webui) / [pipelines](https://github.com/open-webui/pipelines) | 1100 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/18422723?s=40&v=4" width="20" height="20" alt=""> &nbsp; [alishobeiri](https://github.com/alishobeiri) / [thread](https://github.com/alishobeiri/thread) | 1074 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/125468716?s=40&v=4" width="20" height="20" alt=""> &nbsp; [topoteretes](https://github.com/topoteretes) / [cognee](https://github.com/topoteretes/cognee) | 971 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/188657705?s=40&v=4" width="20" height="20" alt=""> &nbsp; [bRAGAI](https://github.com/bRAGAI) / [bRAG-langchain](https://github.com/bRAGAI/bRAG-langchain) | 823 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/169500408?s=40&v=4" width="20" height="20" alt=""> &nbsp; [opslane](https://github.com/opslane) / [opslane](https://github.com/opslane/opslane) | 677 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/151867818?s=40&v=4" width="20" height="20" alt=""> &nbsp; [dynamiq-ai](https://github.com/dynamiq-ai) / [dynamiq](https://github.com/dynamiq-ai/dynamiq) | 639 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/48585267?s=40&v=4" width="20" height="20" alt=""> &nbsp; [theopenconversationkit](https://github.com/theopenconversationkit) / [tock](https://github.com/theopenconversationkit/tock) | 514 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/20493493?s=40&v=4" width="20" height="20" alt=""> &nbsp; [andysingal](https://github.com/andysingal) / [llm-course](https://github.com/andysingal/llm-course) | 394 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/132396805?s=40&v=4" width="20" height="20" alt=""> &nbsp; [phospho-app](https://github.com/phospho-app) / [phospho](https://github.com/phospho-app/phospho) | 384 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/178644984?s=40&v=4" width="20" height="20" alt=""> &nbsp; [sentient-engineering](https://github.com/sentient-engineering) / [agent-q](https://github.com/sentient-engineering/agent-q) | 370 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/168552753?s=40&v=4" width="20" height="20" alt=""> &nbsp; [sql-agi](https://github.com/sql-agi) / [DB-GPT](https://github.com/sql-agi/DB-GPT) | 324 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/60330232?s=40&v=4" width="20" height="20" alt=""> &nbsp; [PostHog](https://github.com/PostHog) / [posthog-foss](https://github.com/PostHog/posthog-foss) | 305 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/154247157?s=40&v=4" width="20" height="20" alt=""> &nbsp; [vespperhq](https://github.com/vespperhq) / [vespper](https://github.com/vespperhq/vespper) | 304 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/185116535?s=40&v=4" width="20" height="20" alt=""> &nbsp; [block](https://github.com/block) / [goose](https://github.com/block/goose) | 295 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/609489?s=40&v=4" width="20" height="20" alt=""> &nbsp; [aorwall](https://github.com/aorwall) / [moatless-tools](https://github.com/aorwall/moatless-tools) | 291 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/2357342?s=40&v=4" width="20" height="20" alt=""> &nbsp; [dmayboroda](https://github.com/dmayboroda) / [minima](https://github.com/dmayboroda/minima) | 221 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/66303003?s=40&v=4" width="20" height="20" alt=""> &nbsp; [RobotecAI](https://github.com/RobotecAI) / [rai](https://github.com/RobotecAI/rai) | 172 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/148684274?s=40&v=4" width="20" height="20" alt=""> &nbsp; [i-am-alice](https://github.com/i-am-alice) / [3rd-devs](https://github.com/i-am-alice/3rd-devs) | 148 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/171735272?s=40&v=4" width="20" height="20" alt=""> &nbsp; [8090-inc](https://github.com/8090-inc) / [xrx-sample-apps](https://github.com/8090-inc/xrx-sample-apps) | 138 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/104478511?s=40&v=4" width="20" height="20" alt=""> &nbsp; [babelcloud](https://github.com/babelcloud) / [LLM-RGB](https://github.com/babelcloud/LLM-RGB) | 135 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/15125613?s=40&v=4" width="20" height="20" alt=""> &nbsp; [souzatharsis](https://github.com/souzatharsis) / [tamingLLMs](https://github.com/souzatharsis/tamingLLMs) | 129 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/169401942?s=40&v=4" width="20" height="20" alt=""> &nbsp; [LibreChat-AI](https://github.com/LibreChat-AI) / [librechat.ai](https://github.com/LibreChat-AI/librechat.ai) | 128 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/51827949?s=40&v=4" width="20" height="20" alt=""> &nbsp; [deepset-ai](https://github.com/deepset-ai) / [haystack-core-integrations](https://github.com/deepset-ai/haystack-core-integrations) | 126 |
| Repository | Stars |
| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -----: |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/85702467?s=40&v=4" width="20" height="20" alt=""> &nbsp; [langflow-ai](https://github.com/langflow-ai) / [langflow](https://github.com/langflow-ai/langflow) | 116251 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/158137808?s=40&v=4" width="20" height="20" alt=""> &nbsp; [open-webui](https://github.com/open-webui) / [open-webui](https://github.com/open-webui/open-webui) | 109642 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/23818?s=40&v=4" width="20" height="20" alt=""> &nbsp; [abi](https://github.com/abi) / [screenshot-to-code](https://github.com/abi/screenshot-to-code) | 70877 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/131470832?s=40&v=4" width="20" height="20" alt=""> &nbsp; [lobehub](https://github.com/lobehub) / [lobe-chat](https://github.com/lobehub/lobe-chat) | 65454 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/69962740?s=40&v=4" width="20" height="20" alt=""> &nbsp; [infiniflow](https://github.com/infiniflow) / [ragflow](https://github.com/infiniflow/ragflow) | 64118 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/135057108?s=40&v=4" width="20" height="20" alt=""> &nbsp; [firecrawl](https://github.com/firecrawl) / [firecrawl](https://github.com/firecrawl/firecrawl) | 56713 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/130722866?s=40&v=4" width="20" height="20" alt=""> &nbsp; [run-llama](https://github.com/run-llama) / [llama_index](https://github.com/run-llama/llama_index) | 44203 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/128289781?s=40&v=4" width="20" height="20" alt=""> &nbsp; [FlowiseAI](https://github.com/FlowiseAI) / [Flowise](https://github.com/FlowiseAI/Flowise) | 43547 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/159330290?s=40&v=4" width="20" height="20" alt=""> &nbsp; [QuivrHQ](https://github.com/QuivrHQ) / [quivr](https://github.com/QuivrHQ/quivr) | 38415 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/6154722?s=40&v=4" width="20" height="20" alt=""> &nbsp; [microsoft](https://github.com/microsoft) / [ai-agents-for-beginners](https://github.com/microsoft/ai-agents-for-beginners) | 38012 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/139558948?s=40&v=4" width="20" height="20" alt=""> &nbsp; [chatchat-space](https://github.com/chatchat-space) / [Langchain-Chatchat](https://github.com/chatchat-space/Langchain-Chatchat) | 36071 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/31035808?s=40&v=4" width="20" height="20" alt=""> &nbsp; [mindsdb](https://github.com/mindsdb) / [mindsdb](https://github.com/mindsdb/mindsdb) | 35669 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/121462774?s=40&v=4" width="20" height="20" alt=""> &nbsp; [BerriAI](https://github.com/BerriAI) / [litellm](https://github.com/BerriAI/litellm) | 28726 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/157326433?s=40&v=4" width="20" height="20" alt=""> &nbsp; [onlook-dev](https://github.com/onlook-dev) / [onlook](https://github.com/onlook-dev/onlook) | 22447 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/487568?s=40&v=4" width="20" height="20" alt=""> &nbsp; [NixOS](https://github.com/NixOS) / [nixpkgs](https://github.com/NixOS/nixpkgs) | 21748 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/170767358?s=40&v=4" width="20" height="20" alt=""> &nbsp; [kortix-ai](https://github.com/kortix-ai) / [suna](https://github.com/kortix-ai/suna) | 17976 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/76263028?s=40&v=4" width="20" height="20" alt=""> &nbsp; [anthropics](https://github.com/anthropics) / [courses](https://github.com/anthropics/courses) | 17057 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/149120496?s=40&v=4" width="20" height="20" alt=""> &nbsp; [mastra-ai](https://github.com/mastra-ai) / [mastra](https://github.com/mastra-ai/mastra) | 16484 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/134601687?s=40&v=4" width="20" height="20" alt=""> &nbsp; [langfuse](https://github.com/langfuse) / [langfuse](https://github.com/langfuse/langfuse) | 16054 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/7250217?s=40&v=4" width="20" height="20" alt=""> &nbsp; [Canner](https://github.com/Canner) / [WrenAI](https://github.com/Canner/WrenAI) | 11868 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/137907881?s=40&v=4" width="20" height="20" alt=""> &nbsp; [promptfoo](https://github.com/promptfoo) / [promptfoo](https://github.com/promptfoo/promptfoo) | 8350 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/193350244?s=40&v=4" width="20" height="20" alt=""> &nbsp; [The-Pocket](https://github.com/The-Pocket) / [PocketFlow](https://github.com/The-Pocket/PocketFlow) | 8313 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/139012218?s=40&v=4" width="20" height="20" alt=""> &nbsp; [OpenPipe](https://github.com/OpenPipe) / [ART](https://github.com/OpenPipe/ART) | 7093 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/125468716?s=40&v=4" width="20" height="20" alt=""> &nbsp; [topoteretes](https://github.com/topoteretes) / [cognee](https://github.com/topoteretes/cognee) | 7011 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/3299148?s=40&v=4" width="20" height="20" alt=""> &nbsp; [awslabs](https://github.com/awslabs) / [agent-squad](https://github.com/awslabs/agent-squad) | 6785 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/162546372?s=40&v=4" width="20" height="20" alt=""> &nbsp; [BasedHardware](https://github.com/BasedHardware) / [omi](https://github.com/BasedHardware/omi) | 6231 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/118301880?s=40&v=4" width="20" height="20" alt=""> &nbsp; [hatchet-dev](https://github.com/hatchet-dev) / [hatchet](https://github.com/hatchet-dev/hatchet) | 6019 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/88676955?s=40&v=4" width="20" height="20" alt=""> &nbsp; [zenml-io](https://github.com/zenml-io) / [zenml](https://github.com/zenml-io/zenml) | 4873 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/132635806?s=40&v=4" width="20" height="20" alt=""> &nbsp; [refly-ai](https://github.com/refly-ai) / [refly](https://github.com/refly-ai/refly) | 4654 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/47287758?s=40&v=4" width="20" height="20" alt=""> &nbsp; [coleam00](https://github.com/coleam00) / [ottomator-agents](https://github.com/coleam00/ottomator-agents) | 4165 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/8251002?s=40&v=4" width="20" height="20" alt=""> &nbsp; [JoshuaC215](https://github.com/JoshuaC215) / [agent-service-toolkit](https://github.com/JoshuaC215/agent-service-toolkit) | 3557 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/185852128?s=40&v=4" width="20" height="20" alt=""> &nbsp; [colanode](https://github.com/colanode) / [colanode](https://github.com/colanode/colanode) | 3517 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/201282378?s=40&v=4" width="20" height="20" alt=""> &nbsp; [VoltAgent](https://github.com/VoltAgent) / [voltagent](https://github.com/VoltAgent/voltagent) | 3210 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/188657705?s=40&v=4" width="20" height="20" alt=""> &nbsp; [bragai](https://github.com/bragai) / [bRAG-langchain](https://github.com/bragai/bRAG-langchain) | 3010 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/11855343?s=40&v=4" width="20" height="20" alt=""> &nbsp; [pingcap](https://github.com/pingcap) / [autoflow](https://github.com/pingcap/autoflow) | 2651 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/135396723?s=40&v=4" width="20" height="20" alt=""> &nbsp; [sourcebot-dev](https://github.com/sourcebot-dev) / [sourcebot](https://github.com/sourcebot-dev/sourcebot) | 2570 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/158137808?s=40&v=4" width="20" height="20" alt=""> &nbsp; [open-webui](https://github.com/open-webui) / [pipelines](https://github.com/open-webui/pipelines) | 2055 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/179994263?s=40&v=4" width="20" height="20" alt=""> &nbsp; [YFGaia](https://github.com/YFGaia) / [dify-plus](https://github.com/YFGaia/dify-plus) | 1734 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/46323662?s=40&v=4" width="20" height="20" alt=""> &nbsp; [TheSpaghettiDetective](https://github.com/TheSpaghettiDetective) / [obico-server](https://github.com/TheSpaghettiDetective/obico-server) | 1687 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/85268109?s=40&v=4" width="20" height="20" alt=""> &nbsp; [MLSysOps](https://github.com/MLSysOps) / [MLE-agent](https://github.com/MLSysOps/MLE-agent) | 1387 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/144196744?s=40&v=4" width="20" height="20" alt=""> &nbsp; [TIGER-AI-Lab](https://github.com/TIGER-AI-Lab) / [TheoremExplainAgent](https://github.com/TIGER-AI-Lab/TheoremExplainAgent) | 1385 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/2314423?s=40&v=4" width="20" height="20" alt=""> &nbsp; [trailofbits](https://github.com/trailofbits) / [buttercup](https://github.com/trailofbits/buttercup) | 1223 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/62564480?s=40&v=4" width="20" height="20" alt=""> &nbsp; [wassim249](https://github.com/wassim249) / [fastapi-langgraph-agent-production-ready-template](https://github.com/wassim249/fastapi-langgraph-agent-production-ready-template) | 1200 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/18422723?s=40&v=4" width="20" height="20" alt=""> &nbsp; [alishobeiri](https://github.com/alishobeiri) / [thread](https://github.com/alishobeiri/thread) | 1098 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/2357342?s=40&v=4" width="20" height="20" alt=""> &nbsp; [dmayboroda](https://github.com/dmayboroda) / [minima](https://github.com/dmayboroda/minima) | 1010 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/65890619?s=40&v=4" width="20" height="20" alt=""> &nbsp; [zstar1003](https://github.com/zstar1003) / [ragflow-plus](https://github.com/zstar1003/ragflow-plus) | 993 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/121352441?s=40&v=4" width="20" height="20" alt=""> &nbsp; [openops-cloud](https://github.com/openops-cloud) / [openops](https://github.com/openops-cloud/openops) | 939 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/151867818?s=40&v=4" width="20" height="20" alt=""> &nbsp; [dynamiq-ai](https://github.com/dynamiq-ai) / [dynamiq](https://github.com/dynamiq-ai/dynamiq) | 927 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/74420131?s=40&v=4" width="20" height="20" alt=""> &nbsp; [xataio](https://github.com/xataio) / [agent](https://github.com/xataio/agent) | 857 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/123981229?s=40&v=4" width="20" height="20" alt=""> &nbsp; [plastic-labs](https://github.com/plastic-labs) / [tutor-gpt](https://github.com/plastic-labs/tutor-gpt) | 845 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/176438967?s=40&v=4" width="20" height="20" alt=""> &nbsp; [trendy-design](https://github.com/trendy-design) / [llmchat](https://github.com/trendy-design/llmchat) | 829 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/2045570?s=40&v=4" width="20" height="20" alt=""> &nbsp; [hotovo](https://github.com/hotovo) / [aider-desk](https://github.com/hotovo/aider-desk) | 781 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/169500408?s=40&v=4" width="20" height="20" alt=""> &nbsp; [opslane](https://github.com/opslane) / [opslane](https://github.com/opslane/opslane) | 719 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/142796018?s=40&v=4" width="20" height="20" alt=""> &nbsp; [wrtnlabs](https://github.com/wrtnlabs) / [autoview](https://github.com/wrtnlabs/autoview) | 688 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/20493493?s=40&v=4" width="20" height="20" alt=""> &nbsp; [andysingal](https://github.com/andysingal) / [llm-course](https://github.com/andysingal/llm-course) | 643 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/48585267?s=40&v=4" width="20" height="20" alt=""> &nbsp; [theopenconversationkit](https://github.com/theopenconversationkit) / [tock](https://github.com/theopenconversationkit/tock) | 587 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/178644984?s=40&v=4" width="20" height="20" alt=""> &nbsp; [sentient-engineering](https://github.com/sentient-engineering) / [agent-q](https://github.com/sentient-engineering/agent-q) | 487 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/58037050?s=40&v=4" width="20" height="20" alt=""> &nbsp; [NicholasGoh](https://github.com/NicholasGoh) / [fastapi-mcp-langgraph-template](https://github.com/NicholasGoh/fastapi-mcp-langgraph-template) | 481 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/148684274?s=40&v=4" width="20" height="20" alt=""> &nbsp; [i-am-alice](https://github.com/i-am-alice) / [3rd-devs](https://github.com/i-am-alice/3rd-devs) | 472 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/163431636?s=40&v=4" width="20" height="20" alt=""> &nbsp; [AIDotNet](https://github.com/AIDotNet) / [koala-ai](https://github.com/AIDotNet/koala-ai) | 470 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/132396805?s=40&v=4" width="20" height="20" alt=""> &nbsp; [phospho-app](https://github.com/phospho-app) / [text-analytics-legacy](https://github.com/phospho-app/text-analytics-legacy) | 439 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/170831637?s=40&v=4" width="20" height="20" alt=""> &nbsp; [inferablehq](https://github.com/inferablehq) / [inferable](https://github.com/inferablehq/inferable) | 403 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/171800766?s=40&v=4" width="20" height="20" alt=""> &nbsp; [duoyang666](https://github.com/duoyang666) / [ai_novel](https://github.com/duoyang666/ai_novel) | 397 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/209155962?s=40&v=4" width="20" height="20" alt=""> &nbsp; [strands-agents](https://github.com/strands-agents) / [samples](https://github.com/strands-agents/samples) | 385 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/16997807?s=40&v=4" width="20" height="20" alt=""> &nbsp; [FranciscoMoretti](https://github.com/FranciscoMoretti) / [sparka](https://github.com/FranciscoMoretti/sparka) | 380 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/66303003?s=40&v=4" width="20" height="20" alt=""> &nbsp; [RobotecAI](https://github.com/RobotecAI) / [rai](https://github.com/RobotecAI/rai) | 373 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/137044109?s=40&v=4" width="20" height="20" alt=""> &nbsp; [ElectricCodeGuy](https://github.com/ElectricCodeGuy) / [SupabaseAuthWithSSR](https://github.com/ElectricCodeGuy/SupabaseAuthWithSSR) | 370 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/169401942?s=40&v=4" width="20" height="20" alt=""> &nbsp; [LibreChat-AI](https://github.com/LibreChat-AI) / [librechat.ai](https://github.com/LibreChat-AI/librechat.ai) | 339 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/15125613?s=40&v=4" width="20" height="20" alt=""> &nbsp; [souzatharsis](https://github.com/souzatharsis) / [tamingLLMs](https://github.com/souzatharsis/tamingLLMs) | 323 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/8931462?s=40&v=4" width="20" height="20" alt=""> &nbsp; [aws-samples](https://github.com/aws-samples) / [aws-ai-ml-workshop-kr](https://github.com/aws-samples/aws-ai-ml-workshop-kr) | 295 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/25676773?s=40&v=4" width="20" height="20" alt=""> &nbsp; [weizxfree](https://github.com/weizxfree) / [KnowFlow](https://github.com/weizxfree/KnowFlow) | 285 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/88676955?s=40&v=4" width="20" height="20" alt=""> &nbsp; [zenml-io](https://github.com/zenml-io) / [zenml-projects](https://github.com/zenml-io/zenml-projects) | 276 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/136071305?s=40&v=4" width="20" height="20" alt=""> &nbsp; [wxai-space](https://github.com/wxai-space) / [LightAgent](https://github.com/wxai-space/LightAgent) | 275 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/90278288?s=40&v=4" width="20" height="20" alt=""> &nbsp; [Ozamatash](https://github.com/Ozamatash) / [deep-research-mcp](https://github.com/Ozamatash/deep-research-mcp) | 269 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/168552753?s=40&v=4" width="20" height="20" alt=""> &nbsp; [sql-agi](https://github.com/sql-agi) / [DB-GPT](https://github.com/sql-agi/DB-GPT) | 241 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/828269?s=40&v=4" width="20" height="20" alt=""> &nbsp; [guyernest](https://github.com/guyernest) / [advanced-rag](https://github.com/guyernest/advanced-rag) | 238 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/175069511?s=40&v=4" width="20" height="20" alt=""> &nbsp; [bklieger-groq](https://github.com/bklieger-groq) / [mathtutor-on-groq](https://github.com/bklieger-groq/mathtutor-on-groq) | 233 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/123981229?s=40&v=4" width="20" height="20" alt=""> &nbsp; [plastic-labs](https://github.com/plastic-labs) / [honcho](https://github.com/plastic-labs/honcho) | 224 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/118967077?s=40&v=4" width="20" height="20" alt=""> &nbsp; [OVINC-CN](https://github.com/OVINC-CN) / [OpenWebUI](https://github.com/OVINC-CN/OpenWebUI) | 202 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/31960995?s=40&v=4" width="20" height="20" alt=""> &nbsp; [zhutoutoutousan](https://github.com/zhutoutoutousan) / [worldquant-miner](https://github.com/zhutoutoutousan/worldquant-miner) | 202 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/29215115?s=40&v=4" width="20" height="20" alt=""> &nbsp; [iceener](https://github.com/iceener) / [ai](https://github.com/iceener/ai) | 186 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/187584218?s=40&v=4" width="20" height="20" alt=""> &nbsp; [giselles-ai](https://github.com/giselles-ai) / [giselle](https://github.com/giselles-ai/giselle) | 181 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/174666116?s=40&v=4" width="20" height="20" alt=""> &nbsp; [ai-shifu](https://github.com/ai-shifu) / [ai-shifu](https://github.com/ai-shifu/ai-shifu) | 181 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/8931462?s=40&v=4" width="20" height="20" alt=""> &nbsp; [aws-samples](https://github.com/aws-samples) / [sample-serverless-mcp-servers](https://github.com/aws-samples/sample-serverless-mcp-servers) | 175 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/202488052?s=40&v=4" width="20" height="20" alt=""> &nbsp; [celerforge](https://github.com/celerforge) / [freenote](https://github.com/celerforge/freenote) | 171 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/104478511?s=40&v=4" width="20" height="20" alt=""> &nbsp; [babelcloud](https://github.com/babelcloud) / [LLM-RGB](https://github.com/babelcloud/LLM-RGB) | 164 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/171735272?s=40&v=4" width="20" height="20" alt=""> &nbsp; [8090-inc](https://github.com/8090-inc) / [xrx-sample-apps](https://github.com/8090-inc/xrx-sample-apps) | 163 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/51827949?s=40&v=4" width="20" height="20" alt=""> &nbsp; [deepset-ai](https://github.com/deepset-ai) / [haystack-core-integrations](https://github.com/deepset-ai/haystack-core-integrations) | 163 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/1009716?s=40&v=4" width="20" height="20" alt=""> &nbsp; [codecentric](https://github.com/codecentric) / [c4-genai-suite](https://github.com/codecentric/c4-genai-suite) | 152 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/196509932?s=40&v=4" width="20" height="20" alt=""> &nbsp; [XSpoonAi](https://github.com/XSpoonAi) / [spoon-core](https://github.com/XSpoonAi/spoon-core) | 150 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/139558948?s=40&v=4" width="20" height="20" alt=""> &nbsp; [chatchat-space](https://github.com/chatchat-space) / [LangGraph-Chatchat](https://github.com/chatchat-space/LangGraph-Chatchat) | 144 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/134601687?s=40&v=4" width="20" height="20" alt=""> &nbsp; [langfuse](https://github.com/langfuse) / [langfuse-docs](https://github.com/langfuse/langfuse-docs) | 139 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/44976328?s=40&v=4" width="20" height="20" alt=""> &nbsp; [piyushgarg-dev](https://github.com/piyushgarg-dev) / [genai-cohort](https://github.com/piyushgarg-dev/genai-cohort) | 135 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/105285801?s=40&v=4" width="20" height="20" alt=""> &nbsp; [i-dot-ai](https://github.com/i-dot-ai) / [redbox](https://github.com/i-dot-ai/redbox) | 132 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/90423581?s=40&v=4" width="20" height="20" alt=""> &nbsp; [bmd1905](https://github.com/bmd1905) / [ChatOpsLLM](https://github.com/bmd1905/ChatOpsLLM) | 127 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/202074444?s=40&v=4" width="20" height="20" alt=""> &nbsp; [Fintech-Dreamer](https://github.com/Fintech-Dreamer) / [FinSynth](https://github.com/Fintech-Dreamer/FinSynth) | 121 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/1566555?s=40&v=4" width="20" height="20" alt=""> &nbsp; [kenshiro-o](https://github.com/kenshiro-o) / [nagato-ai](https://github.com/kenshiro-o/nagato-ai) | 119 |
## 🔒 Security & Privacy
+9
View File
@@ -17,3 +17,12 @@
### Environment Variables
- Environment variables should be imported from the `env.mjs/ts` file of the respective package and not from `process.env.*` to ensure validation and typing.
## Redis Invocations
- Highlight usage of `redis.call` invocations. Those may have suboptimal redis cluster routing and will raise errors. Instead, use the native call patterns.
Example: `await redis?.call("SET", key, "1", "NX", "EX", TTLSeconds);` should use `await redis?.set(key, "1", "EX", TTLSeconds, "NX");` instead.
## Langfuse Cloud
- When attempting to confirm if the current environment is Langfuse Cloud in the frontend, use the `useLangfuseCloudRegion` hook and never environment variables directly.
+1 -1
View File
@@ -131,7 +131,7 @@ services:
retries: 10
postgres:
image: docker.io/postgres:${POSTGRES_VERSION:-latest}
image: docker.io/postgres:${POSTGRES_VERSION:-17}
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
+1 -1
View File
@@ -32,7 +32,7 @@ services:
- 6379:6379
postgres:
image: docker.io/postgres:${POSTGRES_VERSION:-latest}
image: docker.io/postgres:${POSTGRES_VERSION:-17}
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
+7 -7
View File
@@ -36,7 +36,7 @@ services:
start_period: 1s
postgres:
image: docker.io/postgres:${POSTGRES_VERSION:-latest}
image: docker.io/postgres:${POSTGRES_VERSION:-17}
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
@@ -55,7 +55,7 @@ services:
# Redis Cluster. Requires a Unix host to work for network_mode: host. I.e. tests will fail on windows and mac with this setup.
redis-node-0:
image: docker.io/bitnami/redis-cluster:8.0
image: docker.io/bitnamilegacy/redis-cluster:8.0
network_mode: host
volumes:
- redis-cluster_data-0:/bitnami/redis/data
@@ -65,7 +65,7 @@ services:
REDIS_NODES: 127.0.0.1:6370 127.0.0.1:6371 127.0.0.1:6372 127.0.0.1:6373 127.0.0.1:6374 127.0.0.1:6375
redis-node-1:
image: docker.io/bitnami/redis-cluster:8.0
image: docker.io/bitnamilegacy/redis-cluster:8.0
network_mode: host
volumes:
- redis-cluster_data-1:/bitnami/redis/data
@@ -75,7 +75,7 @@ services:
REDIS_NODES: 127.0.0.1:6370 127.0.0.1:6371 127.0.0.1:6372 127.0.0.1:6373 127.0.0.1:6374 127.0.0.1:6375
redis-node-2:
image: docker.io/bitnami/redis-cluster:8.0
image: docker.io/bitnamilegacy/redis-cluster:8.0
network_mode: host
volumes:
- redis-cluster_data-2:/bitnami/redis/data
@@ -85,7 +85,7 @@ services:
REDIS_NODES: 127.0.0.1:6370 127.0.0.1:6371 127.0.0.1:6372 127.0.0.1:6373 127.0.0.1:6374 127.0.0.1:6375
redis-node-3:
image: docker.io/bitnami/redis-cluster:8.0
image: docker.io/bitnamilegacy/redis-cluster:8.0
network_mode: host
volumes:
- redis-cluster_data-3:/bitnami/redis/data
@@ -95,7 +95,7 @@ services:
REDIS_NODES: 127.0.0.1:6370 127.0.0.1:6371 127.0.0.1:6372 127.0.0.1:6373 127.0.0.1:6374 127.0.0.1:6375
redis-node-4:
image: docker.io/bitnami/redis-cluster:8.0
image: docker.io/bitnamilegacy/redis-cluster:8.0
network_mode: host
volumes:
- redis-cluster_data-4:/bitnami/redis/data
@@ -105,7 +105,7 @@ services:
REDIS_NODES: 127.0.0.1:6370 127.0.0.1:6371 127.0.0.1:6372 127.0.0.1:6373 127.0.0.1:6374 127.0.0.1:6375
redis-node-5:
image: docker.io/bitnami/redis-cluster:8.0
image: docker.io/bitnamilegacy/redis-cluster:8.0
network_mode: host
volumes:
- redis-cluster_data-5:/bitnami/redis/data
+1 -1
View File
@@ -44,7 +44,7 @@ services:
- 127.0.0.1:6379:6379
postgres:
image: docker.io/postgres:${POSTGRES_VERSION:-latest}
image: docker.io/postgres:${POSTGRES_VERSION:-17}
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
+1 -1
View File
@@ -142,7 +142,7 @@ services:
retries: 10
postgres:
image: docker.io/postgres:${POSTGRES_VERSION:-latest}
image: docker.io/postgres:${POSTGRES_VERSION:-17}
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
@@ -0,0 +1,119 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
imports:
commons: ./commons.yml
service:
auth: true
base-path: /api/public/integrations/blob-storage
endpoints:
getBlobStorageIntegrations:
docs: Get all blob storage integrations for the organization (requires organization-scoped API key)
method: GET
path: ""
response: BlobStorageIntegrationsResponse
upsertBlobStorageIntegration:
docs: Create or update a blob storage integration for a specific project (requires organization-scoped API key). The configuration is validated by performing a test upload to the bucket.
method: PUT
path: ""
request: CreateBlobStorageIntegrationRequest
response: BlobStorageIntegrationResponse
deleteBlobStorageIntegration:
docs: Delete a blob storage integration by ID (requires organization-scoped API key)
method: DELETE
path: "/{id}"
path-parameters:
id: string
response: BlobStorageIntegrationDeletionResponse
types:
BlobStorageIntegrationType:
enum:
- S3
- S3_COMPATIBLE
- AZURE_BLOB_STORAGE
BlobStorageIntegrationFileType:
enum:
- JSON
- CSV
- JSONL
BlobStorageExportMode:
enum:
- FULL_HISTORY
- FROM_TODAY
- FROM_CUSTOM_DATE
BlobStorageExportFrequency:
enum:
- hourly
- daily
- weekly
CreateBlobStorageIntegrationRequest:
properties:
projectId:
type: string
docs: ID of the project in which to configure the blob storage integration
type: BlobStorageIntegrationType
bucketName:
type: string
docs: Name of the storage bucket
endpoint:
type: optional<string>
docs: Custom endpoint URL (required for S3_COMPATIBLE type)
region:
type: string
docs: Storage region
accessKeyId:
type: optional<string>
docs: Access key ID for authentication
secretAccessKey:
type: optional<string>
docs: Secret access key for authentication (will be encrypted when stored)
prefix:
type: optional<string>
docs: Path prefix for exported files (must end with forward slash if provided)
exportFrequency: BlobStorageExportFrequency
enabled:
type: boolean
docs: Whether the integration is active
forcePathStyle:
type: boolean
docs: Use path-style URLs for S3 requests
fileType: BlobStorageIntegrationFileType
exportMode: BlobStorageExportMode
exportStartDate:
type: optional<datetime>
docs: Custom start date for exports (required when exportMode is FROM_CUSTOM_DATE)
BlobStorageIntegrationResponse:
properties:
id: string
projectId: string
type: BlobStorageIntegrationType
bucketName: string
endpoint: optional<string>
region: string
accessKeyId: optional<string>
prefix: string
exportFrequency: BlobStorageExportFrequency
enabled: boolean
forcePathStyle: boolean
fileType: BlobStorageIntegrationFileType
exportMode: BlobStorageExportMode
exportStartDate: optional<datetime>
nextSyncAt: optional<datetime>
lastSyncAt: optional<datetime>
createdAt: datetime
updatedAt: datetime
BlobStorageIntegrationsResponse:
properties:
data: list<BlobStorageIntegrationResponse>
BlobStorageIntegrationDeletionResponse:
properties:
message: string
+3 -2
View File
@@ -8,8 +8,9 @@ service:
endpoints:
batch:
docs: |
Batched ingestion for Langfuse Tracing.
If you want to use tracing via the API, such as to build your own Langfuse client implementation, this is the only API route you need to implement.
**Legacy endpoint for batch ingestion for Langfuse Observability.**
-> Please use the OpenTelemetry endpoint (`/api/public/otel`). Learn more: https://langfuse.com/integrations/native/opentelemetry
Within each batch, there can be multiple events.
Each event has a type, an id, a timestamp, metadata and a body.
@@ -35,6 +35,18 @@ service:
type: string
docs: The unique langfuse identifier of a score config
response: commons.ScoreConfig
update:
docs: Update a score config
method: PATCH
path: /score-configs/{configId}
path-parameters:
configId:
type: string
docs: The unique langfuse identifier of a score config
request: UpdateScoreConfigRequest
response: commons.ScoreConfig
types:
ScoreConfigs:
properties:
@@ -56,3 +68,23 @@ types:
description:
type: optional<string>
docs: Description is shown across the Langfuse UI and can be used to e.g. explain the config categories in detail, why a numeric range was set, or provide additional context on config name or usage
UpdateScoreConfigRequest:
properties:
isArchived:
type: optional<boolean>
docs: The status of the score config showing if it is archived or not
name:
type: optional<string>
docs: The name of the score config
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
minValue:
type: optional<double>
docs: Configure a minimum value for numerical scores. If not set, the minimum value defaults to -∞
maxValue:
type: optional<double>
docs: Configure a maximum value for numerical scores. If not set, the maximum value defaults to +∞
description:
type: optional<string>
docs: Description is shown across the Langfuse UI and can be used to e.g. explain the config categories in detail, why a numeric range was set, or provide additional context on config name or usage
+3
View File
@@ -50,6 +50,9 @@ service:
configId:
type: optional<string>
docs: Retrieve only scores with a specific configId.
sessionId:
type: optional<string>
docs: Retrieve only scores with a specific sessionId.
queueId:
type: optional<string>
docs: Retrieve only scores with a specific annotation queueId.
+1 -1
View File
@@ -31,7 +31,7 @@ groups:
# client-class-name: LangfuseClient
- name: fernapi/fern-typescript-node-sdk
version: 2.6.1
version: 2.12.3
output:
location: local-file-system
path: ../../../generated/typescript
+2 -2
View File
@@ -1,4 +1,4 @@
{
"organization": "langfuse",
"version": "0.56.0"
}
"version": "0.77.5"
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.108.0",
"version": "3.114.0",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
@@ -40,7 +40,7 @@
"husky": "^9.1.7",
"prettier": "^3.6.2",
"release-it": "^19.0.4",
"turbo": "^2.5.6"
"turbo": "^2.5.8"
},
"release-it": {
"git": {
+9 -2
View File
@@ -2,10 +2,15 @@ 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", "eslint-config-turbo"],
plugins: ["only-warn"],
// extends: ["eslint:recommended", "prettier"require().default],
extends: ["eslint:recommended", "prettier"],
plugins: ["only-warn", "turbo"],
globals: {
React: true,
JSX: true,
@@ -30,6 +35,7 @@ module.exports = {
rules: {
"no-redeclare": "off",
"import/order": "off",
...(turboConfigToUse.rules || {}),
},
overrides: [
{
@@ -51,5 +57,6 @@ module.exports = {
],
},
},
...(turboConfigToUse.overrides || []),
],
};
+4 -1
View File
@@ -2,6 +2,9 @@ const { resolve } = require("node:path");
const project = resolve(process.cwd(), "tsconfig.json");
const turboConfig = require("eslint-config-turbo");
const turboConfigToUse = turboConfig.default || turboConfig;
/*
* This is a custom ESLint configuration for use with
* Next.js apps.
@@ -16,7 +19,6 @@ module.exports = {
extends: [
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/strict-type-checked",
"eslint-config-turbo",
],
rules: {
"@typescript-eslint/no-non-null-assertion": "off",
@@ -47,6 +49,7 @@ module.exports = {
ignorePatterns: ["node_modules/", "dist/"],
// add rules configurations here
rules: {
...(turboConfigToUse.rules || {}),
"@typescript-eslint/consistent-type-imports": [
"warn",
{
+1 -1
View File
@@ -13,7 +13,7 @@
"@vercel/style-guide": "^6.0.0",
"eslint-config-next": "^14.2.15",
"eslint-config-prettier": "^9.1.0",
"eslint-config-turbo": "^2.5.6",
"eslint-config-turbo": "^2.5.8",
"eslint-plugin-only-warn": "^1.1.0",
"typescript": "^5.7.2"
}
+4 -5
View File
@@ -63,7 +63,7 @@
"@azure/storage-blob": "^12.26.0",
"@clickhouse/client": "^1.12.1",
"@google-cloud/storage": "^7.17.0",
"@langchain/anthropic": "^0.3.22",
"@langchain/anthropic": "^0.3.29",
"@langchain/aws": "^0.1.11",
"@langchain/core": "^0.3.58",
"@langchain/google-genai": "^0.2.12",
@@ -73,8 +73,8 @@
"@prisma/client": "^6.10.1",
"@react-email/components": "^0.5.1",
"@react-email/render": "^1.2.1",
"@slack/oauth": "^3.0.3",
"@slack/web-api": "^7.9.3",
"@slack/oauth": "^3.0.4",
"@slack/web-api": "^7.10.0",
"@types/bcryptjs": "^2.4.6",
"bcryptjs": "^2.4.3",
"bullmq": "^5.34.10",
@@ -86,7 +86,7 @@
"ipaddr.js": "^2.2.0",
"jsonpath-plus": "10.3.0",
"kysely": "^0.27.4",
"langchain": "^0.3.28",
"langchain": "^0.3.30",
"langfuse-langchain": "3.38.4",
"lodash": "^4.17.21",
"lossless-json": "^4.1.1",
@@ -113,7 +113,6 @@
"eslint-config-standard": "^17.1.0",
"eslint-plugin-prettier": "^5.1.3",
"kysely-codegen": "^0.16.8",
"nodemon": "^3.1.7",
"prettier": "^3.6.2",
"prisma": "^6.10.1",
"prisma-erd-generator": "^1.11.2",
@@ -641,6 +641,7 @@ export type Organization = {
updated_at: Generated<Timestamp>;
cloud_config: unknown | null;
metadata: unknown | null;
ai_features_enabled: Generated<boolean>;
};
export type OrganizationMembership = {
id: string;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "organizations" ADD COLUMN "ai_features_enabled" BOOLEAN NOT NULL DEFAULT false;
+3
View File
@@ -110,6 +110,7 @@ model Organization {
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
cloudConfig Json? @map("cloud_config") // Langfuse Cloud, for zod schema see @/src/features/organizations/utils/cloudConfigSchema
metadata Json?
aiFeaturesEnabled Boolean @default(false) @map("ai_features_enabled")
organizationMemberships OrganizationMembership[]
projects Project[]
MembershipInvitation MembershipInvitation[]
@@ -853,6 +854,8 @@ model EvalTemplate {
@@map("eval_templates")
}
// We currently assume in the evalRouter that _all_ job_executions are for EVAL job_configs.
// If we ever extend this, we need to adjust the filter condition there. ref.: fetchJobExecutionsByStatus.
enum JobType {
EVAL
}
+28 -21
View File
@@ -8,6 +8,7 @@ import {
JobExecutionStatus,
PrismaClient,
type Project,
ScoreConfigCategoryDomain,
ScoreDataType,
} from "../../src/index";
import { getDisplaySecretKey, hashSecretKey, logger } from "../../src/server";
@@ -29,11 +30,6 @@ import {
generateEvalTraceId,
} from "./utils/seed-helpers";
type ConfigCategory = {
label: string;
value: number;
};
const options = {
environment: { type: "string" },
} as const;
@@ -110,6 +106,9 @@ async function main() {
},
});
// Realistic support chat scenario
await createSupportChatSession(project1);
await prisma.organizationMembership.upsert({
where: {
orgId_userId: {
@@ -533,23 +532,13 @@ export async function createDatasets(
const datasetItem = await prisma.datasetItem.upsert({
where: {
id_projectId: {
id: generateDatasetItemId(
datasetName,
index,
projectId,
SEED_DATASETS.indexOf(data) || 0,
),
id: generateDatasetItemId(datasetName, index, projectId),
projectId,
},
},
create: {
projectId,
id: generateDatasetItemId(
datasetName,
index,
projectId,
SEED_DATASETS.indexOf(data) || 0,
),
id: generateDatasetItemId(datasetName, index, projectId),
datasetId: dataset.id,
sourceTraceId: sourceTraceId ?? null,
sourceObservationId: null,
@@ -754,7 +743,7 @@ async function generateConfigsForProject(projects: Project[]) {
name: string;
id: string;
dataType: ScoreDataType;
categories: ConfigCategory[] | null;
categories: ScoreConfigCategoryDomain[] | null;
}[]
> = new Map();
@@ -781,12 +770,30 @@ async function createTraceSessions(project1: Project, project2: Project) {
}
}
async function createSupportChatSession(project: Project) {
const sessionId = "support-chat-session";
await prisma.traceSession.upsert({
where: {
id_projectId: {
id: sessionId,
projectId: project.id,
},
},
create: {
id: sessionId,
projectId: project.id,
environment: "default",
},
update: {},
});
}
async function generateConfigs(project: Project) {
const configNameAndId: {
name: string;
id: string;
dataType: ScoreDataType;
categories: ConfigCategory[] | null;
categories: ScoreConfigCategoryDomain[] | null;
}[] = [];
const configs = [
@@ -863,7 +870,7 @@ async function generateQueuesForProject(
name: string;
id: string;
dataType: ScoreDataType;
categories: ConfigCategory[] | null;
categories: ScoreConfigCategoryDomain[] | null;
}[]
>,
) {
@@ -887,7 +894,7 @@ async function generateQueues(
name: string;
id: string;
dataType: ScoreDataType;
categories: ConfigCategory[] | null;
categories: ScoreConfigCategoryDomain[] | null;
}[],
) {
const queue = {
@@ -34,6 +34,7 @@ import {
TraceRecordInsertType,
DatasetRunItemRecordInsertType,
createDatasetRunItem,
createDatasetRunScore,
} from "../../../src/server";
/**
@@ -106,7 +107,6 @@ export class DataGenerator {
input.datasetName,
input.itemIndex,
projectId,
input.runNumber || 0,
),
dataset_item_input: input.item.input,
dataset_item_expected_output: input.item.expectedOutput,
@@ -216,6 +216,59 @@ export class DataGenerator {
});
}
/**
* Creates scores for dataset experiment scores with variable values.
* Use for: Dataset experiments requiring detailed score tracking.
*/
generateDatasetScore(
trace: TraceRecordInsertType,
input: DatasetItemInput,
projectId: string,
scoreNames: string[],
): ScoreRecordInsertType {
const scoreId = `score-dataset-${input.datasetName}-${input.itemIndex}-${input.runNumber}-${projectId.slice(-8)}`;
return createTraceScore({
id: scoreId,
trace_id: trace.id,
project_id: projectId,
name: this.randomElement(scoreNames),
value: Math.random() * 100,
string_value: undefined,
data_type: "NUMERIC",
source: "API",
environment: "langfuse-prompt-experiments",
});
}
/**
* Creates scores for dataset experiment traces with variable values.
* Use for: Dataset experiments requiring detailed score tracking.
*/
generateDatasetRunScore(
runId: string,
input: {
datasetName: string;
runNumber: number;
},
projectId: string,
scoreNames: string[],
): ScoreRecordInsertType {
const scoreId = `score-dataset-run-${input.datasetName}-${input.runNumber}-${projectId.slice(-8)}`;
return createDatasetRunScore({
id: scoreId,
project_id: projectId,
dataset_run_id: runId,
name: this.randomElement(scoreNames),
value: Math.random() * 100,
string_value: undefined,
data_type: "NUMERIC",
source: "API",
environment: "langfuse-prompt-experiments",
});
}
/**
* Creates large-scale synthetic traces for performance testing.
* Use for: Load testing, dashboard demos, realistic usage simulation.
@@ -899,6 +952,310 @@ export class DataGenerator {
: JSON.stringify(this.fileContent.chatMlJson);
}
/**
* Creates realistic support chat session data with conversational flow.
* Use for: Demonstrating session-based conversations with tool calls and scoring.
*/
generateSupportChatSessionData(projectId: string): {
traces: TraceRecordInsertType[];
observations: ObservationRecordInsertType[];
scores: ScoreRecordInsertType[];
} {
// Domain: Art museum membership support (non-tech)
const dialogues = [
{
user: "Hi—my family membership at the City Art Museum was charged twice this month.",
assistant:
"Sorry about that. I can help. Could you share your membership ID and the last 4 digits of the card?",
},
{
user: "Membership ID is CAM-48291, card ending 4417.",
assistant: "Thanks. I'll check your billing records now.",
tool: {
name: "billing.lookup",
input: { membershipId: "CAM-48291", last4: "4417" },
output: {
duplicateCharge: true,
amounts: [95.0, 95.0],
date: "2025-08-28",
},
},
},
{
user: "Okay.",
assistant:
"I found a duplicate charge on 2025-08-28 for $95. I can issue a refund to the original payment method. Shall I proceed?",
},
{
user: "Yes, please issue the refund.",
assistant: "Processing your refund now. One moment.",
tool: {
name: "billing.refund",
input: { membershipId: "CAM-48291", amount: 95.0 },
output: { status: "success", refundId: "RFND-20931" },
},
},
{
user: "Thank you!",
assistant:
"Refund RFND-20931 has been issued. You'll see it on your statement within 35 business days.",
},
{
user: "No, that's all. Appreciate the quick help!",
assistant: "Happy to help. Enjoy your next visit to the museum!",
},
// a couple more lightweight turns for scrolling realism
{
user: "Oh, and do members get early access to exhibitions?",
assistant:
"Yes—members get a 48-hour early booking window and a preview evening invite.",
},
{
user: "Perfect.",
assistant: "You're all set. Have a great day!",
},
];
const now = Date.now();
const traces: TraceRecordInsertType[] = dialogues.map((d, index) => ({
id: `support-chat-${index}-${projectId.slice(-8)}`,
timestamp: now + index * 1000,
name: "SupportChatSession",
user_id: null,
metadata: { scenario: "support-chat" },
release: null,
version: null,
project_id: projectId,
environment: "default",
public: false,
bookmarked: false,
tags: ["support", "chat", "session"],
input: JSON.stringify(
d.tool
? {
messages: [
{ role: "user", content: d.user },
{ role: "assistant", content: d.assistant },
{
role: "tool",
name: d.tool.name,
content: d.tool.output,
},
],
}
: { messages: [{ role: "user", content: d.user }] },
),
output: JSON.stringify({ role: "assistant", content: d.assistant }),
session_id: "support-chat-session",
created_at: now + index * 1000,
updated_at: now + index * 1000 + 500,
event_ts: now + index * 1000,
is_deleted: 0,
}));
// Create one GENERATION observation per trace
const observations: ObservationRecordInsertType[] = dialogues
.map((d, index) => {
const start = now + index * 1000 + 50;
const end = start + 400 + Math.floor(Math.random() * 400);
const inputTokens = 80 + Math.floor(Math.random() * 60);
const outputTokens = 60 + Math.floor(Math.random() * 60);
const totalTokens = inputTokens + outputTokens;
const baseGen: ObservationRecordInsertType = {
id: `support-chat-${index}-${projectId.slice(-8)}-gen`,
trace_id: `support-chat-${index}-${projectId.slice(-8)}`,
project_id: projectId,
type: "GENERATION",
parent_observation_id: null,
environment: "default",
start_time: start,
end_time: end,
name: "llm-generation",
metadata: {},
level: "DEFAULT",
status_message: null,
version: null,
input: JSON.stringify({
messages: [
{ role: "user", content: d.user },
d.tool
? {
role: "tool",
name: d.tool.name,
content: d.tool.output,
}
: undefined,
].filter(Boolean),
}),
output: JSON.stringify({ role: "assistant", content: d.assistant }),
provided_model_name: "gpt-4o",
internal_model_id: null,
model_parameters: JSON.stringify({ temperature: 0.2 }),
provided_usage_details: {
input: inputTokens,
output: outputTokens,
total: totalTokens,
},
usage_details: {
input: inputTokens,
output: outputTokens,
total: totalTokens,
},
provided_cost_details: {
input: Math.round(inputTokens * 2) / 1_000_000,
output: Math.round(outputTokens * 3) / 1_000_000,
total: Math.round(totalTokens * 5) / 1_000_000,
},
cost_details: {
input: Math.round(inputTokens * 2) / 1_000_000,
output: Math.round(outputTokens * 3) / 1_000_000,
total: Math.round(totalTokens * 5) / 1_000_000,
},
total_cost: Math.round(totalTokens * 5) / 1_000_000,
completion_start_time: start + 120,
prompt_id: null,
prompt_name: null,
prompt_version: null,
created_at: start,
updated_at: end,
event_ts: start,
is_deleted: 0,
};
if (!d.tool) return [baseGen];
const toolObs: ObservationRecordInsertType = {
id: `support-chat-${index}-${projectId.slice(-8)}-tool`,
trace_id: `support-chat-${index}-${projectId.slice(-8)}`,
project_id: projectId,
type: "TOOL",
parent_observation_id: null,
environment: "default",
start_time: start - 40,
end_time: start - 5,
name: d.tool.name,
metadata: {},
level: "DEFAULT",
status_message: null,
version: null,
input: JSON.stringify(d.tool.input),
output: JSON.stringify(d.tool.output),
provided_model_name: null,
internal_model_id: null,
model_parameters: null,
provided_usage_details: {},
usage_details: {},
provided_cost_details: {},
cost_details: {},
total_cost: null,
completion_start_time: null,
prompt_id: null,
prompt_name: null,
prompt_version: null,
created_at: start - 40,
updated_at: start - 5,
event_ts: start - 40,
is_deleted: 0,
};
return [toolObs, baseGen];
})
.flat();
// Create a couple of scores per trace
const scores: ScoreRecordInsertType[] = dialogues
.map((_, index) => {
const baseTs = now + index * 1000 + 600;
const helpfulness: ScoreRecordInsertType = {
id: `support-chat-${index}-${projectId.slice(-8)}-score-helpfulness`,
project_id: projectId,
trace_id: `support-chat-${index}-${projectId.slice(-8)}`,
session_id: null,
dataset_run_id: null,
observation_id: null,
environment: "default",
name: "helpfulness",
value: 70 + Math.random() * 25,
source: "API",
comment: "Heuristic helpfulness score",
metadata: {},
author_user_id: null,
config_id: null,
data_type: "NUMERIC",
string_value: null,
queue_id: null,
created_at: baseTs,
updated_at: baseTs,
timestamp: baseTs,
event_ts: baseTs,
is_deleted: 0,
};
const safeVal = Math.random() > 0.1 ? 1 : 0;
const safety: ScoreRecordInsertType = {
id: `support-chat-${index}-${projectId.slice(-8)}-score-safety`,
project_id: projectId,
trace_id: `support-chat-${index}-${projectId.slice(-8)}`,
session_id: null,
dataset_run_id: null,
observation_id: null,
environment: "default",
name: "safe",
value: safeVal,
source: "API",
comment: "Content safety",
metadata: {},
author_user_id: null,
config_id: null,
data_type: "BOOLEAN",
string_value: safeVal === 1 ? "true" : "false",
queue_id: null,
created_at: baseTs + 10,
updated_at: baseTs + 10,
timestamp: baseTs + 10,
event_ts: baseTs + 10,
is_deleted: 0,
};
// Optional: resolution score on last turn
const isFinal = index === dialogues.length - 1;
const resolved: ScoreRecordInsertType | null = isFinal
? {
id: `support-chat-${index}-${projectId.slice(-8)}-score-resolved`,
project_id: projectId,
trace_id: `support-chat-${index}-${projectId.slice(-8)}`,
session_id: null,
dataset_run_id: null,
observation_id: null,
environment: "default",
name: "resolved",
value: 1,
source: "API",
comment: "Conversation resolved",
metadata: {},
author_user_id: null,
config_id: null,
data_type: "BOOLEAN",
string_value: "true",
queue_id: null,
created_at: baseTs + 20,
updated_at: baseTs + 20,
timestamp: baseTs + 20,
event_ts: baseTs + 20,
is_deleted: 0,
}
: null;
return [helpfulness, safety, resolved].filter(
Boolean,
) as ScoreRecordInsertType[];
})
.flat();
return { traces, observations, scores };
}
/**
* Creates exactly one score per evaluation trace with prefixed IDs.
* Use for: Evaluation traces that need score validation, evaluator testing.
@@ -11,9 +11,8 @@ export const generateDatasetItemId = (
datasetName: string,
itemIndex: number,
projectId: string,
runNumber: number,
) => {
return `dataset-item-${datasetName}-${itemIndex}-${projectId.slice(-8)}-${runNumber}`;
return `dataset-item-${datasetName}-${itemIndex}-${projectId.slice(-8)}-0`;
};
export const generateDatasetRunTraceId = (
@@ -7,11 +7,19 @@ import {
DatasetRunItemRecordInsertType,
logger,
ObservationRecordInsertType,
ScoreRecordInsertType,
TraceRecordInsertType,
} from "../../../src/server";
import path from "path";
import { readFileSync } from "fs";
const DATASET_SCORE_NAMES = ["score-1", "score-2", "score-3"];
const DATASET_RUN_SCORE_NAMES = [
"dataset-run-score-1",
"dataset-run-score-2",
"dataset-run-score-3",
];
/**
* Orchestrates seeding operations across ClickHouse and PostgreSQL.
*
@@ -98,6 +106,7 @@ export class SeederOrchestrator {
const traces: TraceRecordInsertType[] = [];
const observations: ObservationRecordInsertType[] = [];
const datasetRunItems: DatasetRunItemRecordInsertType[] = [];
const scores: ScoreRecordInsertType[] = [];
for (const seedDataset of SEED_DATASETS) {
for (const [itemIndex, datasetItem] of seedDataset.items.entries()) {
@@ -136,16 +145,43 @@ export class SeederOrchestrator {
projectId,
);
// Generate score data
const score = this.dataGenerator.generateDatasetScore(
trace,
{
datasetName: seedDataset.name,
itemIndex,
item: datasetItem,
runNumber,
},
projectId,
DATASET_SCORE_NAMES,
);
traces.push(trace);
observations.push(observation);
datasetRunItems.push(datasetRunItem);
scores.push(score);
}
// create dataset run level scores
const datasetRunScore = this.dataGenerator.generateDatasetRunScore(
`${seedDataset.name}-${projectId.slice(-8)}`,
{
datasetName: seedDataset.name,
runNumber,
},
projectId,
DATASET_RUN_SCORE_NAMES,
);
scores.push(datasetRunScore);
}
try {
await this.queryBuilder.executeTracesInsert(traces);
await this.queryBuilder.executeObservationsInsert(observations);
await this.queryBuilder.executeDatasetRunItemsInsert(datasetRunItems);
await this.queryBuilder.executeScoresInsert(scores);
} catch (error) {
logger.error(`✗ Insert failed:`, error);
throw error;
@@ -285,6 +321,9 @@ export class SeederOrchestrator {
// Create synthetic data
await this.createSyntheticData(projectIds, opts);
// Create traces for a realistic chat session
await this.createSupportChatSessionTraces(projectIds);
// Log completion statistics (commented out to reduce terminal noise)
await this.logStatistics();
@@ -342,4 +381,27 @@ export class SeederOrchestrator {
}
}
}
async createSupportChatSessionTraces(projectIds: string[]): Promise<void> {
logger.info(
`Creating support chat session data for ${projectIds.length} projects.`,
);
for (const projectId of projectIds) {
logger.info(`Processing support chat session for project ${projectId}`);
// Generate data using the data generator
const { traces, observations, scores } =
this.dataGenerator.generateSupportChatSessionData(projectId);
try {
await this.queryBuilder.executeTracesInsert(traces);
await this.queryBuilder.executeObservationsInsert(observations);
await this.queryBuilder.executeScoresInsert(scores);
} catch (error) {
logger.error(`✗ Support chat session insert failed:`, error);
throw error;
}
}
}
}
@@ -25,4 +25,14 @@ export const DatasetRunItemSchema = z.object({
datasetItemMetadata: MetadataDomain,
});
export type DatasetRunItemDomain = z.infer<typeof DatasetRunItemSchema>;
// Conditional type for dataset run item domain with optional IO
export type DatasetRunItemDomain<WithIO extends boolean = true> =
WithIO extends true
? z.infer<typeof DatasetRunItemSchema>
: Omit<
z.infer<typeof DatasetRunItemSchema>,
| "datasetRunMetadata"
| "datasetItemInput"
| "datasetItemExpectedOutput"
| "datasetItemMetadata"
>;
+121
View File
@@ -0,0 +1,121 @@
import { z } from "zod/v4";
import { isPresent } from "../utils/typeChecks";
// Category type, used for categorical and boolean configs
export const ScoreConfigCategory = z.object({
label: z.string().min(1),
value: z.number(),
});
// Numeric config fields
export const NumericConfigFields = z.object({
maxValue: z.number().nullish(),
minValue: z.number().nullish(),
dataType: z.literal("NUMERIC"),
categories: z.undefined().nullish(),
});
// Boolean config fields
export const BooleanConfigFields = z.object({
dataType: z.literal("BOOLEAN"),
maxValue: z.number().nullish(),
minValue: z.number().nullish(),
categories: z
.array(ScoreConfigCategory)
.length(2, "Boolean data type must have exactly 2 categories.")
.refine((categories) => {
const expectedCategories = [
{ label: "True", value: 1 },
{ label: "False", value: 0 },
];
return categories.every(
(category, index) =>
category.label === expectedCategories[index].label &&
category.value === expectedCategories[index].value,
);
}),
});
// Category config fields and types
export const validateCategories = (
categories: z.infer<typeof ScoreConfigCategory>[],
ctx: z.RefinementCtx,
) => {
const uniqueNames = new Set<string>();
const uniqueValues = new Set<number>();
for (const category of categories) {
if (uniqueNames.has(category.label)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate category label: ${category.label}, category labels must be unique`,
});
return;
}
uniqueNames.add(category.label);
if (uniqueValues.has(category.value)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate category value: ${category.value}, category values must be unique`,
});
return;
}
uniqueValues.add(category.value);
}
};
export const CategoricalConfigFields = z.object({
maxValue: z.undefined().nullish(),
minValue: z.undefined().nullish(),
dataType: z.literal("CATEGORICAL"),
categories: z.array(ScoreConfigCategory).superRefine(validateCategories),
});
const ScoreConfigBase = z.object({
id: z.string(),
name: z.string().min(1).max(35),
isArchived: z.boolean(),
description: z.string().nullish(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
projectId: z.string(),
});
export const validateNumericRangeFields = (
data: Pick<ScoreConfigDomain, "maxValue" | "minValue" | "dataType">,
ctx: z.RefinementCtx,
): void | Promise<void> => {
if (data.dataType === "NUMERIC") {
if (
isPresent(data.maxValue) &&
isPresent(data.minValue) &&
data.maxValue <= data.minValue
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Maximum value must be greater than Minimum value",
});
}
}
};
export const ScoreConfigSchema = z
.discriminatedUnion("dataType", [
z.object({
...ScoreConfigBase.shape,
...NumericConfigFields.shape,
}),
z.object({
...ScoreConfigBase.shape,
...CategoricalConfigFields.shape,
}),
z.object({
...ScoreConfigBase.shape,
...BooleanConfigFields.shape,
}),
])
.superRefine(validateNumericRangeFields);
export type ScoreConfigDomain = z.infer<typeof ScoreConfigSchema>;
export type ScoreConfigCategoryDomain = z.infer<typeof ScoreConfigCategory>;
+1 -1
View File
@@ -1,6 +1,6 @@
import { ScoreDataType } from "@prisma/client";
import z from "zod/v4";
import { MetadataDomain } from "./traces";
import { ScoreDataType } from "@prisma/client";
export const ScoreSource = {
ANNOTATION: "ANNOTATION",
+15 -3
View File
@@ -38,6 +38,7 @@ const EnvSchema = z.object({
LANGFUSE_CACHE_PROMPT_ENABLED: z.enum(["true", "false"]).default("true"),
LANGFUSE_CACHE_PROMPT_TTL_SECONDS: z.coerce.number().default(300), // 5 minutes
CLICKHOUSE_URL: z.string().url(),
CLICKHOUSE_READ_ONLY_URL: z.string().url().optional(),
CLICKHOUSE_CLUSTER_NAME: z.string().default("default"),
CLICKHOUSE_DB: z.string().default("default"),
CLICKHOUSE_USER: z.string(),
@@ -156,9 +157,6 @@ const EnvSchema = z.object({
LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT_SHORT_TERM: z
.enum(["true", "false"])
.default("false"),
LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES: z
.enum(["true", "false"])
.default("false"),
LANGFUSE_EXPERIMENT_WHITELISTED_AMT_TABLES: z
.string()
.optional()
@@ -197,6 +195,12 @@ const EnvSchema = z.object({
return new Map<string, number>();
}
}),
LANGFUSE_WEBHOOK_WHITELISTED_IPS: z
.string()
.optional()
.transform((s) =>
s ? s.split(",").map((s) => s.toLowerCase().trim()) : [],
),
SLACK_CLIENT_ID: z.string().optional(),
SLACK_CLIENT_SECRET: z.string().optional(),
SLACK_STATE_SECRET: z.string().optional(),
@@ -227,6 +231,14 @@ const EnvSchema = z.object({
.int()
.positive()
.default(120_000), // 2 minutes
LANGFUSE_AWS_BEDROCK_REGION: z.string().optional(),
// Langfuse AI Features
LANGFUSE_AI_FEATURES_PUBLIC_KEY: z.string().optional(),
LANGFUSE_AI_FEATURES_SECRET_KEY: z.string().optional(),
LANGFUSE_AI_FEATURES_HOST: z.string().optional(),
LANGFUSE_AI_FEATURES_PROJECT_ID: z.string().optional(),
});
export const env: z.infer<typeof EnvSchema> =
@@ -0,0 +1 @@
export * from "./validation";
@@ -0,0 +1,49 @@
import { z } from "zod/v4";
import { ScoreConfig as ScoreConfigDbType } from "@prisma/client";
import {
ScoreConfigDomain,
ScoreConfigSchema,
} from "../../domain/score-configs";
/**
* Use this function when pulling a list of score configs from the database before using in the application to ensure type safety.
* All score configs are expected to pass the validation. If a score fails validation, it will be logged to Otel.
* @param scoreConfigs
* @returns list of validated score configs
*/
export const filterAndValidateDbScoreConfigList = (
scoreConfigs: ScoreConfigDbType[],
onParseError?: (error: z.ZodError) => void, // eslint-disable-line no-unused-vars
): ScoreConfigDomain[] =>
scoreConfigs.reduce((acc, ts) => {
const result = ScoreConfigSchema.safeParse(ts);
if (result.success) {
acc.push(result.data);
} else {
onParseError?.(result.error);
}
return acc;
}, [] as ScoreConfigDomain[]);
/**
* Use this function when pulling a single score config from the database before using in the application to ensure type safety.
* The score is expected to pass the validation. If a score fails validation, an error will be thrown.
* @param scoreConfig
* @returns validated score config
* @throws error if score fails validation
*/
export const validateDbScoreConfig = (
scoreConfig: ScoreConfigDbType,
): ScoreConfigDomain => ScoreConfigSchema.parse(scoreConfig);
/**
* Use this function when pulling a single score config from the database before using in the application to ensure type safety.
* This function will NOT throw an error by default. The score is expected to pass the validation.
* @param scoreConfig
* @returns score config validation object:
* - success: true if the score config passes validation
* - data: the validated score config if success is true
* - error: the error object if success is false
*/
export const validateDbScoreConfigSafe = (scoreConfig: ScoreConfigDbType) =>
ScoreConfigSchema.safeParse(scoreConfig);
@@ -1,2 +1 @@
export * from "./interfaces";
export * from "./scoreConfigTypes";
@@ -8,7 +8,9 @@ export const GetScoreQueryV2 = GetScoreQuery;
export const GetScoreResponseV2 = APIScoreSchemaV2;
// GET /scores v2
export const GetScoresQueryV2 = GetScoresQuery;
export const GetScoresQueryV2 = GetScoresQuery.extend({
sessionId: z.string().nullish(),
});
export const GetScoreResponseDataV2 = z.intersection(
APIScoreSchemaV2,
z.object({
@@ -2,7 +2,7 @@ import z from "zod/v4";
import { applyScoreValidation } from "../../../../utils/scores";
import { PostScoreBodyFoundationSchema } from "../shared";
import { isPresent } from "../../../../utils/typeChecks";
import { Category as ConfigCategory } from "../../scoreConfigTypes";
import { ScoreConfigCategory } from "../../../../domain/score-configs";
export const ScoreBodyWithoutConfig = applyScoreValidation(
z.discriminatedUnion("dataType", [
@@ -54,7 +54,7 @@ const ScorePropsAgainstConfigNumeric = z
const ScorePropsAgainstConfigCategorical = z
.object({
value: z.string(),
categories: z.array(ConfigCategory),
categories: z.array(ScoreConfigCategory),
dataType: z.literal("CATEGORICAL"),
})
.superRefine((data, ctx) => {
@@ -1,236 +0,0 @@
import { z } from "zod/v4";
import { ScoreConfig as ScoreConfigDbType } from "@prisma/client";
import { isPresent } from "../../utils/typeChecks";
import {
jsonSchema,
paginationMetaResponseZod,
publicApiPaginationZod,
} from "../../utils/zod";
/**
* Types to use across codebase
*/
export type ConfigCategory = z.infer<typeof Category>;
export type ValidatedScoreConfig = z.infer<typeof ValidatedScoreConfigSchema>;
const validateCategories = (
categories: ConfigCategory[],
ctx: z.RefinementCtx,
) => {
const uniqueNames = new Set<string>();
const uniqueValues = new Set<number>();
for (const category of categories) {
if (uniqueNames.has(category.label)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate category label: ${category.label}, category labels must be unique`,
});
return;
}
uniqueNames.add(category.label);
if (uniqueValues.has(category.value)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate category value: ${category.value}, category values must be unique`,
});
return;
}
uniqueValues.add(category.value);
}
};
/**
* Objects
*/
export const Category = z.object({
label: z.string().min(1),
value: z.number(),
});
const Categories = z.array(Category);
const NumericScoreConfig = z.object({
maxValue: z.number().nullish(),
minValue: z.number().nullish(),
dataType: z.literal("NUMERIC"),
categories: z.undefined().nullish(),
});
const CategoricalScoreConfig = z.object({
maxValue: z.undefined().nullish(),
minValue: z.undefined().nullish(),
dataType: z.literal("CATEGORICAL"),
categories: jsonSchema.superRefine((categories, ctx) => {
const parseResult = Categories.safeParse(categories);
if (!parseResult.success) {
ctx.addIssue({
code: "custom",
message:
"Category must be an array of objects with label value pairs, where labels and values are unique.",
} as z.core.$ZodIssueCustom);
return;
}
validateCategories(categories as ConfigCategory[], ctx);
}),
});
const BooleanScoreConfig = z.object({
maxValue: z.undefined().nullish(),
minValue: z.undefined().nullish(),
dataType: z.literal("BOOLEAN"),
categories: z
.array(Category)
.length(2, "Boolean data type must have exactly 2 categories.")
.refine((categories) => {
const expectedCategories = [
{ label: "True", value: 1 },
{ label: "False", value: 0 },
];
return categories.every(
(category, index) =>
category.label === expectedCategories[index].label &&
category.value === expectedCategories[index].value,
);
}),
});
const ScoreConfigBase = z.object({
id: z.string(),
name: z.string().min(1).max(35),
isArchived: z.boolean(),
description: z.string().nullish(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
projectId: z.string(),
});
const ScoreConfigPostBase = z.object({
name: z.string(),
description: z.string().optional(),
});
const ValidatedScoreConfigSchema = z
.union([
ScoreConfigBase.merge(NumericScoreConfig),
ScoreConfigBase.merge(
z.object({
maxValue: z.undefined().nullish(),
minValue: z.undefined().nullish(),
dataType: z.literal("CATEGORICAL"),
categories: Categories.superRefine(validateCategories),
}),
),
ScoreConfigBase.merge(BooleanScoreConfig),
])
.superRefine((data, ctx) => {
if (data.dataType === "NUMERIC") {
if (
isPresent(data.maxValue) &&
isPresent(data.minValue) &&
data.maxValue <= data.minValue
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Maximum value must be greater than Minimum value",
});
}
}
});
/**
* Use this function when pulling a list of score configs from the database before using in the application to ensure type safety.
* All score configs are expected to pass the validation. If a score fails validation, it will be logged to Otel.
* @param scoreConfigs
* @returns list of validated score configs
*/
export const filterAndValidateDbScoreConfigList = (
scoreConfigs: ScoreConfigDbType[],
onParseError?: (error: z.ZodError) => void, // eslint-disable-line no-unused-vars
): ValidatedScoreConfig[] =>
scoreConfigs.reduce((acc, ts) => {
const result = ValidatedScoreConfigSchema.safeParse(ts);
if (result.success) {
acc.push(result.data);
} else {
onParseError?.(result.error);
}
return acc;
}, [] as ValidatedScoreConfig[]);
/**
* Use this function when pulling a single score config from the database before using in the application to ensure type safety.
* The score is expected to pass the validation. If a score fails validation, an error will be thrown.
* @param scoreConfig
* @returns validated score config
* @throws error if score fails validation
*/
export const validateDbScoreConfig = (
scoreConfig: ScoreConfigDbType,
): ValidatedScoreConfig => ValidatedScoreConfigSchema.parse(scoreConfig);
/**
* Use this function when pulling a single score config from the database before using in the application to ensure type safety.
* This function will NOT throw an error by default. The score is expected to pass the validation.
* @param scoreConfig
* @returns score config validation object:
* - success: true if the score config passes validation
* - data: the validated score config if success is true
* - error: the error object if success is false
*/
export const validateDbScoreConfigSafe = (scoreConfig: ScoreConfigDbType) =>
ValidatedScoreConfigSchema.safeParse(scoreConfig);
/**
* Endpoints
*/
// GET /score-configs/{configId}
export const GetScoreConfigQuery = z.object({
configId: z.string(),
});
export const GetScoreConfigResponse = ValidatedScoreConfigSchema;
// POST /score-configs
export const PostScoreConfigBody = z
.discriminatedUnion("dataType", [
ScoreConfigPostBase.merge(CategoricalScoreConfig),
ScoreConfigPostBase.merge(NumericScoreConfig),
ScoreConfigPostBase.merge(
z.object({
dataType: z.literal("BOOLEAN"),
categories: z.undefined().nullish(),
}),
),
])
.superRefine((data, ctx) => {
if (data.dataType === "NUMERIC") {
if (
isPresent(data.maxValue) &&
isPresent(data.minValue) &&
data.maxValue <= data.minValue
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Maximum value must be greater than Minimum value",
});
}
}
});
export const PostScoreConfigResponse = ValidatedScoreConfigSchema;
// GET /score-configs
export const GetScoreConfigsQuery = z.object({
...publicApiPaginationZod,
});
export const GetScoreConfigsResponse = z.object({
data: z.array(ValidatedScoreConfigSchema),
meta: paginationMetaResponseZod,
});
+4
View File
@@ -19,6 +19,7 @@ export * from "./interfaces/rate-limits";
export * from "./tableDefinitions/typeHelpers";
export * from "./domain/webhooks";
export * from "./domain/dataset-run-items";
export * from "./domain/score-configs";
// llm api
export * from "./server/llm/types";
@@ -37,6 +38,9 @@ export * from "./features/annotation/types";
// scores
export * from "./features/scores";
// score configs
export * from "./features/scoreConfigs";
// comments
export * from "./features/comments/types";
@@ -13,7 +13,14 @@ export const CloudConfigSchema = z.object({
customerId: z.string().optional(),
activeSubscriptionId: z.string().optional(),
activeProductId: z.string().optional(),
activeUsageProductId: z.string().optional(),
})
.transform((data) => ({
...data,
isLegacySubscription:
data?.activeProductId !== undefined &&
data?.activeUsageProductId === undefined,
}))
.optional(),
// custom rate limits for an organization
+42 -19
View File
@@ -6,6 +6,8 @@ import { propagation, context } from "@opentelemetry/api";
export type ClickhouseClientType = ReturnType<typeof createClient>;
export type PreferredClickhouseService = "ReadWrite" | "ReadOnly";
/**
* ClickHouseClientManager provides a singleton pattern for managing ClickHouse clients.
* It creates and reuses clients based on their configuration to avoid creating
@@ -35,22 +37,40 @@ export class ClickHouseClientManager {
* @param opts Client parameters
* @returns String hash key
*/
private generateClientSettingsKey(
private generateClientSettings(
opts: NodeClickHouseClientConfigOptions,
): string {
preferredClickhouseService: PreferredClickhouseService = "ReadWrite",
): NodeClickHouseClientConfigOptions {
const keyParams = {
url: env.CLICKHOUSE_URL,
url: this.getClickhouseUrl(preferredClickhouseService),
username: env.CLICKHOUSE_USER,
password: env.CLICKHOUSE_PASSWORD,
database: env.CLICKHOUSE_DB,
http_headers: opts?.http_headers,
http_headers: opts?.http_headers ?? {},
settings: opts?.clickhouse_settings,
request_timeout: opts?.request_timeout ?? 30000,
...(opts.request_timeout
? { request_timeout: opts.request_timeout }
: {}),
// Include any other relevant config options
};
return JSON.stringify(keyParams);
return keyParams;
}
private generateClientSettingsKey(
settings: NodeClickHouseClientConfigOptions,
): string {
return JSON.stringify(settings);
}
private getClickhouseUrl = (
preferredClickhouseService: PreferredClickhouseService,
) => {
return preferredClickhouseService === "ReadWrite"
? env.CLICKHOUSE_URL
: env.CLICKHOUSE_READ_ONLY_URL || env.CLICKHOUSE_URL;
};
/**
* Get or create a client based on the provided parameters
* @param opts Client configuration parameters
@@ -58,13 +78,17 @@ export class ClickHouseClientManager {
*/
public getClient(
opts: NodeClickHouseClientConfigOptions,
preferredClickhouseService: PreferredClickhouseService = "ReadWrite",
): ClickhouseClientType {
const key = this.generateClientSettingsKey(opts);
const settings = this.generateClientSettings(
opts,
preferredClickhouseService,
);
const key = this.generateClientSettingsKey(settings);
if (!this.clientMap.has(key)) {
const headers = opts?.http_headers ?? {};
const activeSpan = getCurrentSpan();
if (activeSpan) {
propagation.inject(context.active(), headers);
propagation.inject(context.active(), settings.http_headers);
}
const cloudOptions: Record<string, unknown> = {};
@@ -78,11 +102,7 @@ export class ClickHouseClientManager {
const client = createClient({
...opts,
url: env.CLICKHOUSE_URL,
username: env.CLICKHOUSE_USER,
password: env.CLICKHOUSE_PASSWORD,
database: env.CLICKHOUSE_DB,
http_headers: headers,
...settings,
keep_alive: {
idle_socket_ttl: env.CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL,
},
@@ -99,9 +119,6 @@ export class ClickHouseClientManager {
}
: {}),
},
...(opts.request_timeout
? { request_timeout: opts.request_timeout }
: {}),
});
this.clientMap.set(key, client);
@@ -122,8 +139,14 @@ export class ClickHouseClientManager {
}
}
export const clickhouseClient = (opts?: NodeClickHouseClientConfigOptions) => {
return ClickHouseClientManager.getInstance().getClient(opts ?? {});
export const clickhouseClient = (
opts?: NodeClickHouseClientConfigOptions,
preferredClickhouseService: PreferredClickhouseService = "ReadWrite",
) => {
return ClickHouseClientManager.getInstance().getClient(
opts ?? {},
preferredClickhouseService,
);
};
/**
@@ -1,7 +1,6 @@
import { randomUUID } from "crypto";
import { z } from "zod/v4";
import { type Model } from "../../db";
import { env } from "../../env";
import {
InvalidRequestError,
@@ -50,12 +49,6 @@ const getS3StorageServiceClient = (bucketName: string): StorageService => {
return s3StorageServiceClient;
};
// eslint-disable-next-line no-unused-vars
export type TokenCountDelegate = (p: {
model: Model;
text: unknown;
}) => number | undefined;
/**
* Get the delay for the event based on the event type. Uses delay if set, 0 if current UTC timestamp is not between
* 23:45 and 00:15, and env.LANGFUSE_INGESTION_QUEUE_DELAY_MS otherwise.
@@ -115,6 +108,9 @@ export const processEventBatch = async (
error?: string;
}[];
}> => {
if (input.length === 0) {
return { successes: [], errors: [] };
}
const { delay = null, source = "api", isLangfuseInternal = false } = options;
// add context of api call to the span
@@ -1,23 +1,23 @@
import {
ScoreBodyWithoutConfig,
ScoreConfigDomain,
ScoreDomain,
ScorePropsAgainstConfig,
validateDbScoreConfigSafe,
ValidatedScoreConfig,
} from "../../../src";
import { prisma, ScoreDataType } from "../../db";
import { InvalidRequestError, LangfuseNotFoundError } from "../../errors";
import { validateDbScoreConfigSafe } from "../../features/scoreConfigs/validation";
import { ScoreEventType } from "./types";
type ValidateAndInflateScoreParams = {
projectId: string;
scoreId: string;
body: any;
body: ScoreEventType["body"];
};
export async function validateAndInflateScore(
params: ValidateAndInflateScoreParams,
): Promise<ScoreDomain> {
) {
const { body, projectId, scoreId } = params;
if (body.configId) {
@@ -40,16 +40,17 @@ export async function validateAndInflateScore(
name: config.name,
};
validateConfigAgainstBody(
bodyWithConfigOverrides,
config as ValidatedScoreConfig,
);
validateConfigAgainstBody({
body: bodyWithConfigOverrides,
config: config as ScoreConfigDomain,
context: "INGESTION",
});
return inflateScoreBody({
projectId,
scoreId,
body: bodyWithConfigOverrides,
config: config as ValidatedScoreConfig,
config: config as ScoreConfigDomain,
});
}
@@ -74,7 +75,7 @@ function inferDataType(value: string | number): ScoreDataType {
}
function mapStringValueToNumericValue(
config: ValidatedScoreConfig,
config: ScoreConfigDomain,
label: string,
): number | null {
return (
@@ -84,12 +85,17 @@ function mapStringValueToNumericValue(
}
function inflateScoreBody(
params: ValidateAndInflateScoreParams & { config?: ValidatedScoreConfig },
): ScoreDomain {
params: ValidateAndInflateScoreParams & { config?: ScoreConfigDomain },
) {
const { body, projectId, scoreId, config } = params;
const relevantDataType = config?.dataType ?? body.dataType;
const scoreProps = { source: "API", ...body, id: scoreId, projectId };
const scoreProps = {
...body,
source: body.source ?? "API",
id: scoreId,
projectId,
};
if (typeof body.value === "number") {
if (relevantDataType && relevantDataType === ScoreDataType.BOOLEAN) {
@@ -104,6 +110,7 @@ function inflateScoreBody(
return {
...scoreProps,
value: body.value,
stringValue: null,
dataType: ScoreDataType.NUMERIC,
};
}
@@ -116,10 +123,43 @@ function inflateScoreBody(
};
}
function validateConfigAgainstBody(
body: any,
config: ValidatedScoreConfig,
): void {
type ScoreBodyWithContext =
| {
body: ScoreEventType["body"];
context: "INGESTION";
}
| {
body: ScoreDomain;
context: "ANNOTATION";
};
function resolveScoreValueIngestion(
body: ScoreEventType["body"],
): string | number | null {
return body.value;
}
function resolveScoreValueAnnotation(
body: ScoreDomain,
): string | number | null {
switch (body.dataType) {
case ScoreDataType.NUMERIC:
case ScoreDataType.BOOLEAN:
return body.value;
case ScoreDataType.CATEGORICAL:
return body.stringValue;
}
}
type ValidateConfigAgainstBodyParams = {
config: ScoreConfigDomain;
} & ScoreBodyWithContext;
export function validateConfigAgainstBody({
body,
config,
context,
}: ValidateConfigAgainstBodyParams): void {
const { maxValue, minValue, categories, dataType: configDataType } = config;
if (body.dataType && body.dataType !== configDataType) {
@@ -141,20 +181,13 @@ function validateConfigAgainstBody(
}
const relevantDataType = configDataType ?? body.dataType;
const dataTypeValidation = ScoreBodyWithoutConfig.safeParse({
...body,
dataType: relevantDataType,
});
if (!dataTypeValidation.success) {
throw new InvalidRequestError(
`Ingested score body not valid against provided config data type.`,
);
}
const scoreValue =
context === "INGESTION"
? resolveScoreValueIngestion(body)
: resolveScoreValueAnnotation(body);
const rangeValidation = ScorePropsAgainstConfig.safeParse({
value: body.value,
value: scoreValue,
dataType: relevantDataType,
...(maxValue !== null && maxValue !== undefined && { maxValue }),
...(minValue !== null && minValue !== undefined && { minValue }),
@@ -25,6 +25,7 @@ import GCPServiceAccountKeySchema, {
BedrockCredentialSchema,
VertexAIConfigSchema,
BEDROCK_USE_DEFAULT_CREDENTIALS,
BedrockCredential,
} from "../../interfaces/customLLMProviderConfigSchemas";
import { processEventBatch } from "../ingestion/processEventBatch";
import { logger } from "../logger";
@@ -32,10 +33,12 @@ import {
ChatMessage,
ChatMessageRole,
ChatMessageType,
isOpenAIReasoningModel,
LLMAdapter,
LLMJSONSchema,
LLMToolDefinition,
ModelParams,
OpenAIModel,
ToolCallResponse,
ToolCallResponseSchema,
TraceParams,
@@ -46,8 +49,56 @@ import { HttpsProxyAgent } from "https-proxy-agent";
const isLangfuseCloud = Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
const PROVIDERS_WITH_REQUIRED_USER_MESSAGE = [
LLMAdapter.VertexAI,
LLMAdapter.Anthropic,
];
const resolveBedrockCredentials = (
apiKey: string,
context: LLMCompletionContext,
): BedrockCredential | undefined => {
// Default credentials can only be used in self-hosted deployments or when credentials are managed by langfuse
const canUseDefaultCredentials =
!isLangfuseCloud || context.credentials === "langfuse";
return apiKey === BEDROCK_USE_DEFAULT_CREDENTIALS && canUseDefaultCredentials
? undefined // undefined = use AWS SDK default credential provider chain
: BedrockCredentialSchema.parse(JSON.parse(apiKey));
};
const resolveBedrockRegion = (
context: LLMCompletionContext,
config?: Record<string, string> | null,
): string | undefined => {
if (context.credentials === "langfuse") {
return env.LANGFUSE_AWS_BEDROCK_REGION ?? undefined;
}
const { region } = BedrockConfigSchema.parse(config);
return region;
};
const transformSystemMessageToUserMessage = (
messages: ChatMessage[],
): BaseMessage[] => {
const safeContent =
typeof messages[0].content === "string"
? messages[0].content
: JSON.stringify(messages[0].content);
return [new HumanMessage(safeContent)];
};
type ProcessTracedEvents = () => Promise<void>;
type LLMCompletionContext = {
tracing: "langfuse";
credentials: "user" | "langfuse";
};
const DEFAULT_LLM_COMPLETION_CONTEXT: LLMCompletionContext = {
tracing: "langfuse",
credentials: "user",
};
type LLMCompletionParams = {
messages: ChatMessage[];
modelParams: ModelParams;
@@ -60,6 +111,7 @@ type LLMCompletionParams = {
config?: Record<string, string> | null;
traceParams?: TraceParams;
throwOnError?: boolean; // default is true
context?: LLMCompletionContext;
};
type FetchLLMCompletionParams = LLMCompletionParams & {
@@ -133,33 +185,57 @@ export async function fetchLLMCompletion(
traceParams,
extraHeaders,
throwOnError = true,
context = DEFAULT_LLM_COMPLETION_CONTEXT,
} = params;
let finalCallbacks: BaseCallbackHandler[] | undefined = callbacks ?? [];
let processTracedEvents: ProcessTracedEvents = () => Promise.resolve();
if (traceParams) {
const handler = new CallbackHandler({
_projectId: traceParams.projectId,
_isLocalEventExportEnabled: true,
environment: traceParams.environment,
});
finalCallbacks.push(handler);
let handler: CallbackHandler;
processTracedEvents = async () => {
try {
const events = await handler.langfuse._exportLocalEvents(
traceParams.projectId,
);
await processEventBatch(
JSON.parse(JSON.stringify(events)), // stringify to emulate network event batch from network call
traceParams.authCheck,
{ isLangfuseInternal: true },
);
} catch (e) {
logger.error("Failed to process traced events", { error: e });
}
};
if (
context.credentials === "langfuse" &&
env.LANGFUSE_AI_FEATURES_PUBLIC_KEY &&
env.LANGFUSE_AI_FEATURES_SECRET_KEY &&
env.LANGFUSE_AI_FEATURES_HOST
) {
// send to configured cloud Langfuse instance
handler = new CallbackHandler({
publicKey: env.LANGFUSE_AI_FEATURES_PUBLIC_KEY,
secretKey: env.LANGFUSE_AI_FEATURES_SECRET_KEY,
baseUrl: env.LANGFUSE_AI_FEATURES_HOST,
environment: traceParams.environment,
});
processTracedEvents = () => Promise.resolve();
} else {
// use local trace export
handler = new CallbackHandler({
_projectId: traceParams.projectId,
_isLocalEventExportEnabled: true,
environment: traceParams.environment,
});
processTracedEvents = async () => {
try {
const events = await handler.langfuse._exportLocalEvents(
traceParams.projectId,
);
await processEventBatch(
JSON.parse(JSON.stringify(events)), // stringify to emulate network event batch from network call
traceParams.authCheck,
{
isLangfuseInternal: context.tracing === "langfuse",
},
);
} catch (e) {
logger.error("Failed to process traced events", { error: e });
}
};
}
finalCallbacks.push(handler);
}
finalCallbacks = finalCallbacks.length > 0 ? finalCallbacks : undefined;
@@ -174,13 +250,13 @@ export async function fetchLLMCompletion(
};
let finalMessages: BaseMessage[];
// VertexAI requires at least 1 user message
if (modelParams.adapter === LLMAdapter.VertexAI && messages.length === 1) {
const safeContent =
typeof messages[0].content === "string"
? messages[0].content
: JSON.stringify(messages[0].content);
finalMessages = [new HumanMessage(safeContent)];
// Some providers require at least 1 user message
if (
messages.length === 1 &&
PROVIDERS_WITH_REQUIRED_USER_MESSAGE.includes(modelParams.adapter)
) {
// Ensure provider schema compliance
finalMessages = transformSystemMessageToUserMessage(messages);
} else {
finalMessages = messages.map((message) => {
// For arbitrary content types, convert to string safely
@@ -250,7 +326,9 @@ export async function fetchLLMCompletion(
openAIApiKey: apiKey,
modelName: modelParams.model,
temperature: modelParams.temperature,
maxTokens: modelParams.max_tokens,
...(isOpenAIReasoningModel(modelParams.model as OpenAIModel)
? { maxCompletionTokens: modelParams.max_tokens }
: { maxTokens: modelParams.max_tokens }),
topP: modelParams.top_p,
streamUsage: false, // https://github.com/langchain-ai/langchainjs/issues/6533
callbacks: finalCallbacks,
@@ -282,12 +360,9 @@ export async function fetchLLMCompletion(
modelKwargs: modelParams.providerOptions,
});
} else if (modelParams.adapter === LLMAdapter.Bedrock) {
const { region } = BedrockConfigSchema.parse(config);
// Handle both explicit credentials and default provider chain
const credentials =
apiKey === BEDROCK_USE_DEFAULT_CREDENTIALS && !isLangfuseCloud
? undefined // undefined = use AWS SDK default credential provider chain
: BedrockCredentialSchema.parse(JSON.parse(apiKey));
const credentials = resolveBedrockCredentials(apiKey, context);
const region = resolveBedrockRegion(context, config);
chatModel = new ChatBedrockConverse({
model: modelParams.model,
+66 -4
View File
@@ -4,7 +4,6 @@ import {
BedrockConfigSchema,
VertexAIConfigSchema,
} from "../../interfaces/customLLMProviderConfigSchemas";
import { TokenCountDelegate } from "../ingestion/processEventBatch";
import { AuthHeaderValidVerificationResult } from "../auth/types";
import { JSONObjectSchema } from "../../utils/zod";
@@ -60,6 +59,13 @@ const AnthropicMessageContentWithToolUse = z.union([
}),
]);
const GoogleAIStudioMessageContentWithToolUse = z.object({
functionCall: z.object({
name: z.string(),
args: z.unknown(),
}),
});
export const LLMToolCallSchema = z.object({
name: z.string(),
id: z.string(),
@@ -104,7 +110,11 @@ export const OpenAIResponseFormatSchema = z.object({
});
export const ToolCallResponseSchema = z.object({
content: z.union([z.string(), z.array(AnthropicMessageContentWithToolUse)]),
content: z.union([
z.string(),
z.array(AnthropicMessageContentWithToolUse),
z.array(GoogleAIStudioMessageContentWithToolUse),
]),
tool_calls: z.array(LLMToolCallSchema),
});
export type ToolCallResponse = z.infer<typeof ToolCallResponseSchema>;
@@ -333,11 +343,63 @@ export const openAIModels = [
"gpt-3.5-turbo",
] as const;
type OpenAIReasoningMap = Record<OpenAIModel, boolean>;
export const openAIModelToReasoning: OpenAIReasoningMap = {
// reasoning models
"gpt-5": true,
"gpt-5-2025-08-07": true,
"gpt-5-mini": true,
"gpt-5-mini-2025-08-07": true,
"gpt-5-nano": true,
"gpt-5-nano-2025-08-07": true,
o3: true,
"o3-2025-04-16": true,
"o4-mini": true,
"o4-mini-2025-04-16": true,
"o3-mini": true,
"o3-mini-2025-01-31": true,
"o1-preview": true,
"o1-preview-2024-09-12": true,
"o1-mini": true,
"o1-mini-2024-09-12": true,
// non-reasoning models
"gpt-4.5-preview": false,
"gpt-4.5-preview-2025-02-27": false,
"gpt-4-turbo-preview": false,
"gpt-4-1106-preview": false,
"gpt-4-0613": false,
"gpt-4-0125-preview": false,
"gpt-4": false,
"gpt-3.5-turbo-16k-0613": false,
"gpt-3.5-turbo-16k": false,
"gpt-3.5-turbo-1106": false,
"gpt-3.5-turbo-0613": false,
"gpt-3.5-turbo-0301": false,
"gpt-3.5-turbo-0125": false,
"gpt-3.5-turbo": false,
"gpt-4.1": false,
"gpt-4.1-2025-04-14": false,
"gpt-4.1-mini": false,
"gpt-4.1-mini-2025-04-14": false,
"gpt-4.1-nano": false,
"gpt-4.1-nano-2025-04-14": false,
"gpt-4o": false,
"gpt-4o-2024-08-06": false,
"gpt-4o-2024-05-13": false,
"gpt-4o-mini": false,
"gpt-4o-mini-2024-07-18": false,
};
export const isOpenAIReasoningModel = (model: OpenAIModel): boolean => {
return openAIModelToReasoning[model];
};
export type OpenAIModel = (typeof openAIModels)[number];
// NOTE: Update docs page when changing this! https://langfuse.com/docs/prompt-management/features/playground#openai-playground--anthropic-playground
// WARNING: The first entry in the array is chosen as the default model to add LLM API keys
export const anthropicModels = [
"claude-sonnet-4-5-20250929",
"claude-sonnet-4-20250514",
"claude-opus-4-1-20250805",
"claude-opus-4-20250514",
@@ -439,7 +501,7 @@ export type TraceParams = {
traceName: string;
traceId: string;
projectId: string;
environment: PromptExperimentEnvironment;
tokenCountDelegate: TokenCountDelegate;
// TODO: add more possibilities for environment re: langfuse AI features
environment: PromptExperimentEnvironment | string;
authCheck: AuthHeaderValidVerificationResult;
};
@@ -33,7 +33,8 @@ class SimpleAttributeMapper implements ObservationTypeMapper {
_scopeData?: Record<string, unknown>,
): boolean {
return (
this.attributeKey in attributes && attributes[this.attributeKey] != null
this.attributeKey in attributes &&
hasMeaningfulValue(attributes[this.attributeKey])
);
}
@@ -102,6 +103,20 @@ class CustomAttributeMapper implements ObservationTypeMapper {
}
}
// value is not null, undefined, empty string, or empty object/array
function hasMeaningfulValue(value: unknown): boolean {
if (value === null || value === undefined || value === "") {
return false;
}
if (Array.isArray(value)) {
return value.length > 0;
}
if (typeof value === "object" && value !== null) {
return Object.keys(value).length > 0;
}
return true;
}
/**
* Registry to manage observation type mappers with a unified interface to map
* span attributes to observation types.
@@ -169,6 +184,7 @@ export class ObservationTypeMapperRegistry {
{
span: "SPAN",
generation: "GENERATION",
event: "EVENT",
embedding: "EMBEDDING",
agent: "AGENT",
tool: "TOOL",
@@ -239,7 +255,7 @@ export class ObservationTypeMapperRegistry {
"llm.model_name",
"model",
];
return modelKeys.some((key) => attributes[key] != null);
return modelKeys.some((key) => hasMeaningfulValue(attributes[key]));
},
() => "GENERATION",
),
@@ -21,6 +21,7 @@ import { LangfuseOtelSpanAttributes } from "./attributes";
import { ObservationTypeMapperRegistry } from "./ObservationTypeMapper";
import { env } from "../../env";
import { OtelIngestionQueue } from "../redis/otelIngestionQueue";
import { isValidDateString } from "./utils";
// Type definitions for internal processor state
interface TraceState {
@@ -512,6 +513,7 @@ export class OtelIngestionProcessor {
metadata: {
...resourceAttributeMetadata,
...this.extractMetadata(attributes, "trace"),
...this.extractMetadata(attributes, "observation"),
...(isLangfuseSDKSpans
? {}
: { attributes: spanAttributesInMetadata }),
@@ -550,9 +552,10 @@ export class OtelIngestionProcessor {
metadata: {
...resourceAttributeMetadata,
...this.extractMetadata(attributes, "trace"),
...(isLangfuseSDKSpans
? {}
: { attributes: spanAttributesInMetadata }),
// removed to not remove trace metadata->attributes through subsequent observations
// ...(isLangfuseSDKSpans
// ? {}
// : { attributes: spanAttributesInMetadata }),
resourceAttributes,
scope: {
...(scopeSpan.scope || {}),
@@ -1079,6 +1082,13 @@ export class OtelIngestionProcessor {
return { input, output };
}
// GCP Vertex Agent Tool call input and output
input = attributes["gcp.vertex.agent.tool_call_args"];
output = attributes["gcp.vertex.agent.tool_response"];
if (input || output) {
return { input, output };
}
// TraceLoop uses attributes property
const inputAttributes = Object.keys(attributes).filter((key) =>
key.startsWith("gen_ai.prompt"),
@@ -1122,12 +1132,12 @@ export class OtelIngestionProcessor {
];
for (const key of environmentAttributeKeys) {
if (resourceAttributes[key]) {
return resourceAttributes[key] as string;
}
if (attributes[key]) {
return attributes[key] as string;
}
if (resourceAttributes[key]) {
return resourceAttributes[key] as string;
}
}
return "default";
@@ -1388,6 +1398,7 @@ export class OtelIngestionProcessor {
LangfuseOtelSpanAttributes.OBSERVATION_MODEL,
"gen_ai.request.model",
"gen_ai.response.model",
"llm.response.model",
"llm.model_name",
"model",
];
@@ -1559,11 +1570,16 @@ export class OtelIngestionProcessor {
startTimeISO?: string,
): string | null {
try {
return JSON.parse(
attributes[
LangfuseOtelSpanAttributes.OBSERVATION_COMPLETION_START_TIME
] as string,
);
const value = attributes[
LangfuseOtelSpanAttributes.OBSERVATION_COMPLETION_START_TIME
] as any;
if (isValidDateString(value)) return value;
// Older SDKs have double stringified timestamps that need JSON parsing
// "\"2025-10-01T08:45:26.112648Z\""
const parsed = JSON.parse(value);
if (isValidDateString(parsed)) return parsed;
} catch {
// Fallthrough
}
@@ -1670,14 +1686,7 @@ export class OtelIngestionProcessor {
[...traceIds].map(async (traceId) => {
const key = `langfuse:project:${this.projectId}:trace:${traceId}:seen`;
const TTLSeconds = 600; // 10 minutes
const result = await redis?.call(
"SET",
key,
"1",
"NX",
"EX",
TTLSeconds,
);
const result = await redis?.set(key, "1", "EX", TTLSeconds, "NX");
return {
traceId: traceId,
+3
View File
@@ -0,0 +1,3 @@
export function isValidDateString(dateString: string): boolean {
return !isNaN(new Date(dateString).getTime());
}
+1
View File
@@ -45,6 +45,7 @@ export const BatchExportJobSchema = z.object({
export const TraceQueueEventSchema = z.object({
projectId: z.string(),
traceId: z.string(),
exactTimestamp: z.date().optional(),
});
export const TracesQueueEventSchema = z.object({
projectId: z.string(),
@@ -2,6 +2,7 @@ import { env } from "../../env";
import {
clickhouseClient,
convertDateToClickhouseDateTime,
PreferredClickhouseService,
} from "../clickhouse/client";
import { logger } from "../logger";
import { getTracer, instrumentAsync } from "../instrumentation";
@@ -14,6 +15,71 @@ import {
StorageService,
StorageServiceFactory,
} from "../services/StorageService";
import { ClickHouseSettings } from "@clickhouse/client";
/**
* Custom error class for ClickHouse resource-related errors
*/
// Error type configuration map
const ERROR_TYPE_CONFIG: Record<
"MEMORY_LIMIT" | "OVERCOMMIT" | "TIMEOUT",
{
discriminators: string[];
}
> = {
MEMORY_LIMIT: {
discriminators: ["memory limit exceeded"],
},
OVERCOMMIT: {
discriminators: ["OvercommitTracker"],
},
TIMEOUT: {
discriminators: ["Timeout", "timeout", "timed out"],
},
};
type ErrorType = keyof typeof ERROR_TYPE_CONFIG;
export class ClickHouseResourceError extends Error {
static ERROR_ADVICE_MESSAGE = [
"Database resource limit exceeded.",
"Please use more specific filters or a shorter time range.",
"We are continuously improving our API performance.",
].join(" ");
public readonly errorType: ErrorType;
constructor(errType: ErrorType, originalError: Error) {
super(originalError.message, { cause: originalError });
this.name = "ClickHouseResourceError";
this.errorType = errType;
// Preserve the original stack trace if available
if (originalError.stack) {
this.stack = originalError.stack;
}
}
static wrapIfResourceError(originalError: Error): Error {
const errorMessage = originalError.message || "";
for (const [type, config] of Object.entries(ERROR_TYPE_CONFIG) as Array<
[
keyof typeof ERROR_TYPE_CONFIG,
(typeof ERROR_TYPE_CONFIG)[keyof typeof ERROR_TYPE_CONFIG],
]
>) {
const hasDiscriminator = config.discriminators.some((discriminator) =>
errorMessage.includes(discriminator),
);
if (hasDiscriminator) {
return new ClickHouseResourceError(type, originalError);
}
}
return originalError;
}
}
let s3StorageServiceClient: StorageService;
@@ -144,6 +210,8 @@ export async function* queryClickhouseStream<T>(opts: {
params?: Record<string, unknown> | undefined;
clickhouseConfigs?: NodeClickHouseClientConfigOptions;
tags?: Record<string, string>;
preferredClickhouseService?: PreferredClickhouseService;
clickhouseSettings?: ClickHouseSettings;
}): AsyncGenerator<T> {
const tracer = getTracer("clickhouse-query-stream");
const span = tracer.startSpan("clickhouse-query-stream", {
@@ -151,20 +219,23 @@ export async function* queryClickhouseStream<T>(opts: {
});
try {
const res = await context.with(
trace.setSpan(context.active(), span),
async () => {
const res = await context
.with(trace.setSpan(context.active(), span), async () => {
// https://opentelemetry.io/docs/specs/semconv/database/database-spans/
span.setAttribute("ch.query.text", opts.query);
span.setAttribute("db.system", "clickhouse");
span.setAttribute("db.query.text", opts.query);
span.setAttribute("db.operation.name", "SELECT");
const res = await clickhouseClient(opts.clickhouseConfigs).query({
const res = await clickhouseClient(
opts.clickhouseConfigs,
opts.preferredClickhouseService,
).query({
query: opts.query,
format: "JSONEachRow",
query_params: opts.params,
clickhouse_settings: {
...opts.clickhouseSettings,
log_comment: JSON.stringify(opts.tags ?? {}),
},
});
@@ -193,19 +264,58 @@ export async function* queryClickhouseStream<T>(opts: {
}
}
return res;
},
);
})
.catch((error) => {
// Transform resource errors to provide actionable advice
throw ClickHouseResourceError.wrapIfResourceError(error as Error);
});
for await (const rows of res.stream<T>()) {
for (const row of rows) {
yield row.json();
yield handleExceptionRow(row.json());
}
}
} catch (error) {
// Also catch errors during streaming
throw ClickHouseResourceError.wrapIfResourceError(error as Error);
} finally {
span.end();
}
}
/**
* ClickHouse has a quirk when it comes to handling exceptions mid response.
* It will simply output a row with "exception" key inside, which is indistinguishable from
* a query like `SELECT "my lovely string" AS exception;` may return.
*
* E.g.:
* ```
* {"exception":"Code: 395. DB::Exception: memory limit exceeded: would use l0.23 GiB"}
* ```
*
* This function makes the best effort to convert such rows into errors and throws them.
*
* See:
* - https://github.com/ClickHouse/clickhouse-js/issues/332
* - https://github.com/ClickHouse/ClickHouse/issues/75175
*
* Ideally this should get fixed in the future versions of ClickHouse.
*/
function handleExceptionRow<T>(parsedRow: T): T {
if (
typeof parsedRow === "object" &&
parsedRow !== null &&
Object.keys(parsedRow).length === 1 &&
"exception" in parsedRow
) {
const potentialException = (parsedRow as { exception: string }).exception;
if (potentialException.match(/^Code: (\d+)/)) {
throw new Error(potentialException);
}
}
return parsedRow;
}
/**
* Determines if an error is retryable (socket hang up, connection reset, etc.)
*/
@@ -223,6 +333,8 @@ export async function queryClickhouse<T>(opts: {
params?: Record<string, unknown> | undefined;
clickhouseConfigs?: NodeClickHouseClientConfigOptions;
tags?: Record<string, string>;
preferredClickhouseService?: PreferredClickhouseService;
clickhouseSettings?: ClickHouseSettings;
}): Promise<T[]> {
return await instrumentAsync(
{ name: "clickhouse-query", spanKind: SpanKind.CLIENT },
@@ -236,11 +348,15 @@ export async function queryClickhouse<T>(opts: {
// Retry logic for socket hang up and other network errors
return await backOff(
async () => {
const res = await clickhouseClient(opts.clickhouseConfigs).query({
const res = await clickhouseClient(
opts.clickhouseConfigs,
opts.preferredClickhouseService,
).query({
query: opts.query,
format: "JSONEachRow",
query_params: opts.params,
clickhouse_settings: {
...opts.clickhouseSettings,
log_comment: JSON.stringify(opts.tags ?? {}),
},
});
@@ -270,7 +386,7 @@ export async function queryClickhouse<T>(opts: {
}
}
return await res.json<T>();
return (await res.json<T>()).map(handleExceptionRow);
},
{
numOfAttempts: env.LANGFUSE_CLICKHOUSE_QUERY_MAX_ATTEMPTS,
@@ -304,7 +420,10 @@ export async function queryClickhouse<T>(opts: {
timeMultiple: 1,
maxDelay: 100,
},
);
).catch((error) => {
// Transform resource errors to provide actionable advice
throw ClickHouseResourceError.wrapIfResourceError(error as Error);
});
},
);
}
@@ -2,7 +2,10 @@ import { DatasetRunItemDomain } from "../../domain/dataset-run-items";
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
import { parseMetadataCHRecordToDomain } from "../utils/metadata_conversion";
import { parseClickhouseUTCDateTimeFormat } from "./clickhouse";
import { DatasetRunItemRecordReadType } from "./definitions";
import {
DatasetRunItemRecordReadType,
DatasetRunItemRecord,
} from "./definitions";
export const convertToDatasetRunMetrics = (row: any) => {
return {
@@ -15,6 +18,7 @@ export const convertToDatasetRunMetrics = (row: any) => {
metadata: row.datasetRunMetadata,
countRunItems: row.count_run_items,
avgTotalCost: undefined,
totalCost: undefined,
avgLatency: undefined,
scores: undefined,
datasetId: row.dataset_id,
@@ -56,10 +60,19 @@ export const convertDatasetRunItemDomainToClickhouse = (
};
};
export const convertDatasetRunItemClickhouseToDomain = (
row: DatasetRunItemRecordReadType,
): DatasetRunItemDomain => {
return {
// Function overloads for clean type discrimination
export function convertDatasetRunItemClickhouseToDomain(
// eslint-disable-next-line no-unused-vars
row: DatasetRunItemRecord<true>,
): DatasetRunItemDomain<true>;
export function convertDatasetRunItemClickhouseToDomain(
// eslint-disable-next-line no-unused-vars
row: DatasetRunItemRecord<false>,
): DatasetRunItemDomain<false>;
export function convertDatasetRunItemClickhouseToDomain<
WithIO extends boolean = true,
>(row: DatasetRunItemRecord<WithIO>): DatasetRunItemDomain<WithIO> {
const baseConversion = {
id: row.id,
projectId: row.project_id,
traceId: row.trace_id,
@@ -70,17 +83,27 @@ export const convertDatasetRunItemClickhouseToDomain = (
datasetRunCreatedAt: parseClickhouseUTCDateTimeFormat(
row.dataset_run_created_at,
),
datasetRunMetadata:
parseMetadataCHRecordToDomain(row.dataset_run_metadata) ?? null,
datasetItemId: row.dataset_item_id,
datasetItemInput: row.dataset_item_input,
datasetItemExpectedOutput: row.dataset_item_expected_output,
datasetItemMetadata: parseMetadataCHRecordToDomain(
row.dataset_item_metadata,
),
createdAt: parseClickhouseUTCDateTimeFormat(row.created_at),
updatedAt: parseClickhouseUTCDateTimeFormat(row.updated_at),
datasetId: row.dataset_id,
error: row.error ?? null,
};
};
// Check if row has IO fields at runtime, typescript does not support conditional types without runtime checks
if ("dataset_item_input" in row) {
return {
...baseConversion,
datasetRunMetadata:
parseMetadataCHRecordToDomain((row as any).dataset_run_metadata) ??
null,
datasetItemInput: (row as any).dataset_item_input,
datasetItemExpectedOutput: (row as any).dataset_item_expected_output,
datasetItemMetadata: parseMetadataCHRecordToDomain(
(row as any).dataset_item_metadata,
),
} as DatasetRunItemDomain<WithIO>;
} else {
return baseConversion as DatasetRunItemDomain<WithIO>;
}
}
@@ -15,12 +15,13 @@ import {
queryClickhouse,
} from "./clickhouse";
import { convertDatasetRunItemClickhouseToDomain } from "./dataset-run-items-converters";
import { DatasetRunItemRecordReadType } from "./definitions";
import { DatasetRunItemRecord } from "./definitions";
import { env } from "../../env";
import { commandClickhouse } from "./clickhouse";
import Decimal from "decimal.js";
import { ClickHouseClientConfigOptions } from "@clickhouse/client";
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
import { ScoreAggregate } from "../../features/scores";
type DatasetItemIdsByTraceIdQuery = {
projectId: string;
@@ -39,6 +40,23 @@ type DatasetRunItemsTableQuery = {
clickhouseConfigs?: ClickHouseClientConfigOptions;
};
type BaseDatasetItemWithRunDataQuery = {
projectId: string;
datasetId: string;
runIds: string[];
filterByRun: {
runId: string;
filters: FilterState;
}[];
};
type DatasetItemIdsWithRunDataQuery = BaseDatasetItemWithRunDataQuery & {
limit?: number;
offset?: number;
};
type DatasetItemsWithRunDataCountQuery = BaseDatasetItemWithRunDataQuery;
type DatasetRunItemsByDatasetIdQuery = Omit<
DatasetRunItemsTableQuery,
"datasetId"
@@ -57,6 +75,17 @@ type DatasetRunsMetricsTableQuery = {
offset?: number;
};
type BaseDatasetRunItemsWithoutIOQuery = {
projectId: string;
datasetId: string;
runIds: string[];
};
type DatasetRunItemsByItemIdsWithoutIOQuery =
BaseDatasetRunItemsWithoutIOQuery & {
datasetItemIds: string[];
};
export type DatasetRunsMetrics = {
id: string;
name: string;
@@ -64,6 +93,7 @@ export type DatasetRunsMetrics = {
datasetId: string;
countRunItems: number;
avgTotalCost: Decimal;
totalCost: Decimal;
avgLatency: number;
aggScoresAvg: Array<[string, number]>;
aggScoreCategories: string[];
@@ -87,6 +117,7 @@ type DatasetRunsMetricsRecordType = {
count_run_items: number;
avg_latency_seconds: number;
avg_total_cost: number;
total_cost: number;
agg_scores_avg: Array<[string, number]>;
agg_score_categories: string[];
};
@@ -101,6 +132,27 @@ type DatasetRunsRowsRecordType = {
dataset_run_metadata: string;
};
export type EnrichedDatasetRunItem = {
id: string;
createdAt: Date;
datasetItemId: string;
datasetRunId: string;
datasetRunName: string;
observation:
| {
id: string;
latency: number;
calculatedTotalCost: Decimal;
}
| undefined;
trace: {
id: string;
duration: number;
totalCost: number;
};
scores: ScoreAggregate;
};
const convertDatasetRunsMetricsRecord = (
record: DatasetRunsMetricsRecordType,
): DatasetRunsMetrics => {
@@ -113,6 +165,9 @@ const convertDatasetRunsMetricsRecord = (
avgTotalCost: record.avg_total_cost
? new Decimal(record.avg_total_cost)
: new Decimal(0),
totalCost: record.total_cost
? new Decimal(record.total_cost)
: new Decimal(0),
avgLatency: record.avg_latency_seconds ?? 0,
aggScoresAvg: record.agg_scores_avg ?? [],
aggScoreCategories: record.agg_score_categories ?? [],
@@ -209,6 +264,10 @@ const getDatasetRunsTableInternal = async <T>(
WHEN drm.trace_avg_cost IS NOT NULL THEN drm.trace_avg_cost
ELSE COALESCE(drm.obs_avg_cost, 0)
END as avg_total_cost,
CASE
WHEN drm.trace_total_cost IS NOT NULL THEN drm.trace_total_cost
ELSE COALESCE(drm.obs_total_cost, 0)
END as total_cost,
-- Score aggregations
sa.scores_avg as agg_scores_avg,
@@ -365,12 +424,14 @@ const getDatasetRunsTableInternal = async <T>(
-- Trace-level metrics (average across traces in this dataset run)
AVG(CASE WHEN dri.observation_id IS NULL THEN tm.latency_ms ELSE NULL END) / 1000.0 as trace_avg_latency,
AVG(CASE WHEN dri.observation_id IS NULL THEN tm.total_cost ELSE NULL END) as trace_avg_cost,
SUM(CASE WHEN dri.observation_id IS NULL THEN tm.total_cost ELSE NULL END) as trace_total_cost,
-- Observation-level metrics
AVG(CASE WHEN dri.observation_id IS NOT NULL THEN
dateDiff('millisecond', of.start_time, of.end_time) / 1000.0
ELSE NULL END) as obs_avg_latency,
AVG(CASE WHEN dri.observation_id IS NOT NULL THEN of.total_cost ELSE NULL END) as obs_avg_cost
AVG(CASE WHEN dri.observation_id IS NOT NULL THEN of.total_cost ELSE NULL END) as obs_avg_cost,
SUM(CASE WHEN dri.observation_id IS NOT NULL THEN of.total_cost ELSE NULL END) as obs_total_cost
FROM dataset_run_items_rmt dri
LEFT JOIN observations_filtered of ON dri.observation_id = of.id
@@ -458,13 +519,195 @@ export const getDatasetRunsTableCountCh = async (
return Number(rows[0]?.count);
};
const getDatasetRunItemsTableInternal = async <T>(
opts: DatasetRunItemsTableQuery & {
type GetDatasetRunItemsTableOpts<IncludeIO extends boolean> =
DatasetRunItemsTableQuery & {
select: "count" | "rows";
tags: Record<string, string>;
},
includeIO?: IncludeIO;
};
// Phase 1: Find dataset item IDs or count that satisfy conditions across ALL runs
const getQualifyingDatasetItems = async <T>(opts: {
select: "count" | "rows";
projectId: string;
datasetId: string;
runIds: string[];
runFilters: {
runId: string;
filters: FilterState;
}[];
limit?: number;
offset?: number;
}): Promise<Array<T>> => {
const { select, projectId, datasetId, runIds, runFilters, limit, offset } =
opts;
// Build base filter (project + dataset only)
const { datasetRunItemsFilter: baseDatasetRunItemsFilter } =
getProjectDatasetIdDefaultFilter(projectId, datasetId);
const baseFilter = baseDatasetRunItemsFilter.apply();
// Build run-specific conditions for the intersection query
const runFilterResults = runFilters.map((runFilter) => {
const { runId, filters: filterState } = runFilter;
// Create run ID condition
const runConditionFilter = new StringFilter({
clickhouseTable: "dataset_run_items_rmt",
field: "dataset_run_id",
operator: "=",
value: runId,
});
// Create user filters for this run
const userFilters = createFilterFromFilterState(
filterState,
datasetRunItemsTableUiColumnDefinitions,
);
// Combine run condition with user filters using AND and apply immediately
const runFilterList = new FilterList([runConditionFilter, ...userFilters]);
return runFilterList.apply();
});
// add empty filters for the runs that have no filters
runIds.forEach((runId) => {
if (runFilters.find((runFilter) => runFilter.runId === runId)) {
return;
}
// Create run ID condition
const runConditionFilter = new FilterList([
new StringFilter({
clickhouseTable: "dataset_run_items_rmt",
field: "dataset_run_id",
operator: "=",
value: runId,
}),
]);
runFilterResults.push(runConditionFilter.apply());
});
const combinedQuery = `(${runFilterResults.map((result) => `(${result.query})`).join(" OR ")})`;
const intersectionQuery =
runFilters.length > 0
? `HAVING COUNT(DISTINCT dataset_run_id) = {totalRunCount: UInt32}`
: "";
// Check if any run has score filters for CTE
const hasScoresFilter = runFilters
.flatMap((f) => f.filters)
.some((f) => f.column.toLowerCase().includes("score"));
// Build scores filter
const scoresFilter = new FilterList([
new StringFilter({
clickhouseTable: "scores",
field: "project_id",
operator: "=",
value: projectId,
}),
]);
const appliedScoresFilter = scoresFilter.apply();
const selectString =
select === "count"
? "COUNT(DISTINCT dataset_item_id) as count"
: "dataset_item_id";
// Build the intersection query
const scoresCte = hasScoresFilter
? `
WITH scores_aggregated AS (
SELECT
dri.dataset_run_id,
dri.project_id,
dri.trace_id,
-- For numeric scores, use tuples of (name, avg_value)
groupArrayIf(
tuple(s.name, s.avg_value),
s.data_type IN ('NUMERIC', 'BOOLEAN')
) AS scores_avg,
-- For categorical scores, use name:value format for improved query performance
groupArrayIf(
concat(s.name, ':', s.string_value),
s.data_type = 'CATEGORICAL' AND notEmpty(s.string_value)
) AS score_categories
FROM dataset_run_items_rmt dri
LEFT JOIN (
SELECT
project_id,
trace_id,
name,
data_type,
string_value,
avg(value) as avg_value
FROM scores s FINAL
WHERE ${appliedScoresFilter.query}
GROUP BY
project_id,
trace_id,
name,
data_type,
string_value
) s ON s.project_id = dri.project_id AND s.trace_id = dri.trace_id
WHERE ${baseFilter.query}
GROUP BY dri.project_id, dri.dataset_id, dri.dataset_run_id, dri.trace_id
),
`
: "WITH ";
const query = `
${scoresCte}
run_qualified_items AS (
SELECT DISTINCT dri.dataset_item_id, dri.dataset_run_id
FROM dataset_run_items_rmt dri
${hasScoresFilter ? `LEFT JOIN scores_aggregated sa ON dri.dataset_run_id = sa.dataset_run_id AND dri.project_id = sa.project_id AND dri.trace_id = sa.trace_id` : ""}
WHERE ${baseFilter.query}
AND ${combinedQuery}
),
intersection_items AS (
SELECT dataset_item_id
FROM run_qualified_items
GROUP BY dataset_item_id
${intersectionQuery}
)
SELECT
${selectString}
FROM intersection_items
${select === "count" ? "" : "ORDER BY dataset_item_id -- for consistent pagination"}
${limit !== undefined && offset !== undefined ? `LIMIT ${limit} OFFSET ${offset}` : ""};`;
const res = await queryClickhouse<T>({
query,
params: {
...baseFilter.params,
...(hasScoresFilter ? appliedScoresFilter.params : {}),
totalRunCount: runIds.length,
...runFilterResults.reduce((acc, result) => {
return { ...acc, ...result.params };
}, {}),
...(limit !== undefined && offset !== undefined ? { limit, offset } : {}),
},
tags: {
feature: "datasets",
type: "dataset-run-items",
projectId,
datasetId,
},
});
return res;
};
const getDatasetRunItemsTableInternal = async <
T,
IncludeIO extends boolean = true,
>(
opts: GetDatasetRunItemsTableOpts<IncludeIO>,
): Promise<Array<T>> => {
const { projectId, datasetId, filter, orderBy, limit, offset } = opts;
const { projectId, datasetId, filter, orderBy, limit, offset, includeIO } =
opts;
let selectString = "";
@@ -487,11 +730,11 @@ const getDatasetRunItemsTableInternal = async <T>(
dri.updated_at as updated_at,
dri.dataset_run_name as dataset_run_name,
dri.dataset_run_description as dataset_run_description,
dri.dataset_run_metadata as dataset_run_metadata,
dri.dataset_run_created_at as dataset_run_created_at,
dri.dataset_item_input as dataset_item_input,
dri.dataset_item_expected_output as dataset_item_expected_output,
dri.dataset_item_metadata as dataset_item_metadata,
${includeIO ? "dri.dataset_run_metadata as dataset_run_metadata, " : ""}
${includeIO ? "dri.dataset_item_input as dataset_item_input, " : ""}
${includeIO ? "dri.dataset_item_expected_output as dataset_item_expected_output, " : ""}
${includeIO ? "dri.dataset_item_metadata as dataset_item_metadata, " : ""}
dri.is_deleted as is_deleted,
dri.event_ts as event_ts`;
break;
@@ -512,6 +755,21 @@ const getDatasetRunItemsTableInternal = async <T>(
);
const appliedFilter = datasetRunItemsFilter.apply();
const scoresFilter = new FilterList([
new StringFilter({
clickhouseTable: "scores",
field: "project_id",
operator: "=",
value: projectId,
}),
]);
const hasScoresFilter = filter.some((f) =>
f.column.toLowerCase().includes("score"),
);
const appliedScoresFilter = scoresFilter.apply();
// Build ORDER BY array - conditionally add event_ts DESC for rows
const orderByArray: OrderByState[] = [];
@@ -537,30 +795,77 @@ const getDatasetRunItemsTableInternal = async <T>(
datasetRunItemsTableUiColumnDefinitions,
);
const scoresCte = `
WITH scores_aggregated AS (
SELECT
dri.dataset_run_id,
dri.project_id,
dri.trace_id,
-- For numeric scores, use tuples of (name, avg_value)
groupArrayIf(
tuple(s.name, s.avg_value),
s.data_type IN ('NUMERIC', 'BOOLEAN')
) AS scores_avg,
-- For categorical scores, use name:value format for improved query performance
groupArrayIf(
concat(s.name, ':', s.string_value),
s.data_type = 'CATEGORICAL' AND notEmpty(s.string_value)
) AS score_categories
FROM dataset_run_items_rmt dri
LEFT JOIN (
SELECT
project_id,
trace_id,
name,
data_type,
string_value,
avg(value) as avg_value
FROM scores s FINAL
WHERE ${appliedScoresFilter.query}
GROUP BY
project_id,
trace_id,
name,
data_type,
string_value
) s ON s.project_id = dri.project_id AND s.trace_id = dri.trace_id
WHERE dri.project_id = {projectId: String}
${datasetId ? "AND dri.dataset_id = {datasetId: String}" : ""}
GROUP BY dri.dataset_run_id, dri.project_id, dri.trace_id
)
`;
const query =
opts.select === "rows"
? `
${scoresCte}
SELECT *
FROM (
SELECT
${selectString}
FROM dataset_run_items_rmt dri
${hasScoresFilter ? `LEFT JOIN scores_aggregated sa ON dri.dataset_run_id = sa.dataset_run_id AND dri.project_id = sa.project_id AND dri.trace_id = sa.trace_id` : ""}
WHERE ${appliedFilter.query}
${orderByClause}
LIMIT 1 BY dri.project_id, dri.dataset_id, dri.dataset_run_id, dri.dataset_item_id
) AS deduplicated
${limit !== undefined && offset !== undefined ? `LIMIT ${limit} OFFSET ${offset}` : ""};`
: `
${scoresCte}
SELECT
${selectString}
FROM dataset_run_items_rmt dri
${hasScoresFilter ? `LEFT JOIN scores_aggregated sa ON dri.dataset_run_id = sa.dataset_run_id AND dri.project_id = sa.project_id AND dri.trace_id = sa.trace_id` : ""}
WHERE ${appliedFilter.query};`;
const res = await queryClickhouse<T>({
query,
params: {
...appliedFilter.params,
...appliedScoresFilter.params,
...(limit !== undefined && offset !== undefined ? { limit, offset } : {}),
...(datasetId ? { datasetId } : {}),
projectId,
},
tags: {
...(opts.tags ?? {}),
@@ -578,27 +883,87 @@ const getDatasetRunItemsTableInternal = async <T>(
export const getDatasetRunItemsCh = async (
opts: DatasetRunItemsTableQuery,
): Promise<DatasetRunItemDomain[]> => {
const rows =
await getDatasetRunItemsTableInternal<DatasetRunItemRecordReadType>({
...opts,
select: "rows",
tags: { kind: "list" },
});
const rows = await getDatasetRunItemsTableInternal<DatasetRunItemRecord>({
...opts,
select: "rows",
tags: { kind: "list" },
});
return rows.map(convertDatasetRunItemClickhouseToDomain);
return rows.map((row) => convertDatasetRunItemClickhouseToDomain(row));
};
export const getDatasetRunItemsByDatasetIdCh = async (
opts: DatasetRunItemsByDatasetIdQuery,
): Promise<DatasetRunItemDomain[]> => {
const rows =
await getDatasetRunItemsTableInternal<DatasetRunItemRecordReadType>({
...opts,
select: "rows",
tags: { kind: "list" },
});
const rows = await getDatasetRunItemsTableInternal<DatasetRunItemRecord>({
...opts,
select: "rows",
tags: { kind: "list" },
});
return rows.map(convertDatasetRunItemClickhouseToDomain);
return rows.map((row) => convertDatasetRunItemClickhouseToDomain(row));
};
export const getDatasetItemsWithRunDataCount = async (
opts: DatasetItemsWithRunDataCountQuery,
): Promise<number> => {
const { projectId, datasetId, runIds, filterByRun } = opts;
const rows = await getQualifyingDatasetItems<{ count: string }>({
select: "count",
projectId,
datasetId,
runIds,
runFilters: filterByRun,
});
return Number(rows[0]?.count);
};
export const getDatasetItemIdsWithRunData = async (
opts: DatasetItemIdsWithRunDataQuery,
): Promise<string[]> => {
const rows = await getQualifyingDatasetItems<{ dataset_item_id: string }>({
select: "rows",
runFilters: opts.filterByRun,
...opts,
});
return rows.map((row) => row.dataset_item_id);
};
export const getDatasetRunItemsWithoutIOByItemIds = async (
opts: DatasetRunItemsByItemIdsWithoutIOQuery,
): Promise<DatasetRunItemDomain<false>[]> => {
// Step 1: Get DRI data matching [datasetId, runId, datasetItemId]
const { datasetItemIds, runIds, ...rest } = opts;
const filter: FilterState = [
{
column: "datasetItemId",
operator: "any of",
value: datasetItemIds,
type: "stringOptions" as const,
},
{
column: "datasetRunId",
operator: "any of",
value: runIds,
type: "stringOptions" as const,
},
];
const rows = await getDatasetRunItemsTableInternal<
DatasetRunItemRecord<false>,
false
>({
...rest,
filter,
select: "rows",
tags: { kind: "list" },
});
// Step 2: Convert to domain
return rows.map((row) => convertDatasetRunItemClickhouseToDomain(row));
};
export const getDatasetItemIdsByTraceIdCh = async (
@@ -225,6 +225,17 @@ const datasetRunItemRecordReadSchema = datasetRunItemRecordBaseSchema.extend({
export type DatasetRunItemRecordReadType = z.infer<
typeof datasetRunItemRecordReadSchema
>;
// Conditional type for dataset run item records with optional IO
export type DatasetRunItemRecord<WithIO extends boolean = true> =
WithIO extends true
? DatasetRunItemRecordReadType
: Omit<
DatasetRunItemRecordReadType,
| "dataset_run_metadata"
| "dataset_item_input"
| "dataset_item_expected_output"
| "dataset_item_metadata"
>;
export const datasetRunItemRecordInsertSchema =
datasetRunItemRecordBaseSchema.extend({
@@ -496,128 +507,3 @@ export const convertPostgresScoreToInsert = (
is_deleted: 0,
};
};
export const convertTraceToTraceNull = (
traceRecord: TraceRecordInsertType,
): TraceNullRecordInsertType => {
return {
// Identifiers
project_id: traceRecord.project_id,
id: traceRecord.id,
start_time: traceRecord.timestamp,
end_time: null, // traces don't have end_time, will be null
name: traceRecord.name || null,
// Metadata properties
metadata: traceRecord.metadata,
user_id: traceRecord.user_id || null,
session_id: traceRecord.session_id || null,
environment: traceRecord.environment,
tags: traceRecord.tags,
version: traceRecord.version || null,
release: traceRecord.release || null,
// UI properties - nullable to prevent absent values being interpreted as overwrites
bookmarked: traceRecord.bookmarked ?? null,
public: traceRecord.public ?? null,
// Aggregations - empty for now, will be populated by aggregation processes
observation_ids: [],
score_ids: [],
cost_details: {},
usage_details: {},
// Input/Output
input: traceRecord.input || "",
output: traceRecord.output || "",
created_at: traceRecord.created_at,
updated_at: traceRecord.updated_at,
event_ts: traceRecord.event_ts,
};
};
export const convertObservationToTraceNull = (
observationRecord: ObservationRecordInsertType,
): TraceNullRecordInsertType => {
return {
// Identifiers
project_id: observationRecord.project_id,
// Use trace_id as the id in traces_null. Always set given the conditions around calling the function
id: observationRecord.trace_id || "",
start_time: observationRecord.start_time,
end_time: observationRecord.end_time || null,
name: null,
// Metadata properties
metadata: {},
user_id: null,
session_id: null,
environment: observationRecord.environment,
tags: [],
version: null,
release: null,
// UI properties - nullable to prevent absent values being interpreted as overwrites
bookmarked: null,
public: null,
// Aggregations - include this observation ID
observation_ids: [observationRecord.id],
score_ids: [],
// We can fill the cost details here, but we shouldn't trust them.
// Only used for verification to estimate how big the double-counting is.
// Actually, we don't as this will make backfills challenging.
cost_details: {}, // observationRecord.cost_details || {},
usage_details: {}, // observationRecord.usage_details || {},
// Input/Output
input: "",
output: "",
created_at: observationRecord.created_at,
updated_at: observationRecord.updated_at,
event_ts: observationRecord.event_ts,
};
};
export const convertScoreToTraceNull = (
scoreRecord: ScoreRecordInsertType,
): TraceNullRecordInsertType => {
return {
// Identifiers
project_id: scoreRecord.project_id,
// Use trace_id as the id in traces_null. Always set given the conditions around calling the function
id: scoreRecord.trace_id || "",
start_time: scoreRecord.timestamp,
end_time: null, // scores don't have end_time
name: null,
// Metadata properties
metadata: {},
user_id: null,
session_id: null,
environment: scoreRecord.environment,
tags: [], // scores don't have tags
version: null, // scores don't have version
release: null, // scores don't have release
// UI properties - nullable to prevent absent values being interpreted as overwrites
bookmarked: null,
public: null,
// Aggregations - include this score ID
observation_ids: [],
score_ids: [scoreRecord.id],
cost_details: {},
usage_details: {},
// Input/Output
input: "", // scores don't have input
output: "", // scores don't have output
created_at: scoreRecord.created_at,
updated_at: scoreRecord.updated_at,
event_ts: scoreRecord.event_ts,
};
};
@@ -2,24 +2,33 @@ import { queryClickhouse } from "./clickhouse";
export type EnvironmentFilterProps = {
projectId: string;
fromTimestamp?: Date;
};
export const getEnvironmentsForProject = async (
props: EnvironmentFilterProps,
): Promise<{ environment: string }[]> => {
const { projectId } = props;
const { projectId, fromTimestamp } = props;
const query = `
SELECT environments
FROM project_environments FINAL
WHERE project_id = {projectId: String}
(
SELECT distinct environment
FROM traces
WHERE project_id = {projectId: String}
${fromTimestamp ? "AND timestamp >= {fromTimestamp: DateTime64(3)}" : ""}
) UNION ALL (
SELECT distinct environment
FROM observations
WHERE project_id = {projectId: String}
${fromTimestamp ? "AND start_time >= {fromTimestamp: DateTime64(3)}" : ""}
)
`;
const results = await queryClickhouse<{
environments: string[];
environment: string;
}>({
query,
params: { projectId },
params: { projectId, fromTimestamp },
tags: {
feature: "tracing",
type: "environment",
@@ -27,10 +36,12 @@ export const getEnvironmentsForProject = async (
projectId,
},
});
// Always add default environment to list
results.push({ environment: "default" });
const environments = results.length > 0 ? results[0].environments : [];
environments.push("default");
return Array.from(new Set(environments)).map((environment) => ({
environment,
}));
return Array.from(new Set(results.map((e) => e.environment))).map(
(environment) => ({
environment,
}),
);
};
@@ -25,7 +25,10 @@ import {
import { OrderByState } from "../../interfaces/orderBy";
import { getTimeframesTracesAMT, getTracesByIds } from "./traces";
import { measureAndReturn } from "../clickhouse/measureAndReturn";
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
import {
convertDateToClickhouseDateTime,
PreferredClickhouseService,
} from "../clickhouse/client";
import { convertObservation } from "./observations_converters";
import { clickhouseSearchCondition } from "../queries/clickhouse-sql/search";
import {
@@ -120,12 +123,19 @@ export type GetObservationsForTraceOpts<IncludeIO extends boolean> = {
projectId: string;
timestamp?: Date;
includeIO?: IncludeIO;
preferredClickhouseService?: PreferredClickhouseService;
};
export const getObservationsForTrace = async <IncludeIO extends boolean>(
opts: GetObservationsForTraceOpts<IncludeIO>,
) => {
const { traceId, projectId, timestamp, includeIO = false } = opts;
const {
traceId,
projectId,
timestamp,
includeIO = false,
preferredClickhouseService,
} = opts;
const query = `
SELECT
@@ -178,6 +188,7 @@ export const getObservationsForTrace = async <IncludeIO extends boolean>(
kind: "list",
projectId,
},
preferredClickhouseService,
});
// Large number of observations in trace with large input / output / metadata will lead to
@@ -307,6 +318,7 @@ export const getObservationById = async ({
type,
traceId,
renderingProps = DEFAULT_RENDERING_PROPS,
preferredClickhouseService,
}: {
id: string;
projectId: string;
@@ -315,6 +327,7 @@ export const getObservationById = async ({
type?: ObservationType;
traceId?: string;
renderingProps?: RenderingProps;
preferredClickhouseService?: PreferredClickhouseService;
}) => {
const records = await getObservationByIdInternal({
id,
@@ -324,6 +337,7 @@ export const getObservationById = async ({
type,
traceId,
renderingProps,
preferredClickhouseService,
});
const mapped = records.map((record) =>
convertObservation(record, renderingProps),
@@ -408,6 +422,7 @@ const getObservationByIdInternal = async ({
type,
traceId,
renderingProps = DEFAULT_RENDERING_PROPS,
preferredClickhouseService,
}: {
id: string;
projectId: string;
@@ -416,6 +431,7 @@ const getObservationByIdInternal = async ({
type?: ObservationType;
traceId?: string;
renderingProps?: RenderingProps;
preferredClickhouseService?: PreferredClickhouseService;
}) => {
const query = `
SELECT
@@ -472,6 +488,7 @@ const getObservationByIdInternal = async ({
kind: "byId",
projectId,
},
preferredClickhouseService,
});
};
@@ -11,6 +11,7 @@ import {
DEFAULT_RENDERING_PROPS,
applyInputOutputRendering,
} from "../utils/rendering";
import { logger } from "../logger";
export const convertObservation = (
record: ObservationRecordReadType,
@@ -19,6 +20,18 @@ export const convertObservation = (
const reducedCostDetails = reduceUsageOrCostDetails(record.cost_details);
const reducedUsageDetails = reduceUsageOrCostDetails(record.usage_details);
if (!record.start_time) {
logger.error(
`Found invalid value start_time: ${record.start_time} for record ${record.id} in project ${record.project_id}. Processing will fail.`,
{
...record,
input: null,
output: null,
metadata: null,
},
);
}
return {
id: record.id,
traceId: record.trace_id ?? null,
@@ -1,4 +1,5 @@
import { ScoreSourceType } from "../../domain";
import { PreferredClickhouseService } from "../clickhouse/client";
import { queryClickhouse } from "./clickhouse";
import { ScoreRecordReadType } from "./definitions";
import { convertToScore } from "./scores_converters";
@@ -13,11 +14,13 @@ export const _handleGetScoreById = async ({
scoreId,
source,
scoreScope,
preferredClickhouseService,
}: {
projectId: string;
scoreId: string;
source?: ScoreSourceType;
scoreScope: "traces_only" | "all";
preferredClickhouseService?: PreferredClickhouseService;
}) => {
const query = `
SELECT *
@@ -44,6 +47,7 @@ export const _handleGetScoreById = async ({
kind: "byId",
projectId,
},
preferredClickhouseService,
});
return rows.map(convertToScore).shift();
};
@@ -24,7 +24,10 @@ import {
ScoreAggregation,
} from "./scores_converters";
import { SCORE_TO_TRACE_OBSERVATIONS_INTERVAL } from "./constants";
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
import {
convertDateToClickhouseDateTime,
PreferredClickhouseService,
} from "../clickhouse/client";
import { ScoreRecordReadType } from "./definitions";
import { env } from "../../env";
import { _handleGetScoreById, _handleGetScoresByIds } from "./scores-utils";
@@ -152,6 +155,7 @@ export type GetScoresForTracesProps<
clickhouseConfigs?: ClickHouseClientConfigOptions;
excludeMetadata?: ExcludeMetadata;
includeHasMetadata?: IncludeHasMetadata;
preferredClickhouseService?: PreferredClickhouseService;
};
type GetScoresForSessionsProps<
@@ -375,6 +379,7 @@ export const getScoresForTraces = async <
clickhouseConfigs,
excludeMetadata = false,
includeHasMetadata = false,
preferredClickhouseService,
} = props;
const select = formatMetadataSelect(excludeMetadata, includeHasMetadata);
@@ -417,6 +422,7 @@ export const getScoresForTraces = async <
projectId,
},
clickhouseConfigs,
preferredClickhouseService,
});
return rows.map((row) => {
@@ -1447,6 +1453,9 @@ export const getScoresForPostHog = async function* (
s.data_type as data_type,
s.comment as comment,
s.environment as environment,
s.trace_id as score_trace_id,
s.session_id as score_session_id,
s.dataset_run_id as score_dataset_run_id,
t.id as trace_id,
t.name as trace_name,
t.session_id as trace_session_id,
@@ -1458,11 +1467,21 @@ export const getScoresForPostHog = async function* (
FROM scores s FINAL
LEFT JOIN ${traceTable} t FINAL ON s.trace_id = t.id AND s.project_id = t.project_id
WHERE s.project_id = {projectId: String}
AND t.project_id = {projectId: String}
AND s.timestamp >= {minTimestamp: DateTime64(3)}
AND s.timestamp <= {maxTimestamp: DateTime64(3)}
AND t.timestamp >= {minTimestamp: DateTime64(3)} - INTERVAL 7 DAY
AND t.timestamp <= {maxTimestamp: DateTime64(3)}
AND (
s.trace_id IS NOT NULL
OR s.session_id IS NOT NULL
OR s.dataset_run_id IS NOT NULL
)
AND (
t.project_id = '' -- use the default value for the string type to filter for absence
OR (
t.project_id = {projectId: String}
AND t.timestamp >= {minTimestamp: DateTime64(3)} - INTERVAL 7 DAY
AND t.timestamp <= {maxTimestamp: DateTime64(3)}
)
)
`;
const records = queryClickhouseStream<Record<string, unknown>>({
@@ -1490,6 +1509,13 @@ export const getScoresForPostHog = async function* (
const baseUrl = env.NEXTAUTH_URL?.replace("/api/auth", "");
for await (const record of records) {
// Determine the effective session_id based on score attachment
const effectiveSessionId =
record.score_session_id || record.trace_session_id;
// Determine the effective trace_id (could be null for session-only or dataset-run-only scores)
const effectiveTraceId = record.score_trace_id || null;
yield {
timestamp: record.timestamp,
langfuse_score_name: record.name,
@@ -1499,15 +1525,23 @@ export const getScoresForPostHog = async function* (
langfuse_score_string_value: record.string_value,
langfuse_score_data_type: record.data_type,
langfuse_trace_name: record.trace_name,
langfuse_trace_id: record.trace_id,
langfuse_trace_id: effectiveTraceId,
langfuse_id: record.id,
langfuse_session_id: record.trace_session_id,
langfuse_session_id: effectiveSessionId,
langfuse_project_id: projectId,
langfuse_user_id: record.trace_user_id || null,
langfuse_release: record.trace_release,
langfuse_tags: record.trace_tags,
langfuse_environment: record.environment,
langfuse_event_version: "1.0.0",
langfuse_score_entity_type: record.score_trace_id
? "trace"
: record.score_session_id
? "session"
: record.score_dataset_run_id
? "dataset_run"
: "unknown",
langfuse_dataset_run_id: record.score_dataset_run_id,
$session_id: record.posthog_session_id ?? null,
...(record.trace_user_id
? {
@@ -15,12 +15,12 @@ import {
FilterList,
StringFilter,
} from "../queries/clickhouse-sql/clickhouse-filter";
import { TraceRecordReadType, convertTraceToTraceNull } from "./definitions";
import { TraceRecordReadType } from "./definitions";
import { tracesTableUiColumnDefinitions } from "../tableMappings/mapTracesTable";
import { UiColumnMappings } from "../../tableDefinitions";
import {
clickhouseClient,
convertDateToClickhouseDateTime,
PreferredClickhouseService,
} from "../clickhouse/client";
import { convertClickhouseToDomain } from "./traces_converters";
import { clickhouseSearchCondition } from "../queries/clickhouse-sql/search";
@@ -57,6 +57,7 @@ export const getTimeframesTracesAMT = (
): TracesAMTs => {
if (!fromTimestamp) {
// The TracesAllAMT must always be returned if there is no timestamp.
console.log("No timestamp provided, returning TracesAllAMT");
return TracesAMTs.TracesAllAMT;
}
@@ -89,6 +90,8 @@ export const getTimeframesTracesAMT = (
* @param {string} traceId - ID of the trace to check
* @param {Date} timestamp - Timestamp for time-based filtering, uses event payload or job timestamp
* @param {FilterState} filter - Filter for the trace
* @param {Date} maxTimeStamp - Upper bound on timestamp
* @param {Date} exactTimestamp - Exact match for the trace
* @returns {Promise<boolean>} - True if trace exists
*
* Notes:
@@ -197,10 +200,10 @@ export const checkTraceExistsAndGetTimestamp = async ({
${observationFilterRes ? `INNER JOIN observations_agg o ON t.id = o.trace_id AND t.project_id = o.project_id` : ""}
WHERE ${tracesFilterRes.query}
AND t.project_id = {projectId: String}
AND timestamp >= {timestamp: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}
${maxTimeStamp ? `AND timestamp <= {maxTimeStamp: DateTime64(3)}` : ""}
${!maxTimeStamp ? `AND timestamp <= {timestamp: DateTime64(3)} + INTERVAL 2 DAY` : ""}
${exactTimestamp ? `AND timestamp = {exactTimestamp: DateTime64(3)}` : ""}
AND t.timestamp >= {timestamp: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}
${maxTimeStamp ? `AND t.timestamp <= {maxTimeStamp: DateTime64(3)}` : ""}
${!maxTimeStamp ? `AND t.timestamp <= {timestamp: DateTime64(3)} + INTERVAL 2 DAY` : ""}
${exactTimestamp ? `AND toDate(t.timestamp) = toDate({exactTimestamp: DateTime64(3)})` : ""}
GROUP BY t.id, t.project_id, t.timestamp
`;
@@ -278,38 +281,6 @@ export const upsertTrace = async (trace: Partial<TraceRecordReadType>) => {
projectId: trace.project_id ?? "",
},
});
// Also insert into traces_null if experiment flag is enabled
if (env.LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES === "true") {
// Convert trace to insert format first (since we have read format)
const traceRecord = trace as TraceRecordReadType;
const traceInsert = {
...traceRecord,
timestamp: new Date(traceRecord.timestamp).getTime(),
created_at: new Date(traceRecord.created_at).getTime(),
updated_at: new Date(traceRecord.updated_at).getTime(),
event_ts: new Date(traceRecord.event_ts).getTime(),
is_deleted: 0,
};
// Convert to traces_null format
const traceNull = convertTraceToTraceNull(traceInsert);
// Insert directly into traces_null using clickhouse client
await clickhouseClient().insert({
table: "traces_null",
format: "JSONEachRow",
values: [traceNull],
clickhouse_settings: {
log_comment: JSON.stringify({
feature: "tracing",
type: "traces_null",
kind: "upsert",
experiment: "insert_into_aggregating_merge_trees",
}),
},
});
}
};
export const getTracesByIds = async (
@@ -691,6 +662,7 @@ export const getTraceById = async ({
fromTimestamp,
renderingProps = DEFAULT_RENDERING_PROPS,
clickhouseFeatureTag = "tracing",
preferredClickhouseService,
}: {
traceId: string;
projectId: string;
@@ -698,6 +670,7 @@ export const getTraceById = async ({
fromTimestamp?: Date;
renderingProps?: RenderingProps;
clickhouseFeatureTag?: string;
preferredClickhouseService?: PreferredClickhouseService;
}) => {
const records = await measureAndReturn({
operationName: "getTraceById",
@@ -756,6 +729,7 @@ export const getTraceById = async ({
query,
params: input.params,
tags: { ...input.tags, experiment_amt: "original" },
preferredClickhouseService,
});
},
newExecution: (input) => {
@@ -791,6 +765,7 @@ export const getTraceById = async ({
query,
params: input.params,
tags: { ...input.tags, experiment_amt: "new" },
preferredClickhouseService,
});
},
});
@@ -24,11 +24,18 @@ type UploadFile = {
fileName: string;
fileType: string;
data: Readable | string;
};
type UploadWithSignedUrl = UploadFile & {
expiresInSeconds: number;
};
export interface StorageService {
uploadFile(params: UploadFile): Promise<{ signedUrl: string }>; // eslint-disable-line no-unused-vars
uploadFile(params: UploadFile): Promise<void>; // eslint-disable-line no-unused-vars
uploadWithSignedUrl(
params: UploadWithSignedUrl, // eslint-disable-line no-unused-vars
): Promise<{ signedUrl: string }>;
uploadJson(path: string, body: Record<string, unknown>[]): Promise<void>; // eslint-disable-line no-unused-vars
@@ -161,8 +168,8 @@ class AzureBlobStorageService implements StorageService {
}
}
public async uploadFile(params: UploadFile): Promise<{ signedUrl: string }> {
const { fileName, data, expiresInSeconds } = params;
public async uploadFile(params: UploadFile): Promise<void> {
const { fileName, data } = params;
try {
await this.createContainerIfNotExists();
@@ -196,6 +203,23 @@ class AzureBlobStorageService implements StorageService {
throw new Error("Unsupported data type. Must be Readable or string.");
}
return;
} catch (err) {
logger.error(
`Failed to upload file to Azure Blob Storage ${fileName}`,
err,
);
throw Error("Failed to upload file to Azure Blob Storage");
}
}
public async uploadWithSignedUrl(
params: UploadWithSignedUrl,
): Promise<{ signedUrl: string }> {
const { fileName, data, fileType, expiresInSeconds } = params;
try {
await this.uploadFile({ fileName, data, fileType });
return {
signedUrl: await this.getSignedUrl(fileName, expiresInSeconds, false),
};
@@ -454,8 +478,7 @@ class S3StorageService implements StorageService {
fileName,
fileType,
data,
expiresInSeconds,
}: UploadFile): Promise<{ signedUrl: string }> {
}: UploadFile): Promise<void> {
try {
await new Upload({
client: this.client,
@@ -467,6 +490,22 @@ class S3StorageService implements StorageService {
}),
}).done();
return;
} catch (err) {
logger.error(`Failed to upload file to ${fileName}`, err);
throw new Error(`Failed to upload to S3: ${err}`);
}
}
public async uploadWithSignedUrl({
fileName,
fileType,
data,
expiresInSeconds,
}: UploadWithSignedUrl): Promise<{ signedUrl: string }> {
try {
await this.uploadFile({ fileName, data, fileType });
const signedUrl = await this.getSignedUrl(fileName, expiresInSeconds);
return { signedUrl };
@@ -663,8 +702,7 @@ class GoogleCloudStorageService implements StorageService {
fileName,
fileType,
data,
expiresInSeconds,
}: UploadFile): Promise<{ signedUrl: string }> {
}: UploadFile): Promise<void> {
try {
const file = this.bucket.file(fileName);
const options = {
@@ -674,27 +712,18 @@ class GoogleCloudStorageService implements StorageService {
if (typeof data === "string") {
await file.save(data, options);
const signedUrl = await this.getSignedUrl(fileName, expiresInSeconds);
return { signedUrl };
return;
} else if (data instanceof Readable) {
return new Promise((resolve, reject) => {
const writeStream = file.createWriteStream(options);
data
.pipe(writeStream)
.on("error", (err) => {
.on("error", (err: unknown) => {
reject(err);
})
.on("finish", async () => {
try {
const signedUrl = await this.getSignedUrl(
fileName,
expiresInSeconds,
);
resolve({ signedUrl });
} catch (err) {
reject(err);
}
.on("finish", () => {
resolve();
});
});
} else {
@@ -709,6 +738,25 @@ class GoogleCloudStorageService implements StorageService {
}
}
public async uploadWithSignedUrl({
fileName,
fileType,
data,
expiresInSeconds,
}: UploadWithSignedUrl): Promise<{ signedUrl: string }> {
try {
await this.uploadFile({ fileName, data, fileType });
const signedUrl = await this.getSignedUrl(fileName, expiresInSeconds);
return { signedUrl };
} catch (err) {
logger.error(
`Failed to upload file to Google Cloud Storage ${fileName}`,
err,
);
throw new Error("Failed to upload to Google Cloud Storage");
}
}
public async uploadJson(
path: string,
body: Record<string, unknown>[],
@@ -76,7 +76,7 @@ export const sendMembershipInvitationEmail = async ({
await mailer.sendMail({
to,
from: `Langfuse <${env.EMAIL_FROM_ADDRESS}>`,
subject: `${inviterName} invited you to join "${orgName}" organization on Langfuse`,
subject: `${inviterName} invited you to join the "${orgName}" organization on Langfuse`,
html: htmlTemplate,
});
} catch (error) {
@@ -322,7 +322,7 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
),
session_data AS (
SELECT
t.session_id,
t.session_id as session_id,
anyLast(t.project_id) as project_id,
max(t.timestamp) as max_timestamp,
min(t.timestamp) as min_timestamp,
@@ -32,6 +32,18 @@ export const datasetRunItemsTableUiColumnDefinitions: UiColumnMappings = [
clickhouseTableName: "dataset_run_items_rmt",
clickhouseSelect: 'dri."dataset_id"',
},
{
uiTableName: "Scores (numeric)",
uiTableId: "agg_scores_avg",
clickhouseTableName: "scores",
clickhouseSelect: "sa.scores_avg",
},
{
uiTableName: "Scores (categorical)",
uiTableId: "agg_score_categories",
clickhouseTableName: "scores",
clickhouseSelect: "sa.score_categories",
},
];
export const mapDatasetRunItemFilterColumn = (
@@ -4,18 +4,9 @@ import {
ObservationRecordInsertType,
ScoreRecordInsertType,
DatasetRunItemRecordInsertType,
convertTraceToTraceNull,
} from "../repositories/definitions";
import { env } from "../../env";
export const createTracesCh = async (trace: TraceRecordInsertType[]) => {
if (env.LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES === "true") {
await clickhouseClient().insert({
table: "traces_null",
format: "JSONEachRow",
values: trace.map(convertTraceToTraceNull),
});
}
return await clickhouseClient().insert({
table: "traces",
format: "JSONEachRow",
@@ -44,8 +44,16 @@ const blockedNetworks = BLOCKED_CIDRS.map((cidr) => {
/**
* Check if an IP address is blocked based on CIDR ranges
*/
export function isIPBlocked(ipString: string): boolean {
export function isIPBlocked(
ipString: string,
whitelistedIPs: string[],
): boolean {
try {
// Check if IP is in whitelist first
if (whitelistedIPs.includes(ipString.toLowerCase().trim())) {
return false;
}
const ip = ipaddr.parse(ipString);
for (const { network, mask, kind } of blockedNetworks) {
@@ -1,6 +1,7 @@
import dns from "node:dns/promises";
import { URL } from "node:url";
import { isIPBlocked, isIPAddress, isHostnameBlocked } from "./ipBlocking";
import { env } from "../../env";
export async function resolveHost(hostname: string): Promise<string[]> {
// Returns every A + AAAA address
@@ -64,7 +65,7 @@ export async function validateWebhookURL(urlString: string): Promise<void> {
// Step 4: Check for IP address literals in hostname
if (isIPAddress(hostname)) {
if (isIPBlocked(hostname)) {
if (isIPBlocked(hostname, env.LANGFUSE_WEBHOOK_WHITELISTED_IPS)) {
throw new Error(`Blocked IP address detected: ${hostname}`);
}
}
@@ -72,7 +73,7 @@ export async function validateWebhookURL(urlString: string): Promise<void> {
// Step 5: DNS resolution and validation
const ips = await resolveHost(hostname);
for (const ip of ips) {
if (isIPBlocked(ip)) {
if (isIPBlocked(ip, env.LANGFUSE_WEBHOOK_WHITELISTED_IPS)) {
throw new Error(`Blocked IP address detected: ${ip}`);
}
}
@@ -0,0 +1,40 @@
import { type ColumnDefinition, type MultiValueOption } from "..";
import { formatColumnOptions } from "./typeHelpers";
export const datasetRunItemsTableCols: ColumnDefinition[] = [
{
name: "Scores (numeric)",
id: "agg_scores_avg",
type: "numberObject",
internal: "agg_scores_avg",
nullable: true,
},
{
name: "Scores (categorical)",
id: "agg_score_categories",
type: "categoryOptions",
internal: "agg_score_categories",
options: [], // to be filled in at runtime
nullable: true,
},
];
export type DatasetRunItemsOptions = {
agg_scores_avg?: Array<string>;
agg_score_categories?: Array<MultiValueOption>;
};
export function datasetRunItemsTableColsWithOptions(
options?: DatasetRunItemsOptions,
cols: ColumnDefinition[] = datasetRunItemsTableCols,
): ColumnDefinition[] {
return cols.map((col) => {
if (col.id === "agg_scores_avg") {
return formatColumnOptions(col, options?.agg_scores_avg ?? []);
}
if (col.id === "agg_score_categories") {
return formatColumnOptions(col, options?.agg_score_categories ?? []);
}
return col;
});
}
@@ -5,3 +5,4 @@ export * from "./promptsTable";
export * from "./datasetItemsTable";
export * from "./mapDatasetRunsTable";
export * from "./datasetRunsTable";
export * from "./datasetRunItemsTable";
+2 -1
View File
@@ -39,4 +39,5 @@ export type TableName =
| "eval_configs"
| "dataset_items"
| "job_executions"
| "dataset_runs";
| "dataset_runs"
| "dataset_run_items_by_run";
+1288 -1174
View File
File diff suppressed because it is too large Load Diff
+36 -10
View File
@@ -1,15 +1,26 @@
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": [".env"],
"globalDependencies": [
".env"
],
"envMode": "loose",
"tasks": {
"build": {
"dependsOn": ["db:generate", "^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"],
"dependsOn": [
"db:generate",
"^build"
],
"outputs": [
"dist/**",
".next/**",
"!.next/cache/**"
],
"cache": true
},
"start": {
"dependsOn": ["^start"]
"dependsOn": [
"^start"
]
},
"db:migrate": {
"cache": false
@@ -22,29 +33,44 @@
"dev": {
"cache": false,
"persistent": true,
"dependsOn": ["db:generate"]
"dependsOn": [
"db:generate"
]
},
"dev:worker": {
"cache": false,
"persistent": true,
"dependsOn": ["db:generate"]
"dependsOn": [
"db:generate",
]
},
"dev:web": {
"cache": false,
"persistent": true,
"dependsOn": ["db:generate"]
"dependsOn": [
"db:generate"
]
},
"db:generate": {
"cache": false,
"dependsOn": ["^db:generate"]
"dependsOn": [
"^db:generate"
]
},
"lint": {
"cache": true,
"outputs": []
},
"test": {
"dependsOn": ["^test", "db:generate"],
"dependsOn": [
"^test",
"db:generate"
],
"cache": true
},
"worker#dev": {
"cache": false,
"persistent": true
}
}
}
}
+2 -2
View File
@@ -5,7 +5,7 @@ FROM --platform=${TARGETPLATFORM:-linux/amd64} node:24-alpine AS alpine
RUN apk update && apk upgrade --no-cache libcrypto3 libssl3 libc6-compat busybox ssl_client
FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine AS base
RUN npm install turbo@^2.5.6 --global
RUN npm install turbo@^2.5.8 --global
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
@@ -125,7 +125,7 @@ RUN if [ -n "$NEXT_PUBLIC_LANGFUSE_CLOUD_REGION" ]; then \
fi
RUN MIGRATE_TARGET_ARCH=$(echo ${TARGETPLATFORM:-linux/amd64} | sed 's/\//-/g') && \
wget -q -O- https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.$MIGRATE_TARGET_ARCH.tar.gz | tar xvz && \
wget -q -O- https://github.com/golang-migrate/migrate/releases/download/v4.19.0/migrate.$MIGRATE_TARGET_ARCH.tar.gz | tar xvz && \
mv migrate /usr/bin/migrate
COPY --from=builder --chown=nextjs:nodejs /app/web/next.config.mjs .
@@ -46,9 +46,26 @@ Sentry.init({
// For example, a tracesSampleRate of 0.5 and profilesSampleRate of 0.5 would
// result in 25% of transactions being profiled (0.5*0.5=0.25)
profilesSampleRate: 0.5,
// ...
// Filter out browser extension errors
// see: https://docs.sentry.io/platforms/javascript/configuration/filtering/#using-allowurls-and-denyurls
denyUrls: [
// Chrome extensions
/chrome-extension:\/\//i,
// Firefox extensions
/moz-extension:\/\//i,
// Safari extensions
/safari-extension:\/\//i,
// Edge extensions
/ms-browser-extension:\/\//i,
// Generic browser extension patterns
/app:\/\/\/scripts\//i,
],
// Note: if you want to override the automatic release value, do not set a
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
// that it will also get attached to your source maps
});
// Export router transition start hook for navigation instrumentation
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
+11 -7
View File
@@ -68,15 +68,17 @@ const nextConfig = {
// this is an ugly hack to get turbopack to work with react-resizable, used in the
// web/src/features/widgets/components/DashboardGrid.tsx file. This **only** affects
// the dev server. The CSS is included in the non-turbopack based prod build anyways.
// Also not needed for the non-turbopack based dev server.
"react-resizable/css/styles.css":
"../node_modules/.pnpm/react-resizable@3.0.5_react-dom@19.1.1_react@19.1.1__react@19.1.1/node_modules/react-resizable/css/styles.css",
},
},
// TODO: enable with new next version! 15.6
// see: https://nextjs.org/docs/app/api-reference/config/next-config-js/turbopackPersistentCaching
// experimental:{
// turbopackPersistentCaching: true,
// },
experimental: {
browserDebugInfoInTerminal: true, // Logs browser logs to terminal
// TODO: enable with new next version! 15.6
// see: https://nextjs.org/docs/app/api-reference/config/next-config-js/turbopackPersistentCaching
// turbopackPersistentCaching: true,
},
/**
* If you have `experimental: { appDir: true }` set, then you must comment the below `i18n` config
@@ -228,7 +230,9 @@ const sentryConfig = withSentryConfig(nextConfig, {
// tunnelRoute: "/api/monitoring-tunnel",
// Hides source maps from generated client bundles
hideSourceMaps: true,
sourcemaps: {
disable: true,
},
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
@@ -243,7 +247,7 @@ const sentryConfig = withSentryConfig(nextConfig, {
// Enable bundle analyzer in analyze mode, otherwise use standard config
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === "true",
openAnalyzer: false, // Open analyzer in browser
openAnalyzer: true, // Open analyzer in browser
});
export default withBundleAnalyzer(sentryConfig);
+11 -8
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.108.0",
"version": "3.114.0",
"private": true,
"license": "MIT",
"engines": {
@@ -8,7 +8,7 @@
},
"scripts": {
"build": "INLINE_RUNTIME_CHUNK=false dotenv -e ../.env -- next build",
"dev": "dotenv -e ../.env -- next dev",
"dev": "dotenv -e ../.env -- next dev --turbopack",
"dev:http": "dotenv -e ../.env -- next dev --experimental-https --experimental-https-key ./localhost+1-key.pem --experimental-https-cert ./localhost+1.pem",
"lint": "dotenv -e ../.env -- next lint --max-warnings 0",
"lint:fix": "dotenv -e ../.env -- next lint --fix",
@@ -19,7 +19,8 @@
"test-client": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env.test -e ../.env -- jest --verbose --runInBand --detectOpenHandles --selectProjects client",
"test:watch": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env.test -e ../.env -- jest --watch --runInBand",
"test:e2e": "dotenv -e ../.env.test -e ../.env -- playwright test --reporter=line",
"test:e2e:server": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env.test -e ../.env -- jest --runInBand --detectOpenHandles --verbose --selectProjects e2e-server"
"test:e2e:server": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env.test -e ../.env -- jest --runInBand --detectOpenHandles --verbose --selectProjects e2e-server",
"build-trace": "dotenv -e ../.env -- next internal trace .next/trace-turbopack"
},
"dependencies": {
"@anthropic-ai/tokenizer": "^0.0.4",
@@ -84,7 +85,7 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@remixicon/react": "^4.2.0",
"@sentry/nextjs": "^8.52.0",
"@sentry/nextjs": "^10.11.0",
"@t3-oss/env-nextjs": "^0.11.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/react-query": "^5.85.1",
@@ -117,15 +118,17 @@
"ioredis": "^5.4.1",
"ip-address": "^9.0.5",
"kysely": "^0.27.4",
"langchain": "^0.3.28",
"langchain": "^0.3.30",
"langfuse": "3.38.4",
"lodash": "^4.17.21",
"lucide-react": "^0.462.0",
"nanoid": "^3.3.8",
"next": "15.5.2",
"next-auth": "^4.24.11",
"next-query-params": "^5.1.0",
"next-themes": "^0.4.6",
"posthog-js": "^1.176.0",
"posthog-node": "^4.3.1",
"posthog-js": "^1.266.0",
"posthog-node": "^5.8.4",
"prexit": "^2.2.0",
"prism-react-renderer": "^2.4.1",
"prisma": "^6.10.1",
@@ -149,7 +152,7 @@
"remark-rehype": "^11.1.1",
"remark-stringify": "^11.0.0",
"sonner": "^2.0.7",
"stripe": "^17.4.0",
"stripe": "^18.5.0",
"superjson": "2.2.2",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
+423 -8
View File
@@ -560,6 +560,146 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/AnnotationQueueAssignmentRequest'
/api/public/integrations/blob-storage:
get:
description: >-
Get all blob storage integrations for the organization (requires
organization-scoped API key)
operationId: blobStorageIntegrations_getBlobStorageIntegrations
tags:
- BlobStorageIntegrations
parameters: []
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/BlobStorageIntegrationsResponse'
'400':
description: ''
content:
application/json:
schema: {}
'401':
description: ''
content:
application/json:
schema: {}
'403':
description: ''
content:
application/json:
schema: {}
'404':
description: ''
content:
application/json:
schema: {}
'405':
description: ''
content:
application/json:
schema: {}
security:
- BasicAuth: []
put:
description: >-
Create or update a blob storage integration for a specific project
(requires organization-scoped API key). The configuration is validated
by performing a test upload to the bucket.
operationId: blobStorageIntegrations_upsertBlobStorageIntegration
tags:
- BlobStorageIntegrations
parameters: []
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/BlobStorageIntegrationResponse'
'400':
description: ''
content:
application/json:
schema: {}
'401':
description: ''
content:
application/json:
schema: {}
'403':
description: ''
content:
application/json:
schema: {}
'404':
description: ''
content:
application/json:
schema: {}
'405':
description: ''
content:
application/json:
schema: {}
security:
- BasicAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateBlobStorageIntegrationRequest'
/api/public/integrations/blob-storage/{id}:
delete:
description: >-
Delete a blob storage integration by ID (requires organization-scoped
API key)
operationId: blobStorageIntegrations_deleteBlobStorageIntegration
tags:
- BlobStorageIntegrations
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/BlobStorageIntegrationDeletionResponse'
'400':
description: ''
content:
application/json:
schema: {}
'401':
description: ''
content:
application/json:
schema: {}
'403':
description: ''
content:
application/json:
schema: {}
'404':
description: ''
content:
application/json:
schema: {}
'405':
description: ''
content:
application/json:
schema: {}
security:
- BasicAuth: []
/api/public/comments:
post:
description: >-
@@ -1412,11 +1552,11 @@ paths:
/api/public/ingestion:
post:
description: >-
Batched ingestion for Langfuse Tracing.
**Legacy endpoint for batch ingestion for Langfuse Observability.**
If you want to use tracing via the API, such as to build your own
Langfuse client implementation, this is the only API route you need to
implement.
-> Please use the OpenTelemetry endpoint (`/api/public/otel`). Learn
more: https://langfuse.com/integrations/native/opentelemetry
Within each batch, there can be multiple events.
@@ -1440,7 +1580,7 @@ paths:
Notes:
- Introduction to data model:
https://langfuse.com/docs/tracing-data-model
https://langfuse.com/docs/observability/data-model
- Batch sizes are limited to 3.5 MB in total. You need to adjust the
number of events per batch accordingly.
@@ -3773,6 +3913,58 @@ paths:
schema: {}
security:
- BasicAuth: []
patch:
description: Update a score config
operationId: scoreConfigs_update
tags:
- ScoreConfigs
parameters:
- name: configId
in: path
description: The unique langfuse identifier of a score config
required: true
schema:
type: string
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/ScoreConfig'
'400':
description: ''
content:
application/json:
schema: {}
'401':
description: ''
content:
application/json:
schema: {}
'403':
description: ''
content:
application/json:
schema: {}
'404':
description: ''
content:
application/json:
schema: {}
'405':
description: ''
content:
application/json:
schema: {}
security:
- BasicAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateScoreConfigRequest'
/api/public/v2/scores:
get:
description: Get a list of scores (supports both trace and session scores)
@@ -3877,6 +4069,13 @@ paths:
schema:
type: string
nullable: true
- name: sessionId
in: query
description: Retrieve only scores with a specific sessionId.
required: false
schema:
type: string
nullable: true
- name: queueId
in: query
description: Retrieve only scores with a specific annotation queueId.
@@ -4413,9 +4612,11 @@ paths:
in: query
description: >-
Comma-separated list of fields to include in the response. Available
field groups are 'core' (always included), 'io' (input, output,
metadata), 'scores', 'observations', 'metrics'. If not provided, all
fields are included. Example: 'core,scores,metrics'
field groups: 'core' (always included), 'io' (input, output,
metadata), 'scores', 'observations', 'metrics'. If not specified,
all fields are returned. Example: 'core,scores,metrics'. Note:
Excluded 'observations' or 'scores' fields return empty arrays;
excluded 'metrics' returns -1 for 'totalCost' and 'latency'.
required: false
schema:
type: string
@@ -4686,6 +4887,178 @@ components:
- userId
- queueId
- projectId
BlobStorageIntegrationType:
title: BlobStorageIntegrationType
type: string
enum:
- S3
- S3_COMPATIBLE
- AZURE_BLOB_STORAGE
BlobStorageIntegrationFileType:
title: BlobStorageIntegrationFileType
type: string
enum:
- JSON
- CSV
- JSONL
BlobStorageExportMode:
title: BlobStorageExportMode
type: string
enum:
- FULL_HISTORY
- FROM_TODAY
- FROM_CUSTOM_DATE
BlobStorageExportFrequency:
title: BlobStorageExportFrequency
type: string
enum:
- hourly
- daily
- weekly
CreateBlobStorageIntegrationRequest:
title: CreateBlobStorageIntegrationRequest
type: object
properties:
projectId:
type: string
description: ID of the project in which to configure the blob storage integration
type:
$ref: '#/components/schemas/BlobStorageIntegrationType'
bucketName:
type: string
description: Name of the storage bucket
endpoint:
type: string
nullable: true
description: Custom endpoint URL (required for S3_COMPATIBLE type)
region:
type: string
description: Storage region
accessKeyId:
type: string
nullable: true
description: Access key ID for authentication
secretAccessKey:
type: string
nullable: true
description: Secret access key for authentication (will be encrypted when stored)
prefix:
type: string
nullable: true
description: >-
Path prefix for exported files (must end with forward slash if
provided)
exportFrequency:
$ref: '#/components/schemas/BlobStorageExportFrequency'
enabled:
type: boolean
description: Whether the integration is active
forcePathStyle:
type: boolean
description: Use path-style URLs for S3 requests
fileType:
$ref: '#/components/schemas/BlobStorageIntegrationFileType'
exportMode:
$ref: '#/components/schemas/BlobStorageExportMode'
exportStartDate:
type: string
format: date-time
nullable: true
description: >-
Custom start date for exports (required when exportMode is
FROM_CUSTOM_DATE)
required:
- projectId
- type
- bucketName
- region
- exportFrequency
- enabled
- forcePathStyle
- fileType
- exportMode
BlobStorageIntegrationResponse:
title: BlobStorageIntegrationResponse
type: object
properties:
id:
type: string
projectId:
type: string
type:
$ref: '#/components/schemas/BlobStorageIntegrationType'
bucketName:
type: string
endpoint:
type: string
nullable: true
region:
type: string
accessKeyId:
type: string
nullable: true
prefix:
type: string
exportFrequency:
$ref: '#/components/schemas/BlobStorageExportFrequency'
enabled:
type: boolean
forcePathStyle:
type: boolean
fileType:
$ref: '#/components/schemas/BlobStorageIntegrationFileType'
exportMode:
$ref: '#/components/schemas/BlobStorageExportMode'
exportStartDate:
type: string
format: date-time
nullable: true
nextSyncAt:
type: string
format: date-time
nullable: true
lastSyncAt:
type: string
format: date-time
nullable: true
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
required:
- id
- projectId
- type
- bucketName
- region
- prefix
- exportFrequency
- enabled
- forcePathStyle
- fileType
- exportMode
- createdAt
- updatedAt
BlobStorageIntegrationsResponse:
title: BlobStorageIntegrationsResponse
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/BlobStorageIntegrationResponse'
required:
- data
BlobStorageIntegrationDeletionResponse:
title: BlobStorageIntegrationDeletionResponse
type: object
properties:
message:
type: string
required:
- message
CreateCommentRequest:
title: CreateCommentRequest
type: object
@@ -7821,6 +8194,48 @@ components:
required:
- name
- dataType
UpdateScoreConfigRequest:
title: UpdateScoreConfigRequest
type: object
properties:
isArchived:
type: boolean
nullable: true
description: The status of the score config showing if it is archived or not
name:
type: string
nullable: true
description: The name of the score config
categories:
type: array
items:
$ref: '#/components/schemas/ConfigCategory'
nullable: true
description: >-
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
minValue:
type: number
format: double
nullable: true
description: >-
Configure a minimum value for numerical scores. If not set, the
minimum value defaults to -∞
maxValue:
type: number
format: double
nullable: true
description: >-
Configure a maximum value for numerical scores. If not set, the
maximum value defaults to +∞
description:
type: string
nullable: true
description: >-
Description is shown across the Langfuse UI and can be used to e.g.
explain the config categories in detail, why a numeric range was
set, or provide additional context on config name or usage
GetScoresResponseTraceData:
title: GetScoresResponseTraceData
type: object
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,619 @@
/** @jest-environment node */
import {
makeZodVerifiedAPICall,
makeAPICall,
} from "@/src/__tests__/test-utils";
import { prisma } from "@langfuse/shared/src/db";
import { z } from "zod/v4";
import { randomUUID } from "crypto";
import {
createAndAddApiKeysToDb,
createBasicAuthHeader,
} from "@langfuse/shared/src/server";
// Schemas based on Fern schema definition
const BlobStorageIntegrationResponseSchema = z.object({
id: z.string(),
projectId: z.string(),
type: z.enum(["S3", "S3_COMPATIBLE", "AZURE_BLOB_STORAGE"]),
bucketName: z.string(),
endpoint: z.string().nullable(),
region: z.string(),
accessKeyId: z.string().nullable(),
prefix: z.string(),
exportFrequency: z.enum(["hourly", "daily", "weekly"]),
enabled: z.boolean(),
forcePathStyle: z.boolean(),
fileType: z.enum(["JSON", "CSV", "JSONL"]),
exportMode: z.enum(["FULL_HISTORY", "FROM_TODAY", "FROM_CUSTOM_DATE"]),
exportStartDate: z.coerce.date().nullable(),
nextSyncAt: z.coerce.date().nullable(),
lastSyncAt: z.coerce.date().nullable(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
const BlobStorageIntegrationsResponseSchema = z.object({
data: z.array(BlobStorageIntegrationResponseSchema),
});
const BlobStorageIntegrationDeletionResponseSchema = z.object({
message: z.string(),
});
// Valid blob storage integration request payload
const validBlobStorageConfig = {
projectId: "",
type: "S3" as const,
bucketName: "test-bucket",
endpoint: null,
region: "us-east-1",
accessKeyId: "AKIA123456789",
secretAccessKey: "secret123456789",
prefix: "langfuse-exports/",
exportFrequency: "daily" as const,
enabled: true,
forcePathStyle: false,
fileType: "JSONL" as const,
exportMode: "FULL_HISTORY" as const,
exportStartDate: null,
};
describe("Blob Storage Integrations API", () => {
// Test data
let testOrgId: string;
let testProject1Id: string;
let testProject2Id: string;
let testApiKey: string;
let testApiSecretKey: string;
let otherOrgId: string;
let otherProjectId: string;
beforeAll(async () => {
// Create test organization
const testOrg = await prisma.organization.create({
data: {
name: `Blob Storage Test Org ${randomUUID().substring(0, 8)}`,
cloudConfig: { plan: "Team" },
},
});
testOrgId = testOrg.id;
// Create test projects
const testProject1 = await prisma.project.create({
data: {
name: `Blob Storage Test Project 1 ${randomUUID().substring(0, 8)}`,
orgId: testOrgId,
},
});
testProject1Id = testProject1.id;
const testProject2 = await prisma.project.create({
data: {
name: `Blob Storage Test Project 2 ${randomUUID().substring(0, 8)}`,
orgId: testOrgId,
},
});
testProject2Id = testProject2.id;
// Create organization API key
const orgApiKey = await createAndAddApiKeysToDb({
prisma,
entityId: testOrgId,
scope: "ORGANIZATION",
note: "Test API Key for Blob Storage API",
predefinedKeys: {
publicKey: `pk-lf-blob-${randomUUID().substring(0, 8)}`,
secretKey: `sk-lf-blob-${randomUUID().substring(0, 8)}`,
},
});
testApiKey = orgApiKey.publicKey;
testApiSecretKey = orgApiKey.secretKey;
// Create another organization for cross-org tests
const otherOrg = await prisma.organization.create({
data: {
name: `Other Blob Storage Org ${randomUUID().substring(0, 8)}`,
cloudConfig: { plan: "Team" },
},
});
otherOrgId = otherOrg.id;
const otherProject = await prisma.project.create({
data: {
name: `Other Blob Storage Project ${randomUUID().substring(0, 8)}`,
orgId: otherOrgId,
},
});
otherProjectId = otherProject.id;
});
afterAll(async () => {
// Clean up test data
await prisma.organization.delete({
where: { id: testOrgId },
});
await prisma.organization.delete({
where: { id: otherOrgId },
});
});
describe("GET /api/public/integrations/blob-storage", () => {
let testIntegrationId: string;
beforeAll(async () => {
// Create a test blob storage integration
const integration = await prisma.blobStorageIntegration.create({
data: {
projectId: testProject1Id,
type: "S3",
bucketName: "test-bucket",
region: "us-east-1",
accessKeyId: "test-access-key",
secretAccessKey: "encrypted-secret",
prefix: "langfuse-exports/",
exportFrequency: "daily",
enabled: true,
forcePathStyle: false,
fileType: "JSONL",
exportMode: "FULL_HISTORY",
},
});
testIntegrationId = integration.projectId;
});
afterAll(async () => {
// Clean up test integration
await prisma.blobStorageIntegration.deleteMany({
where: { projectId: testProject1Id },
});
});
it("should get all blob storage integrations for the organization", async () => {
const response = await makeZodVerifiedAPICall(
BlobStorageIntegrationsResponseSchema,
"GET",
"/api/public/integrations/blob-storage",
undefined,
createBasicAuthHeader(testApiKey, testApiSecretKey),
200,
);
expect(response.status).toBe(200);
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data.length).toBe(1);
const integration = response.body.data.find(
(i) => i.id === testIntegrationId,
);
expect(integration).toBeDefined();
expect(integration?.projectId).toBe(testProject1Id);
expect(integration?.type).toBe("S3");
expect(integration?.bucketName).toBe("test-bucket");
expect(integration?.accessKeyId).toBe("test-access-key");
// Verify that secretAccessKey is not returned
expect(integration).not.toHaveProperty("secretAccessKey");
});
it("should return 401 with invalid API key", async () => {
const result = await makeAPICall(
"GET",
"/api/public/integrations/blob-storage",
undefined,
createBasicAuthHeader("invalid-key", "invalid-secret"),
);
expect(result.status).toBe(401);
});
it("should return 403 with project-scoped API key", async () => {
// Create project API key
const projectApiKey = await createAndAddApiKeysToDb({
prisma,
entityId: testProject1Id,
scope: "PROJECT",
note: "Project API Key",
predefinedKeys: {
publicKey: `pk-lf-proj-${randomUUID().substring(0, 8)}`,
secretKey: `sk-lf-proj-${randomUUID().substring(0, 8)}`,
},
});
const result = await makeAPICall(
"GET",
"/api/public/integrations/blob-storage",
undefined,
createBasicAuthHeader(projectApiKey.publicKey, projectApiKey.secretKey),
);
expect(result.status).toBe(403);
expect(result.body.message).toContain(
"Organization-scoped API key required",
);
// Clean up
await prisma.apiKey.delete({ where: { id: projectApiKey.id } });
});
});
describe("PUT /api/public/integrations/blob-storage", () => {
afterEach(async () => {
// Clean up any created integrations
await prisma.blobStorageIntegration.deleteMany({
where: {
projectId: {
in: [testProject1Id, testProject2Id],
},
},
});
});
it("should create a new blob storage integration", async () => {
const requestBody = {
...validBlobStorageConfig,
projectId: testProject1Id,
};
const response = await makeZodVerifiedAPICall(
BlobStorageIntegrationResponseSchema,
"PUT",
"/api/public/integrations/blob-storage",
requestBody,
createBasicAuthHeader(testApiKey, testApiSecretKey),
);
expect(response.status).toBe(200);
expect(response.body.projectId).toBe(testProject1Id);
expect(response.body.type).toBe("S3");
expect(response.body.bucketName).toBe("test-bucket");
expect(response.body.enabled).toBe(true);
// Verify secretAccessKey is not returned
expect(response.body).not.toHaveProperty("secretAccessKey");
// Verify it was saved to database
const savedIntegration = await prisma.blobStorageIntegration.findUnique({
where: { projectId: testProject1Id },
});
expect(savedIntegration).toBeDefined();
expect(savedIntegration?.bucketName).toBe("test-bucket");
});
it("should update an existing blob storage integration", async () => {
// Create initial integration
await prisma.blobStorageIntegration.create({
data: {
projectId: testProject1Id,
type: "S3",
bucketName: "old-bucket",
region: "us-west-1",
accessKeyId: "old-key",
secretAccessKey: "old-secret",
prefix: "old-prefix/",
exportFrequency: "hourly",
enabled: false,
forcePathStyle: true,
fileType: "JSON",
exportMode: "FROM_TODAY",
},
});
const updateBody = {
...validBlobStorageConfig,
projectId: testProject1Id,
bucketName: "updated-bucket",
enabled: true,
exportFrequency: "weekly" as const,
};
const response = await makeZodVerifiedAPICall(
BlobStorageIntegrationResponseSchema,
"PUT",
"/api/public/integrations/blob-storage",
updateBody,
createBasicAuthHeader(testApiKey, testApiSecretKey),
200,
);
expect(response.status).toBe(200);
expect(response.body.bucketName).toBe("updated-bucket");
expect(response.body.enabled).toBe(true);
expect(response.body.exportFrequency).toBe("weekly");
});
it("should validate required fields", async () => {
const invalidBody = {
projectId: testProject1Id,
type: "S3",
// Missing bucketName
region: "us-east-1",
};
const result = await makeAPICall(
"PUT",
"/api/public/integrations/blob-storage",
invalidBody,
createBasicAuthHeader(testApiKey, testApiSecretKey),
);
expect(result.status).toBe(400);
expect(result.body.message).toContain("Invalid request data");
});
it("should validate enum values", async () => {
const invalidBody = {
...validBlobStorageConfig,
projectId: testProject1Id,
type: "INVALID_TYPE",
};
const result = await makeAPICall(
"PUT",
"/api/public/integrations/blob-storage",
invalidBody,
createBasicAuthHeader(testApiKey, testApiSecretKey),
);
expect(result.status).toBe(400);
expect(result.body.message).toContain("Invalid request data");
});
it("should validate prefix format", async () => {
const invalidBody = {
...validBlobStorageConfig,
projectId: testProject1Id,
prefix: "invalid-prefix", // Should end with /
};
const result = await makeAPICall(
"PUT",
"/api/public/integrations/blob-storage",
invalidBody,
createBasicAuthHeader(testApiKey, testApiSecretKey),
);
expect(result.status).toBe(400);
expect(result.body.error).toBeDefined();
});
it("should return 404 for non-existent project", async () => {
const nonExistentProjectId = randomUUID();
const requestBody = {
...validBlobStorageConfig,
projectId: nonExistentProjectId,
};
const result = await makeAPICall(
"PUT",
"/api/public/integrations/blob-storage",
requestBody,
createBasicAuthHeader(testApiKey, testApiSecretKey),
);
expect(result.status).toBe(404);
expect(result.body.message).toContain("Project not found");
});
it("should return 404 for project from different organization", async () => {
const requestBody = {
...validBlobStorageConfig,
projectId: otherProjectId,
};
const result = await makeAPICall(
"PUT",
"/api/public/integrations/blob-storage",
requestBody,
createBasicAuthHeader(testApiKey, testApiSecretKey),
);
expect(result.status).toBe(404);
expect(result.body.message).toContain("Project not found");
});
it("should return 401 with invalid API key", async () => {
const requestBody = {
...validBlobStorageConfig,
projectId: testProject1Id,
};
const result = await makeAPICall(
"PUT",
"/api/public/integrations/blob-storage",
requestBody,
createBasicAuthHeader("invalid-key", "invalid-secret"),
);
expect(result.status).toBe(401);
});
it("should handle different blob storage types", async () => {
const azureConfig = {
...validBlobStorageConfig,
projectId: testProject1Id,
type: "AZURE_BLOB_STORAGE" as const,
endpoint: "https://myaccount.blob.core.windows.net",
};
const response = await makeZodVerifiedAPICall(
BlobStorageIntegrationResponseSchema,
"PUT",
"/api/public/integrations/blob-storage",
azureConfig,
createBasicAuthHeader(testApiKey, testApiSecretKey),
200,
);
expect(response.status).toBe(200);
expect(response.body.type).toBe("AZURE_BLOB_STORAGE");
expect(response.body.endpoint).toBe(
"https://myaccount.blob.core.windows.net",
);
});
it("should handle export modes with dates", async () => {
const customDateConfig = {
...validBlobStorageConfig,
projectId: testProject1Id,
exportMode: "FROM_CUSTOM_DATE" as const,
exportStartDate: "2024-01-01T00:00:00Z",
};
const response = await makeZodVerifiedAPICall(
BlobStorageIntegrationResponseSchema,
"PUT",
"/api/public/integrations/blob-storage",
customDateConfig,
createBasicAuthHeader(testApiKey, testApiSecretKey),
200,
);
expect(response.status).toBe(200);
expect(response.body.exportMode).toBe("FROM_CUSTOM_DATE");
expect(response.body.exportStartDate).toBeDefined();
});
});
describe("DELETE /api/public/integrations/blob-storage/{id}", () => {
let testIntegrationId: string;
beforeEach(async () => {
// Create test integration
const integration = await prisma.blobStorageIntegration.create({
data: {
projectId: testProject1Id,
type: "S3",
bucketName: "test-bucket",
region: "us-east-1",
accessKeyId: "test-key",
secretAccessKey: "test-secret",
prefix: "test/",
exportFrequency: "daily",
enabled: true,
forcePathStyle: false,
fileType: "JSONL",
exportMode: "FULL_HISTORY",
},
});
testIntegrationId = integration.projectId; // Based on current implementation, ID is projectId
});
afterEach(async () => {
// Clean up
await prisma.blobStorageIntegration.deleteMany({
where: { projectId: testProject1Id },
});
});
it("should delete a blob storage integration", async () => {
const response = await makeZodVerifiedAPICall(
BlobStorageIntegrationDeletionResponseSchema,
"DELETE",
`/api/public/integrations/blob-storage/${testIntegrationId}`,
undefined,
createBasicAuthHeader(testApiKey, testApiSecretKey),
200,
);
expect(response.status).toBe(200);
expect(response.body.message).toBeDefined();
// Verify it was deleted from database
const deletedIntegration = await prisma.blobStorageIntegration.findUnique(
{
where: { projectId: testIntegrationId },
},
);
expect(deletedIntegration).toBeNull();
});
it("should return 404 for non-existent integration", async () => {
const nonExistentId = randomUUID();
const result = await makeAPICall(
"DELETE",
`/api/public/integrations/blob-storage/${nonExistentId}`,
undefined,
createBasicAuthHeader(testApiKey, testApiSecretKey),
);
expect(result.status).toBe(404);
});
it("should return 404 for integration from different organization", async () => {
// Create integration in other org
const otherOrgIntegration = await prisma.blobStorageIntegration.create({
data: {
projectId: otherProjectId,
type: "S3",
bucketName: "other-bucket",
region: "us-east-1",
accessKeyId: "other-key",
secretAccessKey: "other-secret",
prefix: "other/",
exportFrequency: "daily",
enabled: true,
forcePathStyle: false,
fileType: "JSONL",
exportMode: "FULL_HISTORY",
},
});
const result = await makeAPICall(
"DELETE",
`/api/public/integrations/blob-storage/${otherOrgIntegration.projectId}`,
undefined,
createBasicAuthHeader(testApiKey, testApiSecretKey),
);
expect(result.status).toBe(404);
// Check config still exists
const integrations = await prisma.blobStorageIntegration.findMany({
where: { projectId: otherProjectId },
});
expect(integrations).toHaveLength(1);
// Clean up
await prisma.blobStorageIntegration.delete({
where: { projectId: otherProjectId },
});
});
it("should return 401 with invalid API key", async () => {
const result = await makeAPICall(
"DELETE",
`/api/public/integrations/blob-storage/${testIntegrationId}`,
undefined,
createBasicAuthHeader("invalid-key", "invalid-secret"),
);
expect(result.status).toBe(401);
});
it("should return 403 with project-scoped API key", async () => {
const projectApiKey = await createAndAddApiKeysToDb({
prisma,
entityId: testProject1Id,
scope: "PROJECT",
note: "Project API Key",
predefinedKeys: {
publicKey: `pk-lf-proj-del-${randomUUID().substring(0, 8)}`,
secretKey: `sk-lf-proj-del-${randomUUID().substring(0, 8)}`,
},
});
const result = await makeAPICall(
"DELETE",
`/api/public/integrations/blob-storage/${testIntegrationId}`,
undefined,
createBasicAuthHeader(projectApiKey.publicKey, projectApiKey.secretKey),
);
expect(result.status).toBe(403);
// Clean up
await prisma.apiKey.delete({ where: { id: projectApiKey.id } });
});
});
describe("Method validation", () => {
it("should return 405 for unsupported methods", async () => {
const result = await makeAPICall(
"PATCH",
"/api/public/integrations/blob-storage",
undefined,
createBasicAuthHeader(testApiKey, testApiSecretKey),
);
expect(result.status).toBe(405);
expect(result.body.message).toContain("Method not allowed");
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,194 @@
import {
queryClickhouse,
queryClickhouseStream,
ClickHouseResourceError,
} from "@langfuse/shared/src/server";
import { fail } from "assert";
describe("ClickHouse Resource Error Handling", () => {
describe("queryClickhouse", () => {
describe("Error transformation with throwIf", () => {
// It is enough to test different block sizes on one error type only
[1, 10_000].forEach((blockSize) => {
it(`should transform OOM errors to ClickHouseResourceError; block size: ${blockSize}`, async () => {
let res = Array<any>();
try {
res = await queryClickhouse<any>({
query: `SELECT throwIf(number >= 2, 'memory limit exceeded: would use 10.23 GiB') AS v FROM system.numbers LIMIT 2000`,
clickhouseSettings: { max_block_size: `${blockSize}` },
});
fail(
"Should have thrown an error, observed instead " +
JSON.stringify(res),
);
} catch (error: any) {
expect(error).toBeInstanceOf(ClickHouseResourceError);
expect(error.errorType).toBe("MEMORY_LIMIT");
}
});
});
it("should transform OvercommitTracker errors", async () => {
try {
await queryClickhouse({
query: `SELECT throwIf(true, 'OvercommitTracker decision: Query was selected to stop by OvercommitTracker')`,
});
fail("Should have thrown an error");
} catch (error: any) {
expect(error).toBeInstanceOf(ClickHouseResourceError);
expect(error.errorType).toBe("OVERCOMMIT");
}
});
it("should transform timeout errors", async () => {
try {
await queryClickhouse({
query: `SELECT throwIf(true, 'Timeout exceeded while reading from socket')`,
});
fail("Should have thrown an error");
} catch (error: any) {
expect(error).toBeInstanceOf(ClickHouseResourceError);
expect(error.errorType).toBe("TIMEOUT");
}
});
it("should NOT transform regular SQL errors", async () => {
await expect(
queryClickhouse({
query: `SELECT * FROM non_existent_table_xyz123`,
}),
).rejects.toThrow();
try {
await queryClickhouse({
query: `SELECT * FROM non_existent_table_xyz123`,
});
} catch (error: any) {
expect(error).not.toBeInstanceOf(ClickHouseResourceError);
}
});
it("should pass through successful queries", async () => {
const result = await queryClickhouse<{ test_value: Number }>({
query: "SELECT 1 as test_value",
});
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toHaveProperty("test_value");
expect(result[0].test_value).toBe(1);
});
});
});
describe("queryClickhouseStream", () => {
// We don't need to test all error types here, just one is enough
// to verify streaming works with throwIf and different block sizes.
[1, 10_000].forEach((blockSize) => {
it(`should transform errors during streaming; block size ${blockSize}`, async () => {
const generator = queryClickhouseStream({
query: `SELECT throwIf(number = 2, 'memory limit exceeded: would use 10.23 GiB') as V FROM numbers(10)`,
clickhouseSettings: { max_block_size: `${blockSize}` },
});
let fullResponse = [];
await expect(
(async () => {
for await (const item of generator) {
fullResponse.push(item);
}
fail(
"Should have thrown an error, observed instead " +
JSON.stringify(fullResponse),
);
})(),
).rejects.toThrow(ClickHouseResourceError);
});
it("should stream successful queries", async () => {
const generator = queryClickhouseStream({
query: "SELECT number FROM system.numbers LIMIT 3",
});
const results = [];
for await (const item of generator) {
results.push(item);
}
expect(results).toBeDefined();
expect(Array.isArray(results)).toBe(true);
expect(results.length).toBe(3);
expect(results[0]).toHaveProperty("number");
});
});
});
describe("Error patterns documentation", () => {
// Document the actual error patterns we handle
const errorPatterns = [
{
name: "Memory limit exceeded with details",
errorMessage:
"(total) memory limit exceeded: would use 10.08 GiB (attempt to allocate chunk of 4.49 MiB bytes), current RSS: 1.83 GiB, maximum: 8.40 GiB",
shouldBeResourceError: true,
errorType: "MEMORY_LIMIT",
},
{
name: "OvercommitTracker error",
errorMessage:
"OvercommitTracker decision: Query was selected to stop by OvercommitTracker: While executing WaitForAsyncInsert",
shouldBeResourceError: true,
errorType: "OVERCOMMIT",
},
{
name: "Simple memory error",
errorMessage: "memory limit exceeded",
shouldBeResourceError: true,
errorType: "MEMORY_LIMIT",
},
{
name: "Timeout error",
errorMessage: "Timeout exceeded while reading from socket",
shouldBeResourceError: true,
errorType: "TIMEOUT",
},
{
name: "SQL error (not a resource error)",
errorMessage: "Table 'test.non_existent' doesn't exist",
shouldBeResourceError: false,
errorType: null,
},
{
name: "Network error (retryable but not a resource error)",
errorMessage: "socket hang up",
shouldBeResourceError: false,
errorType: null,
},
];
errorPatterns.forEach(
({ name, errorMessage, shouldBeResourceError, errorType }) => {
it(`should correctly classify "${name}"`, () => {
const error = new Error(errorMessage);
const wrappedError =
ClickHouseResourceError.wrapIfResourceError(error);
const isResourceError = ((err: Error) => {
if (err instanceof ClickHouseResourceError) {
return true;
} else {
return false;
}
})(wrappedError);
expect(isResourceError).toBe(shouldBeResourceError);
const resourceError = wrappedError as ClickHouseResourceError;
if (shouldBeResourceError && errorType) {
expect(resourceError.errorType).toBe(errorType);
}
});
},
);
});
});
@@ -11,7 +11,10 @@ import {
createTracesCh,
createOrgProjectAndApiKey,
} from "@langfuse/shared/src/server";
import { makeZodVerifiedAPICall } from "@/src/__tests__/test-utils";
import {
makeAPICall,
makeZodVerifiedAPICall,
} from "@/src/__tests__/test-utils";
import {
DeleteScoreResponseV1,
GetScoreResponseV1,
@@ -1031,6 +1034,21 @@ describe("/api/public/scores API Endpoint", () => {
]),
);
});
it("should reject session ID filtering", async () => {
try {
await makeAPICall(
"GET",
`/api/public/scores?sessionId=${sessionId}`,
undefined,
authentication,
);
} catch (error) {
expect((error as Error).message).toContain(
"API call did not return 200, returned status 400",
);
}
});
});
});
});
@@ -865,6 +865,39 @@ describe("/api/public/v2/scores API Endpoint", () => {
]),
);
});
it("should filter scores by session ID", async () => {
const getScore = await makeZodVerifiedAPICall(
GetScoresResponseV2,
"GET",
`/api/public/v2/scores?sessionId=${sessionId}`,
undefined,
authentication,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 2,
totalPages: 1,
});
expect(getScore.body.data).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: scoreId_6,
sessionId: sessionId,
name: scoreName,
value: 100.5,
}),
expect.objectContaining({
id: scoreId_7,
sessionId: sessionId,
name: "session-score-name",
value: 100.5,
}),
]),
);
});
});
});
});
+343 -5
View File
@@ -5,16 +5,17 @@ import {
makeZodVerifiedAPICall,
pruneDatabase,
} from "@/src/__tests__/test-utils";
import {
GetScoreConfigResponse,
GetScoreConfigsResponse,
PostScoreConfigResponse,
PutScoreConfigResponse,
} from "@/src/features/public-api/types/score-configs";
import {
type ScoreConfig,
prisma,
type ScoreDataType,
} from "@langfuse/shared/src/db";
import {
GetScoreConfigResponse,
PostScoreConfigResponse,
GetScoreConfigsResponse,
} from "@langfuse/shared";
import { createOrgProjectAndApiKey } from "@langfuse/shared/src/server";
const configOne = [
@@ -412,4 +413,341 @@ describe("/api/public/score-configs API Endpoint", () => {
],
});
});
describe("PATCH /api/public/score-configs/:configId", () => {
it("should successfully archive a score config", async () => {
const { id: configId } = (await prisma.scoreConfig.findFirst({
where: {
projectId,
name: configOne[0].name,
},
})) as ScoreConfig;
const patchResponse = await makeZodVerifiedAPICall(
PutScoreConfigResponse,
"PATCH",
`/api/public/score-configs/${configId}`,
{
isArchived: true,
},
auth,
);
expect(patchResponse.status).toBe(200);
expect(patchResponse.body.isArchived).toBe(true);
});
it("should fail when trying to update with invalid data type mismatch - numeric to categorical", async () => {
const foundConfig = await prisma.scoreConfig.findFirst({
where: {
projectId,
name: configTwo[0].name, // Numeric config
},
});
const { id: configId } = foundConfig as ScoreConfig;
const patchResponse = await makeAPICall(
"PATCH",
`/api/public/score-configs/${configId}`,
{
categories: [
{ label: "Good", value: 1 },
{ label: "Bad", value: 0 },
],
},
auth,
);
expect(patchResponse.status).toBe(400);
expect(patchResponse.body).toMatchObject({
message: expect.stringContaining("Invalid input"),
});
});
it("should fail when trying to update boolean with custom categories", async () => {
const { id: configId } = (await prisma.scoreConfig.findFirst({
where: {
projectId,
name: configOne[0].name, // Boolean config
},
})) as ScoreConfig;
const patchResponse = await makeAPICall(
"PATCH",
`/api/public/score-configs/${configId}`,
{
categories: [
{ label: "Custom Good", value: 1 },
{ label: "Custom Bad", value: 0 },
],
},
auth,
);
expect(patchResponse.status).toBe(400);
expect(patchResponse.body).toMatchObject({
message: expect.stringContaining("Invalid input"),
});
});
it("should fail when trying to update with invalid numeric range", async () => {
const { id: configId } = (await prisma.scoreConfig.findFirst({
where: {
projectId,
name: configTwo[0].name, // Numeric config
},
})) as ScoreConfig;
const patchResponse = await makeAPICall(
"PATCH",
`/api/public/score-configs/${configId}`,
{
minValue: 100,
maxValue: 50,
},
auth,
);
expect(patchResponse.status).toBe(400);
expect(patchResponse.body).toMatchObject({
message: expect.stringContaining(
"Maximum value must be greater than Minimum value",
),
});
});
it("should fail when trying to update with invalid name length", async () => {
const { id: configId } = (await prisma.scoreConfig.findFirst({
where: {
projectId,
name: configTwo[0].name,
},
})) as ScoreConfig;
const patchResponse = await makeAPICall(
"PATCH",
`/api/public/score-configs/${configId}`,
{
name: "a".repeat(50), // Exceeds 35 character limit
},
auth,
);
expect(patchResponse.status).toBe(400);
expect(patchResponse.body).toMatchObject({
message: expect.stringContaining("Invalid request"),
});
});
it("should fail when trying to update with empty name", async () => {
const { id: configId } = (await prisma.scoreConfig.findFirst({
where: {
projectId,
name: configTwo[0].name,
},
})) as ScoreConfig;
const patchResponse = await makeAPICall(
"PATCH",
`/api/public/score-configs/${configId}`,
{
name: "",
},
auth,
);
expect(patchResponse.status).toBe(400);
expect(patchResponse.body).toMatchObject({
message: expect.stringContaining("Invalid request"),
});
});
it("should fail when trying to update non-existent config", async () => {
const nonExistentId = "non-existent-config-id";
const patchResponse = await makeAPICall(
"PATCH",
`/api/public/score-configs/${nonExistentId}`,
{
name: "Updated Name",
},
auth,
);
expect(patchResponse.status).toBe(404);
expect(patchResponse.body).toMatchObject({
message: "Score config not found within authorized project",
});
});
it("should successfully update only specific fields without affecting others", async () => {
const { id: configId } = (await prisma.scoreConfig.findFirst({
where: {
projectId,
name: configTwo[0].name,
},
})) as ScoreConfig;
// Get current config state
const beforePatch = await makeZodVerifiedAPICall(
GetScoreConfigResponse,
"GET",
`/api/public/score-configs/${configId}`,
undefined,
auth,
);
// Update only description
const patchResponse = await makeZodVerifiedAPICall(
PutScoreConfigResponse,
"PATCH",
`/api/public/score-configs/${configId}`,
{
description: "Only description updated",
},
auth,
);
expect(patchResponse.status).toBe(200);
expect(patchResponse.body.description).toBe("Only description updated");
// Verify other fields remain unchanged
expect(patchResponse.body.name).toBe(beforePatch.body.name);
expect(patchResponse.body.dataType).toBe(beforePatch.body.dataType);
expect(patchResponse.body.minValue).toBe(beforePatch.body.minValue);
});
it("should fail when trying to update with empty body", async () => {
const { id: configId } = (await prisma.scoreConfig.findFirst({
where: {
projectId,
name: configTwo[0].name,
},
})) as ScoreConfig;
const patchResponse = await makeAPICall(
"PATCH",
`/api/public/score-configs/${configId}`,
{},
auth,
);
expect(patchResponse.status).toBe(400);
expect(patchResponse.body).toMatchObject({
message: expect.stringContaining("Invalid request"),
});
});
it("should successfully update a numeric score config", async () => {
const { id: configId } = (await prisma.scoreConfig.findFirst({
where: {
projectId,
name: configTwo[0].name, // "Test Numeric Config"
},
})) as ScoreConfig;
const patchResponse = await makeZodVerifiedAPICall(
PutScoreConfigResponse,
"PATCH",
`/api/public/score-configs/${configId}`,
{
name: "Updated Numeric Config",
minValue: 5,
maxValue: 100,
description: "Updated description",
},
auth,
);
expect(patchResponse.status).toBe(200);
expect(patchResponse.body).toMatchObject({
id: configId,
name: "Updated Numeric Config",
dataType: "NUMERIC",
minValue: 5,
maxValue: 100,
description: "Updated description",
});
// Verify the update persisted
const getResponse = await makeZodVerifiedAPICall(
GetScoreConfigResponse,
"GET",
`/api/public/score-configs/${configId}`,
undefined,
auth,
);
expect(getResponse.body.name).toBe("Updated Numeric Config");
expect(getResponse.body.minValue).toBe(5);
expect(getResponse.body.maxValue).toBe(100);
});
it("should successfully update a categorical score config", async () => {
const { id: configId } = (await prisma.scoreConfig.findFirst({
where: {
projectId,
name: configThree[0].name, // "Test Categorical Config"
},
})) as ScoreConfig;
const patchResponse = await makeZodVerifiedAPICall(
PutScoreConfigResponse,
"PATCH",
`/api/public/score-configs/${configId}`,
{
name: "Updated Categorical Config",
categories: [
{ label: "Excellent", value: 3 },
{ label: "Good", value: 2 },
{ label: "Poor", value: 1 },
],
},
auth,
);
expect(patchResponse.status).toBe(200);
expect(patchResponse.body).toMatchObject({
id: configId,
name: "Updated Categorical Config",
dataType: "CATEGORICAL",
categories: [
{ label: "Excellent", value: 3 },
{ label: "Good", value: 2 },
{ label: "Poor", value: 1 },
],
});
});
it("should successfully update a boolean score config", async () => {
const { id: configId } = (await prisma.scoreConfig.findFirst({
where: {
projectId,
name: configOne[0].name, // "Test Boolean Config"
},
})) as ScoreConfig;
const patchResponse = await makeZodVerifiedAPICall(
PutScoreConfigResponse,
"PATCH",
`/api/public/score-configs/${configId}`,
{
name: "Updated Boolean Config",
description: "Updated boolean description",
},
auth,
);
expect(patchResponse.status).toBe(200);
expect(patchResponse.body).toMatchObject({
id: configId,
name: "Updated Boolean Config",
dataType: "BOOLEAN",
description: "Updated boolean description",
categories: [
{ label: "True", value: 1 },
{ label: "False", value: 0 },
],
});
});
});
});
@@ -492,6 +492,7 @@ describe("OTel Resource Span Mapping", () => {
environment: "production",
});
});
it("should throw an error if langfuse scope spans have wrong project ID", async () => {
const langfuseOtelSpans = [
{
@@ -1443,6 +1444,55 @@ describe("OTel Resource Span Mapping", () => {
expect(retrieverEvent?.body.usageDetails.input).toBe(50);
});
it("should map tool-call spans with empty model-related attributes to span-create (not generation-create)", async () => {
const resourceSpan = {
scopeSpans: [
{
spans: [
{
...defaultSpanProps,
name: "tool-call",
attributes: [
{ key: "model", value: { stringValue: "" } },
{ key: "provided_model_name", value: { stringValue: "" } },
{ key: "internal_model_id", value: { stringValue: "" } },
{ key: "model_parameters", value: { stringValue: "{}" } },
{
key: "provided_usage_details",
value: { stringValue: "{}" },
},
{ key: "usage_details", value: { stringValue: "{}" } },
{
key: "provided_cost_details",
value: { stringValue: "{}" },
},
{ key: "cost_details", value: { stringValue: "{}" } },
{ key: "total_cost", value: { stringValue: "" } },
{ key: "completion_start_time", value: { stringValue: "" } },
{ key: "prompt_id", value: { stringValue: "" } },
{ key: "prompt_name", value: { stringValue: "" } },
{ key: "prompt_version", value: { stringValue: "" } },
{ key: "token_count", value: { stringValue: "" } },
],
},
],
},
],
};
const langfuseEvents = await convertOtelSpanToIngestionEvent(
resourceSpan,
new Set(),
);
const observationEvent = langfuseEvents.find(
(event) => event.type !== "trace-create",
);
// Tool-call spans with empty model-related attributes should remain as span-create
expect(observationEvent?.type).toBe("span-create");
});
it("should use logfire.msg as span name", async () => {
const resourceSpan = {
scopeSpans: [
@@ -1475,6 +1525,18 @@ describe("OTel Resource Span Mapping", () => {
});
it.each([
[
"should cast input_tokens from string to number",
{
entity: "observation",
otelAttributeKey: "langfuse.observation.completion_start_time",
otelAttributeValue: {
stringValue: "2025-09-17T22:16:28.152000+02:00",
},
entityAttributeKey: "completionStartTime",
entityAttributeValue: "2025-09-17T22:16:28.152000+02:00",
},
],
[
"should cast input_tokens from string to number",
{
@@ -2019,6 +2081,30 @@ describe("OTel Resource Span Mapping", () => {
entityAttributeValue: '{"foo": "bar"}',
},
],
[
"should map gcp.vertex.agent.tool_call_args to input",
{
entity: "observation",
otelAttributeKey: "gcp.vertex.agent.tool_call_args",
otelAttributeValue: {
stringValue: '{"foo": "bar"}',
},
entityAttributeKey: "input",
entityAttributeValue: '{"foo": "bar"}',
},
],
[
"should map gcp.vertex.agent.tool_response to output",
{
entity: "observation",
otelAttributeKey: "gcp.vertex.agent.tool_response",
otelAttributeValue: {
stringValue: '{"foo": "bar"}',
},
entityAttributeKey: "output",
entityAttributeValue: '{"foo": "bar"}',
},
],
])(
"Attributes: %s",
async (
@@ -3955,5 +4041,127 @@ describe("OTel Resource Span Mapping", () => {
const traceEvents = events.filter((e) => e.type === "trace-create");
expect(traceEvents.length).toBe(1);
});
it.skip("should not overwrite existing trace metadata when child span has trace updates", async () => {
// skipped because it's not really getting the trace and it's metadata to check
const traceId = "95f3b926c7d009925bcb5dbc27311120";
// 1. Root span creates trace with original metadata
const rootSpan = {
scopeSpans: [
{
scope: {
name: "openinference.instrumentation.google_adk",
},
spans: [
{
traceId: {
data: [
149, 243, 185, 38, 199, 208, 9, 146, 91, 203, 93, 188, 39,
49, 17, 32,
],
},
spanId: { data: [1, 2, 3, 4, 5, 6, 7, 8] },
name: "root-span",
startTimeUnixNano: { low: 1, high: 1 },
endTimeUnixNano: { low: 2, high: 1 },
attributes: [
{
key: "langfuse.trace.name",
value: { stringValue: "Original Name" },
},
{
key: "langfuse.session.id",
value: { stringValue: "original-session" },
},
{
key: "original_span_attribute",
value: { stringValue: "should_be_preserved" },
},
],
},
],
},
],
};
// 2. Child span with trace updates but different span attributes
const childSpan = {
scopeSpans: [
{
scope: {
name: "openinference.instrumentation.google_adk",
},
spans: [
{
traceId: {
data: [
149, 243, 185, 38, 199, 208, 9, 146, 91, 203, 93, 188, 39,
49, 17, 32,
],
},
spanId: { data: [2, 2, 3, 4, 5, 6, 7, 8] },
parentSpanId: { data: [1, 2, 3, 4, 5, 6, 7, 8] },
name: "child-span",
startTimeUnixNano: { low: 1, high: 1 },
endTimeUnixNano: { low: 2, high: 1 },
attributes: [
{
key: "langfuse.trace.name",
value: { stringValue: "Updated Name" },
}, // to trigger hasTraceUpdates
{
key: "langfuse.session.id",
value: { stringValue: "new-session" },
}, // also triggers hasTraceUpdates
{
key: "new_span_attribute",
value: { stringValue: "new_value" },
},
// Note: missing original_span_attribute
],
},
],
},
],
};
// Validate that root span creates trace with span attributes in metadata.attributes
const rootEvents = await convertOtelSpanToIngestionEvent(
rootSpan,
new Set(),
publicKey,
);
const rootTrace = rootEvents.find((e) => e.type === "trace-create");
expect(rootTrace?.body.sessionId).toBe("original-session");
expect(
rootTrace?.body.metadata?.attributes?.original_span_attribute,
).toBe("should_be_preserved");
// Process child span with trace updates
const childSpanEvents = await convertOtelSpanToIngestionEvent(
childSpan,
new Set([traceId]),
publicKey,
);
const updatedTraceEvent = childSpanEvents.find(
(e) => e.type === "trace-create",
);
expect(updatedTraceEvent).toBeDefined();
// original_span_attribute should still exist
expect(
updatedTraceEvent.body.metadata?.attributes?.original_span_attribute,
).toBe("should_be_preserved");
// new_span_attribute should now exist
expect(
updatedTraceEvent.body.metadata?.attributes?.new_span_attribute,
).toBe("new_value");
// The sessionId should be updated
expect(updatedTraceEvent.body.sessionId).toBe("new-session");
});
});
});
@@ -0,0 +1,362 @@
/** @jest-environment node */
import type { NextApiRequest, NextApiResponse } from "next";
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
import {
BaseError,
LangfuseNotFoundError,
UnauthorizedError,
} from "@langfuse/shared";
import {
ClickHouseResourceError,
logger,
traceException,
} from "@langfuse/shared/src/server";
import { createMocks } from "node-mocks-http";
import { z } from "zod/v4";
import { Prisma } from "@prisma/client";
// Mock the logger and traceException
jest.mock("@langfuse/shared/src/server", () => ({
...jest.requireActual("@langfuse/shared/src/server"),
logger: {
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
},
traceException: jest.fn(),
}));
describe("withMiddlewares error handling", () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe("BaseError handling", () => {
it("should handle BaseError with 4xx status code", async () => {
const error = new BaseError("BadRequest", 400, "Bad Request", false);
const handler = withMiddlewares({
POST: async () => {
throw error;
},
});
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
method: "POST",
headers: {
"x-langfuse-public-key": "test-key",
},
});
await handler(req, res);
expect(res._getStatusCode()).toBe(400);
const jsonData = JSON.parse(res._getData());
expect(jsonData).toMatchObject({
message: "Bad Request",
error: "BadRequest",
});
});
it("should handle BaseError with 5xx status code and trace exception", async () => {
const error = new BaseError(
"ServiceUnavailable",
503,
"Internal Error",
true,
);
const handler = withMiddlewares({
GET: async () => {
throw error;
},
});
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
method: "GET",
headers: {
"x-langfuse-public-key": "test-key",
},
});
await handler(req, res);
expect(res._getStatusCode()).toBe(503);
const jsonData = JSON.parse(res._getData());
expect(jsonData).toMatchObject({
message: "Internal Error",
error: "ServiceUnavailable",
});
// Should trace 5xx errors
expect(traceException).toHaveBeenCalledWith(error);
});
});
describe("LangfuseNotFoundError handling", () => {
it("should handle LangfuseNotFoundError and log as info", async () => {
const error = new LangfuseNotFoundError("Resource not found");
const handler = withMiddlewares({
GET: async () => {
throw error;
},
});
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
method: "GET",
headers: {
"x-langfuse-public-key": "test-key",
},
});
await handler(req, res);
expect(res._getStatusCode()).toBe(404);
const jsonData = JSON.parse(res._getData());
expect(jsonData).toMatchObject({
message: "Resource not found",
error: "LangfuseNotFoundError",
});
// Should log as info, not error
expect(logger.info).toHaveBeenCalledWith(error);
expect(logger.error).not.toHaveBeenCalled();
});
});
describe("UnauthorizedError handling", () => {
it("should handle UnauthorizedError and log as info", async () => {
const error = new UnauthorizedError("Invalid credentials");
const handler = withMiddlewares({
POST: async () => {
throw error;
},
});
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
method: "POST",
headers: {
"x-langfuse-public-key": "test-key",
},
});
await handler(req, res);
expect(res._getStatusCode()).toBe(401);
const jsonData = JSON.parse(res._getData());
expect(jsonData).toMatchObject({
message: "Invalid credentials",
error: "UnauthorizedError",
});
// Should log as info, not error
expect(logger.info).toHaveBeenCalledWith(error);
expect(logger.error).not.toHaveBeenCalled();
});
});
describe("MethodNotAllowedError handling", () => {
it("should throw MethodNotAllowedError for unsupported methods", async () => {
const handler = withMiddlewares({
GET: async () => {},
// POST is not defined
});
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
method: "POST",
headers: {
"x-langfuse-public-key": "test-key",
},
});
await handler(req, res);
expect(res._getStatusCode()).toBe(405);
const jsonData = JSON.parse(res._getData());
expect(jsonData).toMatchObject({
message: "Method not allowed",
error: "MethodNotAllowedError",
});
});
});
describe("ClickHouseResourceError handling", () => {
it("should handle ClickHouseResourceError with 400 status", async () => {
const originalError = new Error("Memory limit exceeded: maximum: 10GB");
const resourceError = new ClickHouseResourceError(
"MEMORY_LIMIT",
originalError,
);
const handler = withMiddlewares({
POST: async () => {
throw resourceError;
},
});
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
method: "POST",
headers: {
"x-langfuse-public-key": "test-key",
},
});
await handler(req, res);
expect(res._getStatusCode()).toBe(524);
const jsonData = JSON.parse(res._getData());
expect(jsonData["message"]).toBeDefined();
expect(jsonData["message"]).toContain(
ClickHouseResourceError.ERROR_ADVICE_MESSAGE,
);
});
});
describe("Prisma exception handling", () => {
it("should handle Prisma exceptions with generic 500 error", async () => {
// Create a real Prisma error
const prismaError = new Prisma.PrismaClientKnownRequestError(
"Unique constraint failed",
{ code: "P2002", clientVersion: "5.0.0" },
);
const handler = withMiddlewares({
POST: async () => {
throw prismaError;
},
});
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
method: "POST",
headers: {
"x-langfuse-public-key": "test-key",
},
});
await handler(req, res);
expect(res._getStatusCode()).toBe(500);
const jsonData = JSON.parse(res._getData());
expect(jsonData).toMatchObject({
message: "Internal Server Error",
error: "An unknown error occurred",
});
expect(traceException).toHaveBeenCalledWith(prismaError);
});
});
describe("Zod validation error handling", () => {
it("should handle Zod validation errors with 400 status", async () => {
const schema = z.object({
name: z.string(),
age: z.number(),
});
const handler = withMiddlewares({
POST: async () => {
// This will throw a ZodError
schema.parse({ name: "John", age: "not a number" });
},
});
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
method: "POST",
headers: {
"x-langfuse-public-key": "test-key",
},
});
await handler(req, res);
expect(res._getStatusCode()).toBe(400);
const jsonData = JSON.parse(res._getData());
expect(jsonData).toMatchObject({
message: "Invalid request data",
error: expect.arrayContaining([
expect.objectContaining({
code: "invalid_type",
path: ["age"],
}),
]),
});
});
});
describe("Generic error handling", () => {
it("should handle generic Error instances with 500 status", async () => {
const error = new Error("Something went wrong");
const handler = withMiddlewares({
DELETE: async () => {
throw error;
},
});
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
method: "DELETE",
headers: {
"x-langfuse-public-key": "test-key",
},
});
await handler(req, res);
expect(res._getStatusCode()).toBe(500);
const jsonData = JSON.parse(res._getData());
expect(jsonData).toMatchObject({
message: "Internal Server Error",
error: "Something went wrong",
});
expect(traceException).toHaveBeenCalledWith(error);
});
it("should handle non-Error thrown values with 500 status", async () => {
const handler = withMiddlewares({
PATCH: async () => {
throw "string error"; // eslint-disable-line no-throw-literal
},
});
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
method: "PATCH",
headers: {
"x-langfuse-public-key": "test-key",
},
});
await handler(req, res);
expect(res._getStatusCode()).toBe(500);
const jsonData = JSON.parse(res._getData());
expect(jsonData).toMatchObject({
message: "Internal Server Error",
error: "An unknown error occurred",
});
});
it("should handle null/undefined errors with 500 status", async () => {
const handler = withMiddlewares({
PUT: async () => {
throw null; // eslint-disable-line no-throw-literal
},
});
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
method: "PUT",
headers: {
"x-langfuse-public-key": "test-key",
},
});
await handler(req, res);
expect(res._getStatusCode()).toBe(500);
const jsonData = JSON.parse(res._getData());
expect(jsonData).toMatchObject({
message: "Internal Server Error",
error: "An unknown error occurred",
});
});
});
});
@@ -1,5 +1,5 @@
import { stripeWebhookApiHandler } from "@/src/ee/features/billing/server/stripeWebhookApiHandler";
import { stripeWebhookHandler } from "@/src/ee/features/billing/server/stripeWebhookHandler";
export const dynamic = "force-dynamic";
export const POST = stripeWebhookApiHandler;
export const POST = stripeWebhookHandler;
+8 -7
View File
@@ -1,4 +1,4 @@
import { env } from "@/src/env.mjs";
import { useLangfuseCloudRegion } from "@/src/features/organizations/hooks";
import { cn } from "@/src/utils/tailwind";
import { useSession } from "next-auth/react";
import { useState } from "react";
@@ -6,25 +6,26 @@ import { useState } from "react";
export const EnvLabel = ({ className }: { className?: string }) => {
const [isHidden, setIsHidden] = useState(false);
const session = useSession();
if (!env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) return null;
const { isLangfuseCloud, region } = useLangfuseCloudRegion();
if (!isLangfuseCloud) return null;
if (!session.data?.user?.email?.endsWith("@langfuse.com")) return null;
if (isHidden) return null;
return (
<div
className={cn(
"flex cursor-pointer items-center gap-1 self-stretch whitespace-nowrap rounded-md px-1 py-0.5 text-xs",
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "STAGING"
region === "STAGING"
? "bg-light-blue text-dark-blue"
: env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV"
: region === "DEV"
? "bg-light-green text-dark-green"
: "bg-light-red text-dark-red",
className,
)}
onClick={() => setIsHidden(true)}
>
{["EU", "US", "HIPAA"].includes(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION)
? `PROD-${env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION}`
: env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION}
{region && ["EU", "US", "HIPAA"].includes(region)
? `PROD-${region}`
: region}
</div>
);
};
+36 -26
View File
@@ -1,4 +1,37 @@
type Accuracy = "minute" | "second" | "millisecond";
type Accuracy = "day" | "hour" | "minute" | "second" | "millisecond";
export const formatLocalIsoDate = (
date: Date,
useUTC = false,
pAccuracy: Accuracy,
) => {
const pad = (num: number) => String(num).padStart(2, "0");
const year = useUTC ? date.getUTCFullYear() : date.getFullYear();
const month = useUTC ? date.getUTCMonth() + 1 : date.getMonth() + 1;
const day = useUTC ? date.getUTCDate() : date.getDate();
const hours = useUTC ? date.getUTCHours() : date.getHours();
const minutes = useUTC ? date.getUTCMinutes() : date.getMinutes();
const seconds = useUTC ? date.getUTCSeconds() : date.getSeconds();
const ms = useUTC ? date.getUTCMilliseconds() : date.getMilliseconds();
let formatted = `${year}-${pad(month)}-${pad(day)}`;
if (["hour", "minute", "second", "millisecond"].includes(pAccuracy)) {
formatted += ` ${pad(hours)}`;
}
if (["minute", "second", "millisecond"].includes(pAccuracy)) {
formatted += `:${pad(minutes)}`;
}
if (["second", "millisecond"].includes(pAccuracy)) {
formatted += `:${pad(seconds)}`;
}
if (pAccuracy === "millisecond") {
formatted += `.${String(ms).padStart(3, "0")}`;
}
return formatted;
};
export const LocalIsoDate = ({
date,
@@ -13,31 +46,8 @@ export const LocalIsoDate = ({
return null;
}
const formatDate = (date: Date, useUTC = false, pAccuracy: Accuracy) => {
const pad = (num: number) => String(num).padStart(2, "0");
const year = useUTC ? date.getUTCFullYear() : date.getFullYear();
const month = useUTC ? date.getUTCMonth() + 1 : date.getMonth() + 1;
const day = useUTC ? date.getUTCDate() : date.getDate();
const hours = useUTC ? date.getUTCHours() : date.getHours();
const minutes = useUTC ? date.getUTCMinutes() : date.getMinutes();
const seconds = useUTC ? date.getUTCSeconds() : date.getSeconds();
const ms = useUTC ? date.getUTCMilliseconds() : date.getMilliseconds();
let formatted = `${year}-${pad(month)}-${pad(day)} ${pad(hours)}:${pad(minutes)}`;
if (["second", "millisecond"].includes(pAccuracy)) {
formatted += `:${pad(seconds)}`;
}
if (pAccuracy === "millisecond") {
formatted += `.${String(ms).padStart(3, "0")}`;
}
return formatted;
};
const localDateString = formatDate(date, false, accuracy);
const utcDateString = formatDate(date, true, "millisecond");
const localDateString = formatLocalIsoDate(date, false, accuracy);
const utcDateString = formatLocalIsoDate(date, true, "millisecond");
return (
<span title={`UTC: ${utcDateString}`} className={className}>
+6 -5
View File
@@ -20,20 +20,22 @@ import {
import { ArrowUp } from "lucide-react";
import { api } from "@/src/utils/api";
import { Button } from "@/src/components/ui/button";
import { env } from "@/src/env.mjs";
import { cn } from "@/src/utils/tailwind";
import { usePlan } from "@/src/features/entitlements/hooks";
import { isSelfHostedPlan, planLabels } from "@langfuse/shared";
import { StatusBadge } from "@/src/components/layouts/status-badge";
import { useLangfuseCloudRegion } from "@/src/features/organizations/hooks";
export const VersionLabel = ({ className }: { className?: string }) => {
const isLangfuseCloud = useLangfuseCloudRegion();
const backgroundMigrationStatus = api.backgroundMigrations.status.useQuery(
undefined,
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
enabled: !env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION, // do not check for updates on Langfuse Cloud
enabled: !isLangfuseCloud, // do not check for updates on Langfuse Cloud
throwOnError: false, // do not render default error message
},
);
@@ -42,12 +44,11 @@ export const VersionLabel = ({ className }: { className?: string }) => {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
enabled: !env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION, // do not check for updates on Langfuse Cloud
enabled: !isLangfuseCloud, // do not check for updates on Langfuse Cloud
throwOnError: false, // do not render default error message
});
const plan = usePlan();
const isLangfuseCloud = Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
const selfHostedPlanLabel = !isLangfuseCloud
? plan && isSelfHostedPlan(plan)
@@ -107,7 +108,7 @@ export const VersionLabel = ({ className }: { className?: string }) => {
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
) : !env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION ? (
) : !isLangfuseCloud ? (
<>
<DropdownMenuLabel>This is the latest release</DropdownMenuLabel>
<DropdownMenuSeparator />
+302 -51
View File
@@ -1,7 +1,8 @@
"use client";
import * as React from "react";
import { Calendar as CalendarIcon, X } from "lucide-react";
import { Calendar as CalendarIcon, X, ChevronDown } from "lucide-react";
import { addMinutes } from "date-fns";
import { Button } from "@/src/components/ui/button";
import { Calendar } from "@/src/components/ui/calendar";
import {
@@ -10,10 +11,9 @@ import {
PopoverTrigger,
} from "@/src/components/ui/popover";
import { cn } from "@/src/utils/tailwind";
import { type DateRange } from "react-day-picker";
import { type DateRange as RDPDateRange } from "react-day-picker";
import { format } from "date-fns";
import { useEffect, useState } from "react";
import { useMediaQuery } from "react-responsive";
import { useEffect, useState, useCallback } from "react";
import { setBeginningOfDay, setEndOfDay } from "@/src/utils/dates";
import { TimePicker } from "@/src/components/ui/time-picker";
import { DashboardDateRangeDropdown } from "@/src/components/date-range-dropdowns";
@@ -21,6 +21,9 @@ import {
DASHBOARD_AGGREGATION_PLACEHOLDER,
type DashboardDateRangeOptions,
type DashboardDateRange,
TIME_RANGES,
formatDateRange,
type TimeRange,
} from "@/src/utils/date-range-utils";
import { combineDateAndTime } from "@/src/components/ui/time-picker-utils";
@@ -65,7 +68,7 @@ export function DatePicker({
mode="single"
selected={date}
onSelect={(d) => onChange(d)}
initialFocus
autoFocus
/>
{includeTimePicker && (
<TimePicker date={date} setDate={(d) => onChange(d)} />
@@ -105,7 +108,7 @@ export function DatePickerWithRange({
disabled,
}: DatePickerWithRangeProps) {
const [internalDateRange, setInternalDateRange] = useState<
DateRange | undefined
RDPDateRange | undefined
>(dateRange);
useEffect(() => {
@@ -113,10 +116,10 @@ export function DatePickerWithRange({
}, [dateRange]);
const setNewDateRange = (
internalDateRange: DateRange | undefined,
internalDateRange: RDPDateRange | undefined,
newFromDate: Date | undefined,
newToDate: Date | undefined,
): DateRange | undefined => {
): RDPDateRange | undefined => {
return internalDateRange
? {
from: newFromDate ?? internalDateRange.from,
@@ -126,7 +129,7 @@ export function DatePickerWithRange({
};
const updateDashboardDateRange = (
newRange: DateRange | undefined,
newRange: RDPDateRange | undefined,
setDateRangeAndOption: (
option: DashboardDateRangeOptions,
date?: DashboardDateRange,
@@ -144,7 +147,7 @@ export function DatePickerWithRange({
}
};
const onCalendarSelection = (range?: DateRange) => {
const onCalendarSelection = (range?: RDPDateRange) => {
const newRange = range
? {
from: range.from ? setBeginningOfDay(range.from) : undefined,
@@ -178,8 +181,6 @@ export function DatePickerWithRange({
updateDashboardDateRange(newRange, setDateRangeAndOption);
};
const isSmallScreen = useMediaQuery({ query: "(max-width: 640px)" });
return (
<div
className={cn("my-3 flex flex-col-reverse gap-2 md:flex-row", className)}
@@ -216,49 +217,32 @@ export function DatePickerWithRange({
defaultMonth={internalDateRange?.from}
selected={internalDateRange}
onSelect={onCalendarSelection}
numberOfMonths={isSmallScreen ? 1 : 2}
numberOfMonths={2}
className="[&>div:first-child]:block [&>div]:hidden [&>div]:sm:block"
disabled={disabled}
/>
{!isSmallScreen && (
<div className="flex flex-row border-t-2 py-1.5">
<div className="px-3">
<p className="px-1 text-sm font-medium">Start time</p>
<TimePicker
date={internalDateRange?.from}
setDate={onStartTimeSelection}
className="border-0 px-0 pt-1"
/>
</div>
<div className="px-3">
<p className="px-1 text-sm font-medium">End time</p>
<TimePicker
date={internalDateRange?.to}
setDate={onEndTimeSelection}
className="border-0 px-0 pt-1"
/>
</div>
<div className="flex flex-col gap-2 border-t-2 py-1.5 sm:flex-row sm:gap-0">
<div className="px-3">
<p className="px-1 text-sm font-medium">
Start<span className="hidden sm:inline"> time</span>
</p>
<TimePicker
date={internalDateRange?.from}
setDate={onStartTimeSelection}
className="border-0 px-0 pt-1"
/>
</div>
)}
{isSmallScreen && (
<div className="flex flex-col gap-2 border-t-2 py-1.5">
<div className="px-3">
<p className="px-1 text-sm font-medium">Start</p>
<TimePicker
date={internalDateRange?.from}
setDate={onStartTimeSelection}
className="border-0 px-0 pt-1"
/>
</div>
<div className="px-3">
<p className="px-1 text-sm font-medium">End</p>
<TimePicker
date={internalDateRange?.to}
setDate={onEndTimeSelection}
className="border-0 px-0 pt-1"
/>
</div>
<div className="px-3">
<p className="px-1 text-sm font-medium">
End<span className="hidden sm:inline"> time</span>
</p>
<TimePicker
date={internalDateRange?.to}
setDate={onEndTimeSelection}
className="border-0 px-0 pt-1"
/>
</div>
)}
</div>
</PopoverContent>
</Popover>
<DashboardDateRangeDropdown
@@ -268,3 +252,270 @@ export function DatePickerWithRange({
</div>
);
}
export type TimeRangePickerProps = {
timeRange?: TimeRange;
onTimeRangeChange: (timeRange: TimeRange) => void;
timeRangePresets: readonly string[];
className?: string;
disabled?: boolean | { before?: Date; after?: Date } | Date | Date[];
};
export function TimeRangePicker({
className,
timeRange,
timeRangePresets,
onTimeRangeChange,
disabled,
}: TimeRangePickerProps) {
// Determine the range type
const rangeType: "named" | "custom" | null = timeRange
? "from" in timeRange
? "custom"
: "named"
: null;
// Disable future dates by default, plus any additional disabled prop
const calendarDisabled = React.useMemo(() => {
const futureDisabled = { after: new Date() };
if (!disabled) return futureDisabled;
if (typeof disabled === "boolean") return disabled;
// Always return an array when combining with additional restrictions
const disabledArray = Array.isArray(disabled) ? disabled : [disabled];
return [...disabledArray, futureDisabled] as React.ComponentProps<
typeof Calendar
>["disabled"];
}, [disabled]);
const namedRangeValue =
rangeType === "named" && timeRange && "range" in timeRange
? timeRange.range
: null;
// Convert TimeRange to DateRange for internal use
const dateRange = timeRange && "from" in timeRange ? timeRange : undefined;
const [internalDateRange, setInternalDateRange] = useState<
RDPDateRange | undefined
>(dateRange);
// Update internal date range when timeRange changes
useEffect(() => {
if (rangeType === "custom") {
// Custom range - use as is
setInternalDateRange(dateRange);
} else if (rangeType === "named" && timeRange && "range" in timeRange) {
// Preset range - look up in generic time ranges
const setting = TIME_RANGES[timeRange.range as keyof typeof TIME_RANGES];
if (setting && setting.minutes) {
const now = new Date();
setInternalDateRange({
from: addMinutes(now, -setting.minutes),
to: now,
});
} else {
setInternalDateRange(undefined);
}
} else {
setInternalDateRange(undefined);
}
}, [timeRange, dateRange, rangeType]);
const setNewDateRange = (
internalDateRange: RDPDateRange | undefined,
newFromDate: Date | undefined,
newToDate: Date | undefined,
): RDPDateRange | undefined => {
return internalDateRange
? {
from: newFromDate ?? internalDateRange.from,
to: newToDate ?? internalDateRange.to,
}
: undefined;
};
const updateDateRange = (newRange: RDPDateRange | undefined) => {
if (newRange && newRange.from && newRange.to) {
onTimeRangeChange({
from: newRange.from,
to: newRange.to,
});
}
};
const onCalendarSelection = (range?: RDPDateRange) => {
const newRange = range
? {
from: range.from
? setBeginningOfDay(new Date(range.from))
: undefined,
to: range.to ? setEndOfDay(new Date(range.to)) : undefined,
}
: undefined;
setInternalDateRange(newRange);
updateDateRange(newRange);
};
const onStartTimeSelection = (date: Date | undefined) => {
const newDateTime = combineDateAndTime(internalDateRange?.from, date);
const newRange = setNewDateRange(
internalDateRange,
newDateTime,
internalDateRange?.to,
);
setInternalDateRange(newRange);
updateDateRange(newRange);
};
const onEndTimeSelection = (date: Date | undefined) => {
const newDateTime = combineDateAndTime(internalDateRange?.to, date);
const newRange = setNewDateRange(
internalDateRange,
internalDateRange?.from,
newDateTime,
);
setInternalDateRange(newRange);
updateDateRange(newRange);
};
const onPresetSelection = (value: string) => {
if (timeRangePresets.includes(value as keyof typeof TIME_RANGES)) {
onTimeRangeChange({ range: value });
}
};
const [isOpen, setIsOpen] = useState(false);
const [tab, setTab] = useState<"presets" | "calendar">("presets");
const handleOpenChange = useCallback((open: boolean) => {
setIsOpen(open);
if (open) {
setTab("presets");
}
}, []);
const getDisplayContent = () => {
if (rangeType === "custom") {
// Custom range - show calendar icon and date range
return (
<div className="flex items-center gap-2">
<CalendarIcon className="h-4 w-4" />
<span>
{dateRange
? formatDateRange(dateRange.from, dateRange.to)
: "Select from calendar"}
</span>
</div>
);
} else if (rangeType === "named") {
// Preset range - show badge with abbreviation and label
const setting = TIME_RANGES[namedRangeValue as keyof typeof TIME_RANGES];
return (
<div className="flex items-center gap-2">
<span className="h-5 w-10 rounded bg-muted px-1.5 text-center text-xs leading-5">
{setting?.abbreviation || namedRangeValue}
</span>
<span>{setting?.label || namedRangeValue}</span>
</div>
);
} else {
// No time range selected
return (
<div className="flex items-center gap-2">
<CalendarIcon className="h-4 w-4" />
<span>Select time range</span>
</div>
);
}
};
return (
<div className={cn("my-3", className)}>
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-fit justify-start text-left font-normal hover:bg-accent hover:text-accent-foreground",
!timeRange && "text-muted-foreground",
)}
>
<div className="flex items-center gap-2">
{getDisplayContent()}
<ChevronDown className="h-4 w-4 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
{tab === "calendar" ? (
/* Show calendar picker when in calendar mode */
<>
<Calendar
mode="range"
defaultMonth={internalDateRange?.from || new Date()}
selected={internalDateRange}
onSelect={onCalendarSelection}
numberOfMonths={1}
disabled={calendarDisabled}
/>
<div className="flex flex-col gap-3 border-t p-3">
<div className="flex flex-col gap-1">
<p className="px-1 text-sm font-medium">Start time</p>
<TimePicker
date={internalDateRange?.from}
setDate={onStartTimeSelection}
className="border-0 px-0 py-0"
/>
</div>
<div className="flex flex-col gap-1">
<p className="px-1 text-sm font-medium">End time</p>
<TimePicker
date={internalDateRange?.to}
setDate={onEndTimeSelection}
className="border-0 px-0 py-0"
/>
</div>
</div>
</>
) : (
/* Always show preset options dropdown */
<div className="p-1">
{timeRangePresets.map((presetKey) => {
const setting =
TIME_RANGES[presetKey as keyof typeof TIME_RANGES];
return (
<div
key={presetKey}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
onClick={() => {
onPresetSelection(presetKey);
setIsOpen(false);
}}
>
<span className="h-5 w-10 rounded bg-muted px-1.5 text-center text-xs leading-5">
{setting.abbreviation}
</span>
<span>{setting.label}</span>
</div>
);
})}
<div
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
onClick={() => {
setTab("calendar");
}}
>
<span className="leading flex h-5 w-10 items-center justify-center rounded bg-muted px-1.5 text-center text-xs">
<CalendarIcon className="h-3 w-3" />
</span>
<span>Select from calendar</span>
</div>
</div>
)}
</PopoverContent>
</Popover>
</div>
);
}
+30 -6
View File
@@ -19,6 +19,8 @@ import {
getDateFromOption,
isTableDataRangeOptionAvailable,
isDashboardDateRangeOptionAvailable,
getAbbreviatedTimeRange,
getTimeRangeLabel,
} from "@/src/utils/date-range-utils";
import { useEntitlementLimit } from "@/src/features/entitlements/hooks";
import { useMemo } from "react";
@@ -45,24 +47,40 @@ const BaseDateRangeDropdown = <T extends string>({
return (
<Select value={selectedOption} onValueChange={onSelectionChange}>
<SelectTrigger className="w-fit font-medium hover:bg-accent hover:text-accent-foreground focus:ring-0 focus:ring-offset-0">
{selectedOption !== "All time" && <span>Past</span>}
<SelectValue placeholder="Select" />
<SelectValue placeholder="Select">
<div className="flex items-center gap-2">
<span className="w-10 rounded bg-muted px-1.5 py-0.5 text-center text-xs">
{getAbbreviatedTimeRange(selectedOption)}
</span>
<span>{getTimeRangeLabel(selectedOption)}</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent position="popper" defaultValue={60}>
<SelectContent
position="popper"
defaultValue={60}
className="[&_[data-checkmark]]:hidden"
>
{options.map((item) => {
const itemObj = (
<SelectItem
key={item}
value={item}
disabled={limitedOptions?.includes(item)}
className="pl-2"
>
{item}
<div className="flex items-center gap-2">
<span className="w-10 rounded bg-muted px-1.5 py-0.5 text-center text-xs">
{getAbbreviatedTimeRange(item)}
</span>
<span>{getTimeRangeLabel(item)}</span>
</div>
</SelectItem>
);
const isLimited = limitedOptions?.includes(item);
return isLimited ? (
<HoverCard openDelay={200}>
<HoverCard openDelay={200} key={item}>
<HoverCardTrigger asChild>
<span>{itemObj}</span>
</HoverCardTrigger>
@@ -113,7 +131,7 @@ export const DashboardDateRangeDropdown: React.FC<
value as keyof typeof dashboardDateRangeAggregationSettings
];
setDateRangeAndOption(value, {
from: addMinutes(new Date(), -setting.minutes),
from: addMinutes(new Date(), -setting!.minutes!),
to: new Date(),
});
};
@@ -153,6 +171,12 @@ export const TableDateRangeDropdown: React.FC<TableDateRangeDropdownProps> = ({
}, [lookbackLimit]);
const onDropDownSelection = (value: TableDateRangeOptions) => {
// Handle "custom" placeholder case
if (value === "custom") {
setDateRangeAndOption(value, undefined);
return;
}
const dateFromOption = getDateFromOption({
filterSource: "TABLE",
option: value,
+2 -2
View File
@@ -70,7 +70,7 @@ const ScoreGroupBadge = <T extends APIScoreV2 | LastUserScore>({
<HoverCardTrigger className="inline-block">
<MessageCircleMoreIcon className="mb-[0.0625rem] !size-3" />
</HoverCardTrigger>
<HoverCardContent className="max-h-[50dvh] overflow-y-auto whitespace-normal break-normal">
<HoverCardContent className="max-h-[50dvh] overflow-y-auto whitespace-normal break-normal text-xs">
<p className="whitespace-pre-wrap">{s.comment}</p>
</HoverCardContent>
</HoverCard>
@@ -80,7 +80,7 @@ const ScoreGroupBadge = <T extends APIScoreV2 | LastUserScore>({
<HoverCardTrigger className="inline-block">
<BracesIcon className="mb-[0.0625rem] !size-3" />
</HoverCardTrigger>
<HoverCardContent className="max-h-[50dvh] overflow-y-auto whitespace-normal break-normal rounded-md border-none p-0">
<HoverCardContent className="max-h-[50dvh] overflow-y-auto whitespace-normal break-normal rounded-md border-none p-0 text-xs">
<JSONView codeClassName="!rounded-md" json={s.metadata} />
</HoverCardContent>
</HoverCard>
+5 -5
View File
@@ -35,6 +35,7 @@ import {
processNavigation,
type NavigationItem,
} from "@/src/components/layouts/utilities/routes";
import { useLangfuseCloudRegion } from "@/src/features/organizations/hooks";
const signOutUser = async () => {
sessionStorage.clear();
@@ -119,6 +120,7 @@ export default function Layout(props: PropsWithChildren) {
| string
| undefined;
const session = useSessionWithRetryOnUnauthenticated();
const { isLangfuseCloud, region } = useLangfuseCloudRegion();
const enableExperimentalFeatures =
session.data?.environment.enableExperimentalFeatures ?? false;
@@ -127,9 +129,7 @@ export default function Layout(props: PropsWithChildren) {
const uiCustomization = useUiCustomization();
const cloudAdmin =
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined &&
session.data?.user?.admin === true;
const cloudAdmin = isLangfuseCloud && session.data?.user?.admin === true;
// project info based on projectId in the URL
const { project, organization } = useQueryProjectOrOrganization();
@@ -319,13 +319,13 @@ export default function Layout(props: PropsWithChildren) {
rel="icon"
type="image/png"
sizes="32x32"
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-32x32${env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV" ? "-dev" : ""}.png`}
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-32x32${region === "DEV" ? "-dev" : ""}.png`}
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-16x16${env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV" ? "-dev" : ""}.png`}
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-16x16${region === "DEV" ? "-dev" : ""}.png`}
/>
</Head>
<div>
+3 -2
View File
@@ -22,12 +22,12 @@ import {
import { env } from "@/src/env.mjs";
import { useRouter } from "next/router";
import Link from "next/link";
import { LangfuseLogo } from "@/src/components/LangfuseLogo";
import { SidebarNotifications } from "@/src/components/nav/sidebar-notifications";
import { UsageTracker } from "@/src/ee/features/billing/components/UsageTracker";
import { type RouteGroup } from "@/src/components/layouts/routes";
import { ExternalLink, Grid2X2 } from "lucide-react";
import { useLangfuseCloudRegion } from "@/src/features/organizations/hooks";
type AppSidebarProps = {
navItems: {
@@ -75,6 +75,7 @@ export function AppSidebar({
const DemoBadge = () => {
const router = useRouter();
const { isLangfuseCloud } = useLangfuseCloudRegion();
const routerProjectId = router.query.projectId as string | undefined;
if (
@@ -82,7 +83,7 @@ const DemoBadge = () => {
env.NEXT_PUBLIC_DEMO_ORG_ID &&
env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
routerProjectId === env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION)
isLangfuseCloud
)
)
return null;
@@ -29,6 +29,15 @@ type SidebarNotification = {
};
const notifications: SidebarNotification[] = [
{
id: "js-sdk-v4",
title: "New JS/TS SDK v4",
description:
"With v4, the TypeScript SDK significantly improves DX, speed, and ecosystem integrations.",
link: "https://langfuse.com/docs/observability/sdk/typescript/overview",
linkTitle: "Learn more",
createdAt: "2025-09-09",
},
{
id: "python-sdk-v3",
title: "New Python SDK v3",
+3 -3
View File
@@ -70,7 +70,7 @@ export const ScoresTableCell = ({
<HoverCardTrigger className="inline-block cursor-pointer">
<MessageCircleMore size={12} />
</HoverCardTrigger>
<HoverCardContent className="overflow-hidden whitespace-normal break-normal">
<HoverCardContent className="overflow-hidden whitespace-normal break-normal text-xs">
<p className="whitespace-pre-wrap">{aggregate.comment}</p>
</HoverCardContent>
</HoverCard>
@@ -111,7 +111,7 @@ export const ScoresTableCell = ({
/>
</div>
</HoverCardTrigger>
<HoverCardContent className="z-20 flex max-h-[40vh] max-w-64 flex-col overflow-y-auto whitespace-normal break-normal">
<HoverCardContent className="z-20 flex max-h-[40vh] max-w-64 flex-col overflow-y-auto whitespace-normal break-normal text-xs">
<ScoreValueCounts valueCounts={aggregate.valueCounts} wrap={wrap} />
</HoverCardContent>
</HoverCard>
@@ -159,7 +159,7 @@ function AggregateScoreMetadataPeek({
<HoverCardTrigger className="inline-block cursor-pointer">
<BracesIcon size={12} />
</HoverCardTrigger>
<HoverCardContent className="overflow-hidden whitespace-normal break-normal rounded-md border-none p-0">
<HoverCardContent className="overflow-hidden whitespace-normal break-normal rounded-md border-none p-0 text-xs">
{metadataLoaded ? (
<JSONView codeClassName="!rounded-md" json={metadata} />
) : (

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