Compare commits

...
46 Commits
Author SHA1 Message Date
Nimar ec70ca3951 chore: release v3.85.2 2025-07-23 18:20:34 +02:00
NimarandGitHub 142d3a1612 fix(trace-view): render new lines in json table view (#8072) 2025-07-23 16:09:32 +00:00
Max DeichmannandGitHub 1dee7092b3 chore: configure posthog SDK (#8067)
* chore: configure posthog SDK

* chore: add score values to scores

* update posthog integration

* update posthog integration
2025-07-23 15:27:06 +00:00
Steffen SchmitzandGitHub 83b64f5e77 perf: extend model cache duration and invalidate on model changes (#8053) 2025-07-23 16:39:04 +02:00
Steffen SchmitzandGitHub 48b05247c8 fix(admin-api): handle Okta provisioning for POST and PATCH (#8064) 2025-07-23 14:15:10 +00:00
Max DeichmannandGitHub 9732262466 chore: fix node types discrepancies across packages (#8060)
* chore: fix node types discrepancies across packages

* chore: fix node types discrepancies across packages
2025-07-23 12:23:43 +00:00
Max DeichmannandGitHub e51e2df2dc chore: fix snyk again (#8050)
* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes
2025-07-23 12:04:01 +00:00
Nimar 74d1dfe5ac chore: release v3.85.1 2025-07-23 13:55:34 +02:00
NimarandGitHub 5633a8867e fix(datasets-table): don't table render json here (#8058) 2025-07-23 11:52:50 +00:00
6a8bdae22e feat: multi window playground (#7661)
* docs: add technical specs for multi window/prompt playground

* docs: add requirements and tech summary for sharing

* feat: add multi-window playground architecture and state isolation

* feat: make playground UI multi-window capable

* fix: multi window playground caching

* fix: jump to playground button adds windows to playground using stable id

* fix: avoid race condition in writing to cache before navigating to playground

* feat: add collapsible, more compact sections to non-model config

* feat: add compact version of model picker/params component

* feat: add ability to copy window states to multi playground

* fix: solve header crowding for thin windows in playground

* feat: make entire playground config section collapsible

* feat: change SaveToPrompt button to align with other window buttons

* feat: switch buttons in page header to icon-only when too small

* fix: adjust button labels to fit button size

* fix: update the execution window status correctly

* docs: complete remaining task items

* refactor: simplify playground state management hooks

* fix: make the model configuration section prettier

* docs: remove agent work files

* refactor: call register functions on the registry directly

* chore: refactored some components and compacted the design even more

* chore: move add message buttons

* chore: start align model param settings popover when compact

* chore: small UI spacing improvements

* fix: scrolling bug

* fix: handle config headers when window gets small

* chore: rename button hover

* feat: move CMD+Enter to execute all button

* feat: give user the choice to jump to fresh/existing playground

* push

* chore: fix

* chore: fix

* push

* push

* fixes

---------

Co-authored-by: Max Deichmann <m.deichmann@tum.de>
2025-07-23 10:01:43 +00:00
Steffen SchmitzandGitHub dbf994f9bb perf: replace prisma upsert with raw query (#8036)
* perf: replace prisma upsert with raw query

* chore: drop prsima import

* chore: lint
2025-07-23 10:00:06 +00:00
Steffen SchmitzandGitHub 4d5c288d1e perf: initialize clickhouse skip read map on startup (#8039)
* perf: initialize clickhouse skip read map on startup

* chore: linting

* chore: patch skip cache init
2025-07-23 09:29:44 +00:00
Hassieb PakzadandGitHub cf119fb9cc fix(otel): parse trace name if as_root is true (#8008)
* fix(otel): parse trace name if as_root is true

* push

* push

* push
2025-07-23 09:13:54 +00:00
Hassieb PakzadandGitHub 5af8c4e400 fix(otel): parse public attribute for traces (#8049) 2025-07-23 10:18:31 +02:00
Max DeichmannandGitHub 835a19bbd2 fix: fix snyk uploads (#8044) 2025-07-22 20:48:38 +00:00
Max DeichmannandGitHub ae91a98ad9 fix: use drop down for prompt full text search (#8043)
* fix: use drop down for prompt full text search

* fix: use drop down for prompt full text search

* fix: use drop down for prompt full text search

* fix: use drop down for prompt full text search

* fix: use drop down for prompt full text search
2025-07-22 20:33:25 +00:00
NimarandGitHub e38cc24205 chore: add default claude settings (#8042) 2025-07-22 19:12:19 +00:00
marliessophieandGitHub 7ab45ec685 feat(dataset-run-items): migrate from postgres to clickhouse; write to CH API-only (#7912)
* feat(migrations): add dataset_run_items table in clickhouse

* feat(dataset): add environment variables and utility for pg/ch execution

* chore: re-write `runitemsByRunIdOrItemId` to support both clickhouse and postgres execution

* chore: use `runItemsByRunId` or `runItemsByItemId` rather than  `runitemsByRunIdOrItemId`

* chore: rename env variables

* chore: reflect dri as ch data is seeder

* fixup: dataset run item types and converters

* chore: background migration

* chore: rewrite dataset-router routes

* chore(dataset): dual-write strategy for dataset run items to support ClickHouse and PostgreSQL

* chore: add utilities and converters

* chore: rewrite GET api/public/dataset-run-items

* feat(dataset): implement dataset run items deletion queue and processing

* chore: rewrite DELETE api/public/datasets/{datasetName}/runs/{runName}

* chore: rewrite GET api/public/datasets/{datasetName}/runs/{runName}

* fixup: rewrite GET api/public/datasets/{datasetName}/runs/{runName}

* chore(schema): add error to DRI schema

* fixup(ingestion): support dataset run items

* fixup: rewrite POST /api/public/dataset-run-items​

* fixup: rewrite POST /api/public/dataset-run-items​ --amend

* fixup(ingestion): support dataset run items --amend

* fixup(experiments): preliminary implementation, for illustrative purposes only

* fixup(schema): indexes

* fixup(schema): remove indexes, add `output`

* chore: support DRI ingestion

* chore: eslint, writes related

* chore: rewrite experiment service

* chore: rm file

* chore: cascade delete dataset run items

* chore: add clustered migrations

* chore: update background migration

* chore: handle experiment job errors

* chore: remove CH read implementation

* chore: remove md file

* chore: remove CH read implementation II

* chore: fix typing

* chore: fix migration types

* fix: lint/imports

* fix: import errors

* chore: imports and comments

* chore: remove only from test

* fix: validateDatasetRunAndFetch

* fix: validateDatasetRunAndFetch id

* fixup: attempt fix of test

* fix: test

* chore: test validate logging

* chore: adjust logs

* chore: revert cascade delete

* Revert "feat(dataset): implement dataset run items deletion queue and processing"

This reverts commit 9f725f053386ded41e3617efaacee128d5ac023f.

* chore: delete imports

* revert: experiment service changes

* chore: adjust comment

* rename: env variables to incl experiment

* chore: remove unused variable eslint disables from datasetExecution and types files

* refactor: remove enrichedDatasetRunItem function and integrate its logic directly into IngestionService

* chore: remove TODO comment regarding dataset run item authorization

* refactor: replace Date constructor with parseClickhouseUTCDateTimeFormat for date parsing in dataset run item conversion

* chore: remove deprecated flag for runitemsByRunIdOrItemId method

* refactor: remove getDatasetRunItemsByRunId function to streamline dataset run item retrieval

* refactor: add back run properties in dataset runs mapping

* chore: revert changes to delete-dataset-run API

* refactor: remove validateDatasetItemAndFetch function and replace its usage with direct Prisma query in IngestionService

* refactor: delete validateCreateDatasetRunItemBodyAndFetch function and replace its logic with direct Prisma queries in dataset-run-items API

* refactor: move createOrFetchDatasetRun function to a new dataset-runs API file and maintain unique constraint handling

* refactor: remove validateDatasetRunAndFetch function and replace its usage with direct Prisma queries in dataset-run-items and IngestionService

* chore: nits

* refactor: streamline dataset run item creation by consolidating validation and execution logic

* chore: fix imports

* fix: types

* fix: simplify ingestion schema

* refactor: enhance ingestion schema creation to support public and internal environments

* chore: extend immutable keys list DRI

* chore: use same id for clickhouse and postgres

* chore: remove background migration trigger

* chore: move dataset run items migration into readme

* chore: remove creation of DRI in seeder

* chore: lint
2025-07-22 16:20:47 +00:00
Nimar b101bf48d3 chore: release v3.85.0 2025-07-22 16:47:56 +02:00
NimarandGitHub 4c4f0ff3c0 feat(tracing): pretty display json as collapsible table (#7938)
* feat(tracing): pretty display json as collapsible table

* show empty list and unwrap single item containing objects

* enable toggle for code or pretty view

* clean up

* fix type casting

* some more fixes

* expand row with click anywhere

* fix alignment

* remove dead code

* cleanup

* make it more clean

* don't unwrap initial items

* collapse expand all

* fixing collapse button

* don't render for chatml

* better markdown check

* reduce col width

* make table borderless

* display null / empty string for empty value

* smaller text

* make table more compact

* make preview items grey & italic

* linebreak path column

* table headers inherit color

* cleanup

* external collapsed state; also move button into code view

* remove code view switch

* show empty dicts also as empty

* always show objects as tables

* extract markdown into helper function

* review fixes

* update
2025-07-22 14:41:11 +00:00
Steffen SchmitzandGitHub a42ca96225 chore: expose email_from_address and smtp_connection_url in docker-compose (#8033)
chore: expose email_from_address and smtp_connection_url in docker-compose.yml
2025-07-22 13:26:03 +00:00
marliessophieandGitHub f63420827b feat(prompts/playground): allow arbitrary message sorting in messages (#8031) 2025-07-22 12:41:39 +00:00
Steffen SchmitzandGitHub e6fd056ae5 chore: migrate analytic routes to new AMTs (#8029)
* chore: migrate analytic routes to new AMTs

* chore: reduce new shared code

* chore: cleanup

* chore: cleanup
2025-07-22 12:39:48 +00:00
Steffen SchmitzandGitHub 4708fe4f47 chore: migrate additional session routes to new AMTs (#8027)
* chore: migrate additional session routes to new AMTs

* chore: remove unused function

* chore: remove unused code

* chore: remove outdated row in readme

* chore: update assertions
2025-07-22 12:24:57 +00:00
marliessophieandGitHub 362246b616 chore(datasets): update button label from "Rename" to "Edit" in DatasetActionButton component (#8030) 2025-07-22 12:21:45 +00:00
Steffen SchmitzandGitHub b3de5fd71a chore: migrate traces table to AMTs (#7853)
* chore: migrate traces table to AMTs

* chore: fix linting errors

* chore: ensure inplace update

* chore: add updated query

* chore: use final on query
2025-07-21 14:12:25 +00:00
Hassieb PakzadandGitHub ad236ecc99 fix(model-params): increase max tokens limit (#8005) 2025-07-21 15:12:37 +02:00
NimarandGitHub beb2063dcb fix: add select all to multi select dropdown (#8004) 2025-07-21 12:53:09 +00:00
Hassieb PakzadandGitHub 4de10298dc fix(otel): parse only trace io on trace update (#8003) 2025-07-21 12:11:04 +00:00
Danushka FernandoandGitHub f9924975fa feat: expose a key prefix option for Redis (#7958) 2025-07-21 14:01:14 +02:00
Steffen SchmitzandGitHub 98774cbd61 fix(sessions): infer session env from trace (#7996) 2025-07-21 10:44:17 +00:00
Steffen SchmitzandGitHub 80361ef53f fix(sessions): align duration unit with UI (#7998)
* fix(sessions): align duration unit with UI

* chore: update tests
2025-07-21 09:40:17 +00:00
Steffen SchmitzandGitHub 8350507434 chore: add missing blob-storage export entitlement (#7990) 2025-07-21 08:09:22 +00:00
marliessophieandGitHub 0a976edb70 fix(prompts): commitMessage error handling in NewPromptForm (#7984) 2025-07-20 16:13:39 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Max Deichmann
6a67c93010 chore(deps): bump exponential-backoff from 3.1.1 to 3.1.2 (#7412)
Bumps [exponential-backoff](https://github.com/coveooss/exponential-backoff) from 3.1.1 to 3.1.2.
- [Commits](https://github.com/coveooss/exponential-backoff/compare/v3.1.1...v3.1.2)

---
updated-dependencies:
- dependency-name: exponential-backoff
  dependency-version: 3.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Max Deichmann <m.deichmann@tum.de>
2025-07-20 09:33:39 +00:00
Max DeichmannandGitHub e51ee05d9c chore: upgrade express 5 (#7978)
* chore: upgrade express 5

* chore: upgrade express 5
2025-07-20 09:13:22 +00:00
Max Deichmann 565c561715 chore: release v3.84.0 2025-07-20 09:45:00 +02:00
Max DeichmannandGitHub d1338ad5e1 chore: move dev dependencies to dev dependencies web container (#7977)
* fix

* fix

* fix

* fix

* chore: move dev dependencies to dev dependencies web container
2025-07-19 19:15:48 +00:00
Max DeichmannandGitHub 52d4fe37de chore: remove unused tsx (#7976)
* fix

* fix

* fix

* fix
2025-07-19 19:15:40 +00:00
Max DeichmannandGitHub 67ef7abb63 chore: full text search for prompts (#7973)
* fix

* fix

* fix
2025-07-19 18:55:58 +00:00
Max DeichmannandGitHub 727ae71a93 fix: fix codemirror upgrade (#7975)
fix
2025-07-19 17:57:49 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a8eabbc96d chore(deps): bump the radix-ui group with 23 updates (#5562)
Bumps the radix-ui group with 23 updates:

| Package | From | To |
| --- | --- | --- |
| [@radix-ui/react-accordion](https://github.com/radix-ui/primitives) | `1.2.1` | `1.2.3` |
| [@radix-ui/react-alert-dialog](https://github.com/radix-ui/primitives) | `1.1.2` | `1.1.6` |
| [@radix-ui/react-avatar](https://github.com/radix-ui/primitives) | `1.1.1` | `1.1.3` |
| [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives) | `1.1.2` | `1.1.4` |
| [@radix-ui/react-collapsible](https://github.com/radix-ui/primitives) | `1.1.1` | `1.1.3` |
| [@radix-ui/react-dialog](https://github.com/radix-ui/primitives) | `1.1.2` | `1.1.6` |
| [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives) | `2.1.2` | `2.1.6` |
| [@radix-ui/react-hover-card](https://github.com/radix-ui/primitives) | `1.1.2` | `1.1.6` |
| @radix-ui/react-icons | `1.3.0` | `1.3.2` |
| [@radix-ui/react-label](https://github.com/radix-ui/primitives) | `2.1.0` | `2.1.2` |
| [@radix-ui/react-popover](https://github.com/radix-ui/primitives) | `1.1.2` | `1.1.6` |
| [@radix-ui/react-progress](https://github.com/radix-ui/primitives) | `1.1.1` | `1.1.2` |
| [@radix-ui/react-radio-group](https://github.com/radix-ui/primitives) | `1.2.1` | `1.2.3` |
| [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives) | `1.2.0` | `1.2.3` |
| [@radix-ui/react-select](https://github.com/radix-ui/primitives) | `2.1.2` | `2.1.6` |
| [@radix-ui/react-separator](https://github.com/radix-ui/primitives) | `1.1.0` | `1.1.2` |
| [@radix-ui/react-slider](https://github.com/radix-ui/primitives) | `1.2.1` | `1.2.3` |
| [@radix-ui/react-slot](https://github.com/radix-ui/primitives) | `1.1.0` | `1.1.2` |
| [@radix-ui/react-switch](https://github.com/radix-ui/primitives) | `1.1.1` | `1.1.3` |
| [@radix-ui/react-tabs](https://github.com/radix-ui/primitives) | `1.1.1` | `1.1.3` |
| [@radix-ui/react-toggle](https://github.com/radix-ui/primitives) | `1.1.0` | `1.1.2` |
| [@radix-ui/react-toggle-group](https://github.com/radix-ui/primitives) | `1.1.0` | `1.1.2` |
| [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives) | `1.1.3` | `1.1.8` |


Updates `@radix-ui/react-accordion` from 1.2.1 to 1.2.3
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-alert-dialog` from 1.1.2 to 1.1.6
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-avatar` from 1.1.1 to 1.1.3
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-checkbox` from 1.1.2 to 1.1.4
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-collapsible` from 1.1.1 to 1.1.3
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-dialog` from 1.1.2 to 1.1.6
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-dropdown-menu` from 2.1.2 to 2.1.6
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-hover-card` from 1.1.2 to 1.1.6
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-icons` from 1.3.0 to 1.3.2

Updates `@radix-ui/react-label` from 2.1.0 to 2.1.2
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-popover` from 1.1.2 to 1.1.6
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-progress` from 1.1.1 to 1.1.2
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-radio-group` from 1.2.1 to 1.2.3
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-scroll-area` from 1.2.0 to 1.2.3
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-select` from 2.1.2 to 2.1.6
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-separator` from 1.1.0 to 1.1.2
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-slider` from 1.2.1 to 1.2.3
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-slot` from 1.1.0 to 1.1.2
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-switch` from 1.1.1 to 1.1.3
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-tabs` from 1.1.1 to 1.1.3
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-toggle` from 1.1.0 to 1.1.2
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-toggle-group` from 1.1.0 to 1.1.2
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-tooltip` from 1.1.3 to 1.1.8
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-accordion"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-alert-dialog"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-avatar"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-checkbox"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-collapsible"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-dialog"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-dropdown-menu"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-hover-card"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-icons"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-label"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-popover"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-progress"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-radio-group"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-scroll-area"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-select"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-separator"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-slider"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-slot"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-switch"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-tabs"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-toggle"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-toggle-group"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-tooltip"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-19 17:21:00 +00:00
Max DeichmannandGitHub 51befcad0f chore: upgrade codemirror (#7972)
fix
2025-07-19 17:13:38 +00:00
Max DeichmannandGitHub ee8b5e4998 chore: package upgrade vitest (#7971)
fix
2025-07-19 17:13:14 +00:00
Max DeichmannandGitHub 9bb9f7fa39 chore: remove ineffective pnpm configs (#7970) 2025-07-19 17:04:43 +00:00
Thorsten SpiekerandGitHub 4cc10840ab feat: automation secrets (#7807) 2025-07-18 17:20:48 +02:00
125 changed files with 9577 additions and 2979 deletions
+14
View File
@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(rg:*)",
"Bash(grep:*)",
"Bash(ls:*)",
"Bash(cat:*)",
"Bash(head:*)",
"Bash(tail:*)"
],
"deny": []
}
}
+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
ignore-words-list = afterall,vertx,notIn,alue
+1
View File
@@ -36,6 +36,7 @@ updates:
patterns:
- "express"
- "@types/express"
- "@types/express-serve-static-core"
observability:
patterns:
- "dd-trace"
+10 -42
View File
@@ -1,60 +1,28 @@
name: Snyk Container
on:
push:
branches:
- "main"
# Snyk cannot upload results in merge group. Hence, we only run when pushing https://github.com/github/codeql-action/issues/1572
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
branches: ["main"]
jobs:
snyk:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Run Snyk to check Docker image for vulnerabilities (langfuse-web)
continue-on-error: true
- name: Scan web image with Snyk
uses: snyk/actions/docker@master
continue-on-error: true # let upload step always run
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
image: langfuse/langfuse
args: --file=web/Dockerfile
image: langfuse/langfuse # pulled from Docker Hub
args: --severity-threshold=high # (any extra CLI flags, but NO --file)
# Workaround for https://github.com/github/codeql-action/issues/2187
- name: Replace security-severity undefined for license-related findings
run: 'sed -i ''s/"security-severity": "undefined"/"security-severity": "0"/g'' snyk.sarif'
- name: Replace security-severity null for license-related findings
run: 'sed -i ''s/"security-severity": "null"/"security-severity": "0"/g'' snyk.sarif'
- name: Upload result to GitHub Code Scanning
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: snyk.sarif
category: web
- name: Run Snyk to check Docker image for vulnerabilities (langfuse-worker)
continue-on-error: true
- name: Scan worker image with Snyk
uses: snyk/actions/docker@master
continue-on-error: true # let upload step always run
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
image: langfuse/langfuse-worker
args: --file=worker/Dockerfile
# Workaround for https://github.com/github/codeql-action/issues/2187
- name: Replace security-severity undefined for license-related findings
run: 'sed -i ''s/"security-severity": "undefined"/"security-severity": "0"/g'' snyk.sarif'
- name: Replace security-severity null for license-related findings
run: 'sed -i ''s/"security-severity": "null"/"security-severity": "0"/g'' snyk.sarif'
- name: Upload result to GitHub Code Scanning
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: snyk.sarif
category: worker
image: langfuse/langfuse-worker # pulled from Docker Hub
args: --severity-threshold=high # (any extra CLI flags, but NO --file)
+43
View File
@@ -0,0 +1,43 @@
name: Trivy Container Security
on:
push:
branches: ["main"]
jobs:
trivy-web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Run Trivy vulnerability scanner (web)
uses: aquasecurity/trivy-action@master
with:
image-ref: "langfuse/langfuse"
format: "sarif"
output: "trivy-web.sarif"
- name: Upload Trivy scan results to GitHub Security tab (web)
if: always() && hashFiles('trivy-web.sarif') != ''
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: "trivy-web.sarif"
category: trivy-web
trivy-worker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Run Trivy vulnerability scanner (worker)
uses: aquasecurity/trivy-action@master
with:
image-ref: "langfuse/langfuse-worker"
format: "sarif"
output: "trivy-worker.sarif"
- name: Upload Trivy scan results to GitHub Security tab (worker)
if: always() && hashFiles('trivy-worker.sarif') != ''
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: "trivy-worker.sarif"
category: trivy-worker
+2
View File
@@ -62,6 +62,8 @@ services:
REDIS_TLS_CA: ${REDIS_TLS_CA:-/certs/ca.crt}
REDIS_TLS_CERT: ${REDIS_TLS_CERT:-/certs/redis.crt}
REDIS_TLS_KEY: ${REDIS_TLS_KEY:-/certs/redis.key}
EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-}
SMTP_CONNECTION_URL: ${SMTP_CONNECTION_URL:-}
langfuse-web:
image: docker.io/langfuse/langfuse:3
-5
View File
@@ -45,10 +45,5 @@
"ts-node": "^10.9.2",
"tsc-watch": "^6.2.0",
"typescript": "^5.4.5"
},
"pnpm": {
"overrides": {
"nanoid": "^3.3.8"
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.83.0",
"version": "3.85.2",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
+2 -9
View File
@@ -79,7 +79,7 @@
"bullmq": "^5.34.10",
"dd-trace": "^5.36.0",
"decimal.js": "^10.4.3",
"exponential-backoff": "^3.1.1",
"exponential-backoff": "^3.1.2",
"https-proxy-agent": "^7.0.6",
"ioredis": "^5.4.1",
"jsonpath-plus": "10.3.0",
@@ -118,17 +118,10 @@
"prisma-kysely": "^1.8.0",
"ts-node": "^10.9.2",
"tsc-watch": "^6.2.0",
"tsx": "^4.19.1",
"typescript": "^5.4.5",
"vitest": "^2.1.2"
"typescript": "^5.4.5"
},
"peerDependencies": {
"@types/react": "~18.2.79",
"react": "~18.2.0"
},
"pnpm": {
"overrides": {
"nanoid": "^3.3.8"
}
}
}
@@ -23,6 +23,7 @@ import {
SEED_TEXT_PROMPTS,
} from "./utils/postgres-seed-constants";
import {
generateDatasetItemId,
generateDatasetRunTraceId,
generateEvalObservationId,
generateEvalScoreId,
@@ -517,6 +518,7 @@ export async function createDatasets(
description: data.description,
projectId,
metadata: data.metadata,
id: `${datasetName}-${projectId.slice(-8)}`,
},
}));
@@ -532,13 +534,23 @@ export async function createDatasets(
const datasetItem = await prisma.datasetItem.upsert({
where: {
id_projectId: {
id: `${dataset.id}-${index}`,
id: generateDatasetItemId(
datasetName,
index,
projectId,
SEED_DATASETS.indexOf(data) || 0,
),
projectId,
},
},
create: {
projectId,
id: `${dataset.id}-${index}`,
id: generateDatasetItemId(
datasetName,
index,
projectId,
SEED_DATASETS.indexOf(data) || 0,
),
datasetId: dataset.id,
sourceTraceId: sourceTraceId ?? null,
sourceObservationId: null,
@@ -554,14 +566,14 @@ export async function createDatasets(
for (let datasetRunNumber = 0; datasetRunNumber < 3; datasetRunNumber++) {
const datasetRun = await prisma.datasetRuns.upsert({
where: {
datasetId_projectId_name: {
datasetId: dataset.id,
id_projectId: {
id: `demo-dataset-run-${datasetRunNumber}-${projectId.slice(-8)}`,
projectId,
name: `demo-dataset-run-${datasetRunNumber}`,
},
},
create: {
projectId,
id: `demo-dataset-run-${datasetRunNumber}-${projectId.slice(-8)}`,
name: `demo-dataset-run-${datasetRunNumber}`,
description: Math.random() > 0.5 ? "Dataset run description" : "",
datasetId: dataset.id,
@@ -2,6 +2,8 @@ import {
TraceRecordInsertType,
ObservationRecordInsertType,
ScoreRecordInsertType,
DatasetRunItemRecordInsertType,
createDatasetRunItemsCh,
} from "../../../src/server";
import { SEED_TEXT_PROMPTS } from "./postgres-seed-constants";
import {
@@ -42,6 +44,16 @@ export class ClickHouseQueryBuilder {
return await createObservationsCh(observations);
}
/**
* Creates INSERT query for dataset run items data using VALUES syntax.
* Use for: Small datasets, dataset run items that link to postgres data (e.g. dataset runs)
*/
async executeDatasetRunItemsInsert(
datasetRunItems: DatasetRunItemRecordInsertType[],
): Promise<InsertResult> {
return await createDatasetRunItemsCh(datasetRunItems);
}
/**
* Creates INSERT query for score data using VALUES syntax.
* Use for: Small datasets, scores with custom values and metadata.
@@ -6,6 +6,8 @@ import {
REALISTIC_MODELS,
} from "./clickhouse-seed-constants";
import {
generateDatasetItemId,
generateDatasetRunItemId,
generateDatasetRunTraceId,
generateEvalObservationId,
generateEvalScoreId,
@@ -23,6 +25,8 @@ import {
ObservationRecordInsertType,
ScoreRecordInsertType,
TraceRecordInsertType,
DatasetRunItemRecordInsertType,
createDatasetRunItem,
} from "../../../src/server";
/**
@@ -60,6 +64,49 @@ export class DataGenerator {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
/**
* Creates dataset run items for dataset runs.
* Use for: Dataset experiment scenarios.
*/
generateDatasetRunItem(
input: DatasetItemInput & { runCreatedAt: number },
projectId: string,
): DatasetRunItemRecordInsertType {
const datasetRunItemId = generateDatasetRunItemId(
input.datasetName,
input.itemIndex,
projectId,
input.runNumber || 0,
);
// TODO: there are too many dataset run items in the postgres database?
return createDatasetRunItem({
id: datasetRunItemId,
project_id: projectId,
trace_id: generateDatasetRunTraceId(
input.datasetName,
input.itemIndex,
projectId,
input.runNumber || 0,
),
dataset_id: `${input.datasetName}-${projectId.slice(-8)}`,
dataset_run_id: `demo-dataset-run-${input.runNumber}-${projectId.slice(-8)}`,
dataset_run_name: `demo-dataset-run-${input.runNumber}-${projectId.slice(-8)}`,
dataset_run_created_at: input.runCreatedAt,
dataset_run_description:
(input.runNumber || 0) % 2 === 0 ? "Dataset run description" : "",
dataset_run_metadata: { key: "value" },
dataset_item_id: generateDatasetItemId(
input.datasetName,
input.itemIndex,
projectId,
input.runNumber || 0,
),
dataset_item_input: input.item.input,
dataset_item_expected_output: input.item.output,
});
}
/**
* Creates traces from dataset items for experiment runs.
* Use for: Dataset experiments scenarios.
@@ -1,3 +1,21 @@
export const generateDatasetRunItemId = (
datasetName: string,
itemIndex: number,
projectId: string,
runNumber: number,
) => {
return `dataset-run-item-${datasetName}-${itemIndex}-${projectId.slice(-8)}-${runNumber}`;
};
export const generateDatasetItemId = (
datasetName: string,
itemIndex: number,
projectId: string,
runNumber: number,
) => {
return `dataset-item-${datasetName}-${itemIndex}-${projectId.slice(-8)}-${runNumber}`;
};
export const generateDatasetRunTraceId = (
datasetName: string,
itemIndex: number,
@@ -92,12 +92,26 @@ export class SeederOrchestrator {
logger.info(
`Processing run ${runNumber + 1}/${numberOfRuns} for project ${projectId}`,
);
// const now = Date.now();
const traces: TraceRecordInsertType[] = [];
const observations: ObservationRecordInsertType[] = [];
// const datasetRunItems: DatasetRunItemRecordInsertType[] = [];
for (const seedDataset of SEED_DATASETS) {
for (const [itemIndex, datasetItem] of seedDataset.items.entries()) {
// // Generate dataset run item data
// const datasetRunItem = this.dataGenerator.generateDatasetRunItem(
// {
// datasetName: seedDataset.name,
// itemIndex,
// item: datasetItem,
// runNumber,
// runCreatedAt: now,
// },
// projectId,
// );
// Generate trace data
const trace = this.dataGenerator.generateDatasetTrace(
{
@@ -123,12 +137,14 @@ export class SeederOrchestrator {
traces.push(trace);
observations.push(observation);
// datasetRunItems.push(datasetRunItem);
}
}
try {
await this.queryBuilder.executeTracesInsert(traces);
await this.queryBuilder.executeObservationsInsert(observations);
// await this.queryBuilder.executeDatasetRunItemsInsert(datasetRunItems);
} catch (error) {
logger.error(`✗ Insert failed:`, error);
throw error;
+11
View File
@@ -44,10 +44,17 @@ export const AvailableWebhookApiSchema = z.record(
z.enum(["v1"]),
);
export const RequestHeaderSchema = z.object({
secret: z.boolean(),
value: z.string(),
});
export const WebhookActionConfigSchema = z.object({
type: z.literal("WEBHOOK"),
url: z.url(),
headers: z.record(z.string(), z.string()),
requestHeaders: z.record(z.string(), RequestHeaderSchema),
displayHeaders: z.record(z.string(), RequestHeaderSchema),
apiVersion: AvailableWebhookApiSchema,
secretKey: z.string(),
displaySecretKey: z.string(),
@@ -55,6 +62,8 @@ export const WebhookActionConfigSchema = z.object({
export const SafeWebhookActionConfigSchema = WebhookActionConfigSchema.omit({
secretKey: true,
headers: true,
requestHeaders: true,
});
export type SafeWebhookActionConfig = z.infer<
@@ -64,6 +73,8 @@ export type SafeWebhookActionConfig = z.infer<
export const WebhookActionCreateSchema = WebhookActionConfigSchema.omit({
secretKey: true,
displaySecretKey: true,
headers: true, // don't use legacy field anymore
displayHeaders: true,
});
export const ActionConfigSchema = z.discriminatedUnion("type", [
@@ -0,0 +1,28 @@
import z from "zod/v4";
import { jsonSchema } from "../utils/zod";
import { MetadataDomain } from "./traces";
export const DatasetRunItemSchema = z.object({
id: z.string(),
projectId: z.string(),
datasetRunId: z.string(),
datasetItemId: z.string(),
datasetId: z.string(),
traceId: z.string(),
observationId: z.string().nullable(),
error: z.string().nullable(),
// timestamps
createdAt: z.date(),
updatedAt: z.date(),
// dataset run fields
datasetRunName: z.string(),
datasetRunDescription: z.string().nullable(),
datasetRunMetadata: MetadataDomain,
datasetRunCreatedAt: z.date(),
// dataset item fields
datasetItemInput: jsonSchema,
datasetItemExpectedOutput: jsonSchema,
datasetItemMetadata: MetadataDomain,
});
export type DatasetRunItemDomain = z.infer<typeof DatasetRunItemSchema>;
+2 -2
View File
@@ -3,8 +3,8 @@ import { jsonSchema } from "../utils/zod";
import { EventActionSchema } from "./automations";
export const WebhookDefaultHeaders = {
"Content-Type": "application/json",
"User-Agent": "Langfuse/1.0",
"content-type": "application/json",
"user-agent": "Langfuse/1.0",
};
export const WebhookOutboundBaseSchema = z.object({
+11 -2
View File
@@ -16,6 +16,7 @@ const EnvSchema = z.object({
.nullable(),
REDIS_AUTH: z.string().nullish(),
REDIS_CONNECTION_STRING: z.string().nullish(),
REDIS_KEY_PREFIX: z.string().nullish(),
REDIS_TLS_ENABLED: z.enum(["true", "false"]).default("false"),
REDIS_TLS_CA_PATH: z.string().optional(),
REDIS_TLS_CERT_PATH: z.string().optional(),
@@ -31,8 +32,10 @@ const EnvSchema = z.object({
"ENCRYPTION_KEY must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32",
)
.optional(),
LANGFUSE_CACHE_MODEL_MATCH_ENABLED: z.enum(["true", "false"]).default("true"),
LANGFUSE_CACHE_MODEL_MATCH_TTL_SECONDS: z.coerce.number().default(86400), // 24 hours
LANGFUSE_CACHE_PROMPT_ENABLED: z.enum(["true", "false"]).default("true"),
LANGFUSE_CACHE_PROMPT_TTL_SECONDS: z.coerce.number().default(300),
LANGFUSE_CACHE_PROMPT_TTL_SECONDS: z.coerce.number().default(300), // 5 minutes
CLICKHOUSE_URL: z.string().url(),
CLICKHOUSE_CLUSTER_NAME: z.string().default("default"),
CLICKHOUSE_DB: z.string().default("default"),
@@ -107,7 +110,13 @@ const EnvSchema = z.object({
LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS: z.coerce.number().default(240_000), // 4 minutes
LANGFUSE_CLICKHOUSE_QUERY_MAX_ATTEMPTS: z.coerce.number().default(3), // Maximum attempts for socket hang up errors
LANGFUSE_SKIP_S3_LIST_FOR_OBSERVATIONS_PROJECT_IDS: z.string().optional(),
// Dataset Run Items Migration Environment Variables
LANGFUSE_EXPERIMENT_DATASET_RUN_ITEMS_WRITE_CH: z
.enum(["true", "false"])
.default("false"),
LANGFUSE_EXPERIMENT_DATASET_RUN_ITEMS_READ_CH: z
.enum(["true", "false"])
.default("false"),
LANGFUSE_EXPERIMENT_COMPARE_READ_FROM_AGGREGATING_MERGE_TREES: z
.enum(["true", "false"])
.default("false"),
@@ -11,7 +11,8 @@ export type IngestionEntityTypes =
| "trace"
| "observation"
| "score"
| "sdk_log";
| "sdk_log"
| "dataset_run_item";
export const getClickhouseEntityType = (
eventType: string,
@@ -29,6 +30,8 @@ export const getClickhouseEntityType = (
return "observation";
case eventTypes.SCORE_CREATE:
return "score";
case eventTypes.DATASET_RUN_ITEM_CREATE:
return "dataset_run_item";
case eventTypes.SDK_LOG:
return "sdk_log";
default:
@@ -0,0 +1,94 @@
import { env } from "../../env";
import { logger } from "../../server/logger";
import {
DatasetRunItemsExecutionStrategy,
DatasetRunItemsOperationType,
} from "./types";
/**
* Returns the execution strategy for dataset run items based on environment variables.
*
* Two-phase migration approach:
* 1. Dual-write phase: DATASET_RUN_ITEMS_WRITE_TO_CLICKHOUSE=true (write to both databases)
* 2. Read migration phase: DATASET_RUN_ITEMS_READ_FROM_CLICKHOUSE=true (read from ClickHouse)
*/
function getDatasetRunItemsExecutionStrategy(): DatasetRunItemsExecutionStrategy {
return {
shouldWriteToClickHouse:
env.LANGFUSE_EXPERIMENT_DATASET_RUN_ITEMS_WRITE_CH === "true",
shouldReadFromClickHouse:
env.LANGFUSE_EXPERIMENT_DATASET_RUN_ITEMS_READ_CH === "true",
};
}
// Re-export the enum for backward compatibility
/**
* Executes the appropriate database operation based on the execution strategy.
*
* @param postgresExecution - Function to execute PostgreSQL operation
* @param clickhouseExecution - Function to execute ClickHouse operation
* @param operationType - Type of operation ("read" or "write")
* @returns Result from the selected execution strategy
*/
export async function executeWithDatasetRunItemsStrategy<TInput, TOutput>({
input,
operationType,
postgresExecution,
clickhouseExecution,
}: {
input: TInput;
operationType: DatasetRunItemsOperationType;
// eslint-disable-next-line no-unused-vars
postgresExecution: (input: TInput) => Promise<TOutput>;
// eslint-disable-next-line no-unused-vars
clickhouseExecution: (input: TInput) => Promise<TOutput>;
}): Promise<TOutput> {
const strategy = getDatasetRunItemsExecutionStrategy();
if (operationType === DatasetRunItemsOperationType.WRITE) {
// For write operations, implement dual-write strategy
if (strategy.shouldWriteToClickHouse) {
// Dual-write phase: write to both databases
const postgresResult = await postgresExecution(input);
try {
await clickhouseExecution(input);
logger.debug("Successfully wrote to both PostgreSQL and ClickHouse", {
operation: `dataset_run_items_${operationType}`,
});
} catch (error) {
logger.error("ClickHouse write failed during dual-write phase", {
error: error instanceof Error ? error.message : String(error),
operation: `dataset_run_items_${operationType}`,
});
// Continue with PostgreSQL result since it succeeded
}
return postgresResult;
} else {
// Write only to PostgreSQL
return await postgresExecution(input);
}
} else {
// For read operations, rely on the strategy
const shouldExecuteClickhouse = strategy.shouldReadFromClickHouse;
if (shouldExecuteClickhouse) {
try {
return await clickhouseExecution(input);
} catch (error) {
logger.error(
"ClickHouse execution failed, falling back to PostgreSQL",
{
error: error instanceof Error ? error.message : String(error),
operation: `dataset_run_items_${operationType}`,
},
);
// Fallback to PostgreSQL for reliability
return await postgresExecution(input);
}
} else {
return await postgresExecution(input);
}
}
}
@@ -0,0 +1,16 @@
/**
* Types and enums for dataset run items execution.
* This file is frontend-safe and doesn't import server-side dependencies.
*/
export enum DatasetRunItemsOperationType {
// eslint-disable-next-line no-unused-vars
READ = "read",
// eslint-disable-next-line no-unused-vars
WRITE = "write",
}
export type DatasetRunItemsExecutionStrategy = {
shouldWriteToClickHouse: boolean;
shouldReadFromClickHouse: boolean;
};
+6 -1
View File
@@ -20,6 +20,7 @@ export * from "./clickhouse/schemaUtils";
export * from "./clickhouse/schema";
export * from "./repositories/definitions";
export * from "../server/ingestion/types";
export * from "../server/ingestion/modelMatch";
export * from "./ingestion/processEventBatch";
export * from "../server/ingestion/validateAndInflateScore";
export * from "./redis/redis";
@@ -62,9 +63,13 @@ export * from "./services/DashboardService";
export * from "./services/TableViewService";
export * from "./services/DefaultEvaluationModelService";
export * from "./clickhouse/measureAndReturn";
export * from "./data-deletion/ingestionFileDeletion";
export * from "./s3";
// dataset run items
export * from "./dataset-run-items/datasetExecution";
export * from "./dataset-run-items/types";
// test utils
export * from "./test-utils";
export * from "./utils/headerUtils";
@@ -1,13 +1,15 @@
import { Model, Prisma } from "@langfuse/shared";
import { Model, Prisma } from "../../";
import {
instrumentAsync,
logger,
recordIncrement,
} from "@langfuse/shared/src/server";
import { env } from "../env";
import { redis } from "@langfuse/shared/src/server";
redis,
safeMultiDel,
} from "../";
import { type Cluster } from "ioredis";
import { env } from "../../env";
import { Decimal } from "decimal.js";
import { prisma } from "@langfuse/shared/src/db";
import { prisma } from "../../db";
export type ModelMatchProps = {
projectId: string;
@@ -178,6 +180,11 @@ export const getRedisModelKey = (p: ModelMatchProps) => {
};
const getModelMatchKeyPrefix = () => {
if (env.REDIS_CLUSTER_ENABLED === "true") {
// Use hash tags for Redis cluster compatibility
// This ensures all model cache keys are placed on the same hash slot
return "{model-match}";
}
return "model-match";
};
@@ -205,3 +212,35 @@ export const redisModelToPrismaModel = (redisModel: string): Model => {
: null,
};
};
export async function clearModelCacheForProject(
projectId: string,
): Promise<void> {
if (env.LANGFUSE_CACHE_MODEL_MATCH_ENABLED === "false" || !redis) {
return;
}
try {
const pattern = `${getModelMatchKeyPrefix()}:${projectId}:*`;
const keys =
env.REDIS_CLUSTER_ENABLED === "true"
? (
await Promise.all(
(redis as Cluster)
.nodes("master")
.map((node) => node.keys(pattern) || []),
)
).flat()
: await redis.keys(pattern);
if (keys.length > 0) {
await safeMultiDel(redis, keys);
logger.info(
`Cleared ${keys.length} model cache entries for project ${projectId}`,
);
}
} catch (error) {
logger.error(`Error clearing model cache for project ${projectId}`, error);
}
}
@@ -256,11 +256,18 @@ export const processEventBatch = async (
const shardingKey = `${authCheck.scope.projectId}-${eventData.eventBodyId}`;
const queue = IngestionQueue.getInstance({ shardingKey });
const shouldSkipS3List =
getClickhouseEntityType(eventData.type) === "observation" &&
const isDatasetRunItemEvent =
getClickhouseEntityType(eventData.type) === "dataset_run_item";
const isObservationEvent =
getClickhouseEntityType(eventData.type) === "observation";
const isOtelOrSkipS3Project =
authCheck.scope.projectId !== null &&
(projectIdsToSkipS3List.includes(authCheck.scope.projectId) ||
source === "otel");
(source === "otel" ||
projectIdsToSkipS3List.includes(authCheck.scope.projectId));
const shouldSkipS3List =
isDatasetRunItemEvent || (isObservationEvent && isOtelOrSkipS3Project);
return queue
? queue.add(
+44 -5
View File
@@ -224,6 +224,7 @@ export const eventTypes = {
GENERATION_CREATE: "generation-create",
GENERATION_UPDATE: "generation-update",
SDK_LOG: "sdk-log",
DATASET_RUN_ITEM_CREATE: "dataset-run-item-create",
// LEGACY, only required for backwards compatibility
OBSERVATION_CREATE: "observation-create",
OBSERVATION_UPDATE: "observation-update",
@@ -343,9 +344,15 @@ export const SdkLogEvent = z.object({
// As we allow plain values, arrays, and objects the JSON parse via bodyParser should suffice.
// Complete schema factory - single source of truth for ALL schemas
const createAllIngestionSchemas = (
environmentSchema: z.ZodDefault<z.ZodString>,
) => {
const createAllIngestionSchemas = ({
isPublic = true,
}: {
isPublic: boolean;
}) => {
const environmentSchema = isPublic
? PublicEnvironmentName
: InternalEnvironmentName;
// Base schemas with environment
const TraceBody = z.object({
id: idSchema.nullish(),
@@ -504,6 +511,22 @@ const createAllIngestionSchemas = (
]),
);
const DatasetRunItemBody = z.object({
// Core identifiers
id: idSchema.nullish(),
traceId: z.string(),
observationId: z.string().nullish(),
error: z.string().nullish(),
// Metadata (optional)
createdAt: stringDateTime.nullish(),
// Dataset identification
datasetId: z.string(),
// Run identification
runId: z.string(),
// Dataset item identification
datasetItemId: z.string(),
});
// Event schemas
const base = z.object({
id: idSchema,
@@ -546,6 +569,17 @@ const createAllIngestionSchemas = (
body: ScoreBody,
});
const baseDatasetRunItemCreateEvent = base.extend({
type: z.literal(eventTypes.DATASET_RUN_ITEM_CREATE),
body: DatasetRunItemBody,
});
const datasetRunItemCreateEvent = isPublic
? baseDatasetRunItemCreateEvent.refine(() => false, {
message: "Dataset run item creation is only allowed for internal usage",
})
: baseDatasetRunItemCreateEvent;
const sdkLogEvent = base.extend({
type: z.literal(eventTypes.SDK_LOG),
body: SdkLogEvent,
@@ -570,6 +604,7 @@ const createAllIngestionSchemas = (
generationCreateEvent,
generationUpdateEvent,
sdkLogEvent,
datasetRunItemCreateEvent,
// LEGACY, only required for backwards compatibility
legacyObservationCreateEvent,
legacyObservationUpdateEvent,
@@ -595,6 +630,7 @@ const createAllIngestionSchemas = (
generationCreateEvent,
generationUpdateEvent,
scoreEvent,
datasetRunItemCreateEvent,
sdkLogEvent,
legacyObservationCreateEvent,
legacyObservationUpdateEvent,
@@ -604,8 +640,8 @@ const createAllIngestionSchemas = (
};
// Create both public and internal schema instances
const publicSchemas = createAllIngestionSchemas(PublicEnvironmentName);
const internalSchemas = createAllIngestionSchemas(InternalEnvironmentName);
const publicSchemas = createAllIngestionSchemas({ isPublic: true });
const internalSchemas = createAllIngestionSchemas({ isPublic: false });
// Export individual schemas for backwards compatibility
export const TraceBody = publicSchemas.TraceBody;
@@ -632,6 +668,8 @@ export const generationCreateEvent = publicSchemas.generationCreateEvent;
export const generationUpdateEvent = publicSchemas.generationUpdateEvent;
export const scoreEvent = publicSchemas.scoreEvent;
export const sdkLogEvent = publicSchemas.sdkLogEvent;
export const datasetRunItemCreateEvent =
publicSchemas.datasetRunItemCreateEvent;
export const legacyObservationCreateEvent =
publicSchemas.legacyObservationCreateEvent;
export const legacyObservationUpdateEvent =
@@ -649,6 +687,7 @@ export const ingestionEvent = publicSchemas.ingestionEvent;
export type IngestionEventType = z.infer<typeof ingestionEvent>;
export type TraceEventType = z.infer<typeof traceEvent>;
export type ScoreEventType = z.infer<typeof scoreEvent>;
export type DatasetRunItemEventType = z.infer<typeof datasetRunItemCreateEvent>;
/**
* Creates an ingestion event schema with appropriate environment validation.
-5
View File
@@ -233,11 +233,6 @@ export enum LLMAdapter {
GoogleAIStudio = "google-ai-studio",
}
export const SYSTEM_ROLES: string[] = [
ChatMessageRole.System,
ChatMessageRole.Developer,
];
export const TextPromptContentSchema = z.string().min(1, "Enter a prompt");
export const PromptContentSchema = z.union([
@@ -6,6 +6,7 @@ export const clickhouseSearchCondition = (
query?: string,
searchType?: TracingSearchType[],
tablePrefix?: string,
useTracesAmtCompatMode: boolean = false,
) => {
const prefix = tablePrefix ? `${tablePrefix}.` : "";
@@ -13,9 +14,11 @@ export const clickhouseSearchCondition = (
!searchType || searchType.includes("id")
? `${prefix}id ILIKE {searchString: String} OR user_id ILIKE {searchString: String} OR ${prefix}name ILIKE {searchString: String}`
: null,
searchType && searchType.includes("content")
searchType && searchType.includes("content") && !useTracesAmtCompatMode
? `${prefix}input ILIKE {searchString: String} OR ${prefix}output ILIKE {searchString: String}`
: null,
: searchType && searchType.includes("content") && useTracesAmtCompatMode
? `finalizeAggregation(${prefix}input) ILIKE {searchString: String} OR finalizeAggregation(${prefix}output) ILIKE {searchString: String}`
: null,
].filter(Boolean);
return {
@@ -6,6 +6,7 @@ import { logger } from "../logger";
const defaultRedisOptions: Partial<RedisOptions> = {
maxRetriesPerRequest: null,
enableAutoPipelining: env.REDIS_ENABLE_AUTO_PIPELINING === "true",
keyPrefix: env.REDIS_KEY_PREFIX ?? undefined,
};
export const redisQueueRetryOptions: Partial<RedisOptions> = {
@@ -15,6 +15,7 @@ import {
SafeWebhookActionConfig,
} from "../../domain/automations";
import { FilterState } from "../../types";
import { decryptSecretHeaders, mergeHeaders } from "../utils/headerUtils";
export const getActionByIdWithSecrets = async ({
projectId,
@@ -35,12 +36,25 @@ export const getActionByIdWithSecrets = async ({
}
const config = actionConfig.config as WebhookActionConfigWithSecrets;
// Decrypt secret headers for webhook execution using new structure
const decryptedHeaders = config.requestHeaders
? decryptSecretHeaders(mergeHeaders(config.headers, config.requestHeaders))
: Object.entries(config.headers).reduce(
(acc, [key, value]) => {
acc[key] = { secret: false, value };
return acc;
},
{} as Record<string, { secret: boolean; value: string }>,
);
return {
...actionConfig,
config: {
type: config.type,
url: config.url,
headers: config.headers,
requestHeaders: decryptedHeaders,
displayHeaders: config.displayHeaders,
apiVersion: config.apiVersion,
displaySecretKey: config.displaySecretKey,
secretKey: config.secretKey,
@@ -116,12 +130,26 @@ const convertTriggerToDomain = (trigger: Trigger): TriggerDomain => {
const convertActionToDomain = (action: Action): ActionDomain => {
const config = action.config as WebhookActionConfigWithSecrets;
// Handle legacy headers - convert them to displayHeaders format if displayHeaders is undefined
let displayHeaders = config.displayHeaders;
if (!displayHeaders && config.headers) {
// Convert legacy headers to displayHeaders format
displayHeaders = Object.entries(config.headers).reduce(
(acc, [key, value]) => {
acc[key] = { secret: false, value };
return acc;
},
{} as Record<string, { secret: boolean; value: string }>,
);
}
return {
...action,
config: {
type: config.type,
url: config.url,
headers: config.headers,
displayHeaders,
apiVersion: config.apiVersion,
displaySecretKey: config.displaySecretKey,
} as SafeWebhookActionConfig,
@@ -0,0 +1,86 @@
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";
export const convertToDatasetRunMetrics = (row: any) => {
return {
id: row.dataset_run_id,
projectId: row.project_id,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
name: row.datasetRunName,
description: row.datasetRunDescription ?? "",
metadata: row.datasetRunMetadata,
countRunItems: row.count_run_items,
avgTotalCost: undefined,
avgLatency: undefined,
scores: undefined,
datasetId: row.dataset_id,
};
};
export const convertDatasetRunItemDomainToClickhouse = (
datasetRunItem: DatasetRunItemDomain,
): DatasetRunItemRecordReadType => {
return {
id: datasetRunItem.id,
project_id: datasetRunItem.projectId,
trace_id: datasetRunItem.traceId,
observation_id: datasetRunItem.observationId,
dataset_id: datasetRunItem.datasetId,
dataset_run_id: datasetRunItem.datasetRunId,
dataset_run_name: datasetRunItem.datasetRunName,
dataset_run_description: datasetRunItem.datasetRunDescription,
dataset_run_metadata: datasetRunItem.datasetRunMetadata as Record<
string,
string
>,
dataset_item_id: datasetRunItem.datasetItemId,
dataset_item_input: datasetRunItem.datasetItemInput as string,
dataset_item_expected_output:
datasetRunItem.datasetItemExpectedOutput as string,
dataset_item_metadata: datasetRunItem.datasetItemMetadata as Record<
string,
string
>,
created_at: convertDateToClickhouseDateTime(datasetRunItem.createdAt),
updated_at: convertDateToClickhouseDateTime(datasetRunItem.updatedAt),
event_ts: convertDateToClickhouseDateTime(new Date()),
is_deleted: 0,
dataset_run_created_at: convertDateToClickhouseDateTime(
datasetRunItem.datasetRunCreatedAt,
),
error: datasetRunItem.error,
};
};
export const convertDatasetRunItemClickhouseToDomain = (
row: DatasetRunItemRecordReadType,
): DatasetRunItemDomain => {
return {
id: row.id,
projectId: row.project_id,
traceId: row.trace_id,
observationId: row.observation_id ?? null,
datasetRunId: row.dataset_run_id,
datasetRunName: row.dataset_run_name,
datasetRunDescription: row.dataset_run_description ?? null,
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,
};
};
@@ -196,6 +196,45 @@ export const scoreRecordInsertSchema = scoreRecordBaseSchema.extend({
});
export type ScoreRecordInsertType = z.infer<typeof scoreRecordInsertSchema>;
const datasetRunItemRecordBaseSchema = z.object({
id: z.string(),
project_id: z.string(),
trace_id: z.string(),
observation_id: z.string().nullish(),
dataset_id: z.string(),
dataset_run_id: z.string(),
dataset_item_id: z.string(),
dataset_run_name: z.string(),
dataset_run_description: z.string().nullish(),
dataset_run_metadata: z.record(z.string(), z.string()),
dataset_item_input: z.string(),
dataset_item_expected_output: z.string(),
dataset_item_metadata: z.record(z.string(), z.string()),
is_deleted: z.number(),
error: z.string().nullish(),
});
const datasetRunItemRecordReadSchema = datasetRunItemRecordBaseSchema.extend({
dataset_run_created_at: clickhouseStringDateSchema,
created_at: clickhouseStringDateSchema,
updated_at: clickhouseStringDateSchema,
event_ts: clickhouseStringDateSchema,
});
export type DatasetRunItemRecordReadType = z.infer<
typeof datasetRunItemRecordReadSchema
>;
export const datasetRunItemRecordInsertSchema =
datasetRunItemRecordBaseSchema.extend({
created_at: z.number(),
updated_at: z.number(),
event_ts: z.number(),
dataset_run_created_at: z.number(),
});
export type DatasetRunItemRecordInsertType = z.infer<
typeof datasetRunItemRecordInsertSchema
>;
export const blobStorageFileLogRecordBaseSchema = z.object({
id: z.string(),
project_id: z.string(),
@@ -308,6 +347,44 @@ export const convertPostgresTraceToInsert = (
};
};
export const convertPostgresDatasetRunItemToInsert = (
datasetRunItem: Record<string, any>,
): DatasetRunItemRecordInsertType => {
return {
id: datasetRunItem.id,
project_id: datasetRunItem.project_id,
dataset_run_id: datasetRunItem.dataset_run_id,
dataset_item_id: datasetRunItem.dataset_item_id,
trace_id: datasetRunItem.trace_id,
observation_id: datasetRunItem.observation_id,
error: datasetRunItem.error,
created_at: datasetRunItem.created_at?.getTime(),
updated_at: datasetRunItem.updated_at?.getTime(),
// denormalized run data
dataset_run_created_at: datasetRunItem.dataset_run_created_at?.getTime(),
dataset_id: datasetRunItem.dataset_id,
dataset_run_name: datasetRunItem.dataset_run_name,
dataset_run_description: datasetRunItem.dataset_run_description,
dataset_run_metadata:
typeof datasetRunItem.dataset_run_metadata === "string"
? { dataset_run_metadata: datasetRunItem.dataset_run_metadata }
: Array.isArray(datasetRunItem.dataset_run_metadata)
? { dataset_run_metadata: datasetRunItem.dataset_run_metadata }
: datasetRunItem.dataset_run_metadata,
// denormalized item data
dataset_item_input: datasetRunItem.dataset_item_input,
dataset_item_expected_output: datasetRunItem.dataset_item_expected_output,
dataset_item_metadata:
typeof datasetRunItem.dataset_item_metadata === "string"
? { dataset_item_metadata: datasetRunItem.dataset_item_metadata }
: Array.isArray(datasetRunItem.dataset_item_metadata)
? { dataset_item_metadata: datasetRunItem.dataset_item_metadata }
: datasetRunItem.dataset_item_metadata,
event_ts: datasetRunItem.created_at?.getTime(),
is_deleted: 0,
};
};
/**
* Expects a single record from a
* `select o.*,
@@ -13,3 +13,4 @@ export * from "./scores-utils";
export * from "./blobStorageLog";
export * from "./environments";
export * from "./automation-repository";
export * from "./dataset-run-items-converters";
@@ -1335,6 +1335,8 @@ export const getScoresForPostHog = async function* (
s.timestamp as timestamp,
s.name as name,
s.value as value,
s.string_value as string_value,
s.data_type as data_type,
s.comment as comment,
s.environment as environment,
t.name as trace_name,
@@ -1342,6 +1344,7 @@ export const getScoresForPostHog = async function* (
t.user_id as trace_user_id,
t.release as trace_release,
t.tags as trace_tags,
s.metadata as metadata,
t.metadata['$posthog_session_id'] as posthog_session_id
FROM scores s FINAL
LEFT JOIN traces t FINAL ON s.trace_id = t.id AND s.project_id = t.project_id
@@ -1382,6 +1385,9 @@ export const getScoresForPostHog = async function* (
langfuse_score_name: record.name,
langfuse_score_value: record.value,
langfuse_score_comment: record.comment,
langfuse_score_metadata: record.metadata,
langfuse_score_string_value: record.string_value,
langfuse_score_data_type: record.data_type,
langfuse_trace_name: record.trace_name,
langfuse_id: record.id,
langfuse_session_id: record.trace_session_id,
+429 -204
View File
@@ -325,26 +325,71 @@ export const getTracesBySessionId = async (
sessionIds: string[],
timestamp?: Date,
) => {
const query = `
SELECT *
FROM traces
WHERE session_id IN ({sessionIds: Array(String)})
AND project_id = {projectId: String}
${timestamp ? `AND timestamp >= {timestamp: DateTime64(3)}` : ""}
ORDER BY event_ts DESC
LIMIT 1 by id, project_id;`;
const records = await queryClickhouse<TraceRecordReadType>({
query,
params: {
sessionIds,
projectId,
timestamp: timestamp ? convertDateToClickhouseDateTime(timestamp) : null,
const records = await measureAndReturn({
operationName: "getTracesBySessionId",
projectId,
input: {
params: {
sessionIds,
projectId,
timestamp: timestamp
? convertDateToClickhouseDateTime(timestamp)
: null,
},
tags: {
feature: "tracing",
type: "trace",
kind: "list",
projectId,
},
timestamp,
},
tags: {
feature: "tracing",
type: "trace",
kind: "list",
projectId,
existingExecution: (input) => {
const query = `
SELECT *
FROM traces
WHERE session_id IN ({sessionIds: Array(String)})
AND project_id = {projectId: String}
${timestamp ? `AND timestamp >= {timestamp: DateTime64(3)}` : ""}
ORDER BY event_ts DESC
LIMIT 1 by id, project_id;
`;
return queryClickhouse<TraceRecordReadType>({
query,
params: input.params,
tags: input.tags,
});
},
newExecution: (input) => {
const traceAmt = getTimeframesTracesAMT(input.timestamp);
const query = `
SELECT
id,
name as name,
user_id as user_id,
metadata as metadata,
release as release,
version as version,
project_id,
environment,
finalizeAggregation(public) as public,
finalizeAggregation(bookmarked) as bookmarked,
tags,
finalizeAggregation(input) as input,
finalizeAggregation(output) as output,
session_id as session_id,
start_time as timestamp,
created_at,
updated_at
FROM ${traceAmt} FINAL
WHERE session_id IN ({sessionIds: Array(String)})
AND project_id = {projectId: String}
`;
return queryClickhouse<TraceRecordReadType>({
query,
params: input.params,
tags: input.tags,
});
},
});
@@ -600,39 +645,76 @@ export const getTracesGroupedByName = async (
? new FilterList(chFilter).apply()
: undefined;
// We mainly use queries like this to retrieve filter options.
// Therefore, we can skip final as some inaccuracy in count is acceptable.
const query = `
select
name as name,
count(*) as count
from traces t FINAL
WHERE t.project_id = {projectId: String}
AND t.name IS NOT NULL
${timestampFilterRes?.query ? `AND ${timestampFilterRes.query}` : ""}
GROUP BY name
ORDER BY count(*) desc
LIMIT 1000;
`;
const rows = await queryClickhouse<{
name: string;
count: string;
}>({
query: query,
params: {
projectId: projectId,
...(timestampFilterRes ? timestampFilterRes.params : {}),
return measureAndReturn({
operationName: "getTracesGroupedByName",
projectId,
input: {
params: {
projectId,
...(timestampFilterRes ? timestampFilterRes.params : {}),
},
tags: {
feature: "tracing",
type: "trace",
kind: "analytic",
projectId,
},
},
tags: {
feature: "tracing",
type: "trace",
kind: "analytic",
projectId,
existingExecution: async (input) => {
// We mainly use queries like this to retrieve filter options.
// Therefore, we can skip final as some inaccuracy in count is acceptable.
const query = `
select
name as name,
count(*) as count
from traces t FINAL
WHERE t.project_id = {projectId: String}
AND t.name IS NOT NULL
${timestampFilterRes?.query ? `AND ${timestampFilterRes.query}` : ""}
GROUP BY name
ORDER BY count(*) desc
LIMIT 1000;
`;
return queryClickhouse<{
name: string;
count: string;
}>({
query,
params: input.params,
tags: input.tags,
});
},
newExecution: async (input) => {
// Extract the timestamp from filter for AMT table selection
const fromTimestamp = timestampFilter?.find(
(f) =>
f.column === "timestamp" &&
(f.operator === ">=" || f.operator === ">"),
)?.value as Date | undefined;
const traceAmt = getTimeframesTracesAMT(fromTimestamp);
const query = `
select
name as name,
count(*) as count
from ${traceAmt} t
WHERE t.project_id = {projectId: String}
AND t.name IS NOT NULL
GROUP BY name
ORDER BY count(*) desc
LIMIT 1000;
`;
return queryClickhouse<{
name: string;
count: string;
}>({
query,
params: input.params,
tags: input.tags,
});
},
});
return rows;
};
export const getTracesGroupedByUsers = async (
@@ -657,44 +739,84 @@ export const getTracesGroupedByUsers = async (
const tracesFilterRes = tracesFilter.apply();
const search = clickhouseSearchCondition(searchQuery, undefined, "t");
// We mainly use queries like this to retrieve filter options.
// Therefore, we can skip final as some inaccuracy in count is acceptable.
const query = `
select
user_id as user,
count(*) as count
from traces t
WHERE t.project_id = {projectId: String}
AND t.user_id IS NOT NULL
AND t.user_id != ''
${tracesFilterRes?.query ? `AND ${tracesFilterRes.query}` : ""}
${search.query}
GROUP BY user
ORDER BY count desc
${limit !== undefined && offset !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
`;
const rows = await queryClickhouse<{
user: string;
count: string;
}>({
query: query,
params: {
limit,
offset,
projectId,
...(tracesFilterRes ? tracesFilterRes.params : {}),
...(searchQuery ? search.params : {}),
return measureAndReturn({
operationName: "getTracesGroupedByUsers",
projectId,
input: {
params: {
limit,
offset,
projectId,
...(tracesFilterRes ? tracesFilterRes.params : {}),
...(searchQuery ? search.params : {}),
},
tags: {
feature: "tracing",
type: "trace",
kind: "analytic",
projectId,
},
},
tags: {
feature: "tracing",
type: "trace",
kind: "analytic",
projectId,
existingExecution: async (input) => {
// We mainly use queries like this to retrieve filter options.
// Therefore, we can skip final as some inaccuracy in count is acceptable.
const query = `
select
user_id as user,
count(*) as count
from traces t
WHERE t.project_id = {projectId: String}
AND t.user_id IS NOT NULL
AND t.user_id != ''
${tracesFilterRes?.query ? `AND ${tracesFilterRes.query}` : ""}
${search.query}
GROUP BY user
ORDER BY count desc
${limit !== undefined && offset !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
`;
return queryClickhouse<{
user: string;
count: string;
}>({
query,
params: input.params,
tags: input.tags,
});
},
newExecution: async (input) => {
// Extract the timestamp from filter for AMT table selection
const fromTimestamp = filter?.find(
(f) =>
f.column === "timestamp" &&
(f.operator === ">=" || f.operator === ">"),
)?.value as Date | undefined;
const traceAmt = getTimeframesTracesAMT(fromTimestamp);
const query = `
select
user_id as user,
count(*) as count
from ${traceAmt} t
WHERE t.project_id = {projectId: String}
AND t.user_id IS NOT NULL
AND t.user_id != ''
${tracesFilterRes?.query ? `AND ${tracesFilterRes.query}` : ""}
${search.query}
GROUP BY user
ORDER BY count desc
${limit !== undefined && offset !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
`;
return queryClickhouse<{
user: string;
count: string;
}>({
query,
params: input.params,
tags: input.tags,
});
},
});
return rows;
};
export type GroupedTracesQueryProp = {
@@ -713,69 +835,138 @@ export const getTracesGroupedByTags = async (props: GroupedTracesQueryProp) => {
const filterRes = new FilterList(chFilter).apply();
const query = `
select distinct(arrayJoin(tags)) as value
from traces t
WHERE t.project_id = {projectId: String}
${filterRes?.query ? `AND ${filterRes.query}` : ""}
LIMIT 1000;
`;
const rows = await queryClickhouse<{
value: string;
}>({
query: query,
params: {
projectId: projectId,
...(filterRes ? filterRes.params : {}),
return measureAndReturn({
operationName: "getTracesGroupedByTags",
projectId,
input: {
params: {
projectId,
...(filterRes ? filterRes.params : {}),
},
tags: {
feature: "tracing",
type: "trace",
kind: "analytic",
projectId,
},
},
tags: {
feature: "tracing",
type: "trace",
kind: "analytic",
projectId,
existingExecution: async (input) => {
const query = `
select distinct(arrayJoin(tags)) as value
from traces t
WHERE t.project_id = {projectId: String}
${filterRes?.query ? `AND ${filterRes.query}` : ""}
LIMIT 1000;
`;
return queryClickhouse<{
value: string;
}>({
query,
params: input.params,
tags: input.tags,
});
},
newExecution: async (input) => {
// Extract the timestamp from filter for AMT table selection
const fromTimestamp = filter?.find(
(f) =>
f.column === "timestamp" &&
(f.operator === ">=" || f.operator === ">"),
)?.value as Date | undefined;
const traceAmt = getTimeframesTracesAMT(fromTimestamp);
const query = `
select distinct(arrayJoin(tags)) as value
from ${traceAmt} t
WHERE t.project_id = {projectId: String}
${filterRes?.query ? `AND ${filterRes.query}` : ""}
LIMIT 1000;
`;
return queryClickhouse<{
value: string;
}>({
query,
params: input.params,
tags: input.tags,
});
},
});
return rows;
};
export const getTracesIdentifierForSession = async (
projectId: string,
sessionId: string,
) => {
const query = `
SELECT
id,
user_id,
name,
timestamp,
project_id,
environment
FROM traces
WHERE (project_id = {projectId: String})
AND (session_id = {sessionId: String})
ORDER BY timestamp ASC
LIMIT 1 BY id, project_id;
`;
const rows = await queryClickhouse<{
id: string;
user_id: string;
name: string;
timestamp: string;
environment: string;
}>({
query: query,
params: {
projectId,
sessionId,
const rows = await measureAndReturn({
operationName: "getTracesIdentifierForSession",
projectId,
input: {
params: {
projectId,
sessionId,
},
tags: {
feature: "tracing",
type: "trace",
kind: "list",
projectId,
},
},
tags: {
feature: "tracing",
type: "trace",
kind: "list",
projectId,
existingExecution: (input) => {
const query = `
SELECT
id,
user_id,
name,
timestamp,
project_id,
environment
FROM traces
WHERE (project_id = {projectId: String})
AND (session_id = {sessionId: String})
ORDER BY timestamp ASC
LIMIT 1 BY id, project_id;
`;
return queryClickhouse<{
id: string;
user_id: string;
name: string;
timestamp: string;
environment: string;
}>({
query,
params: input.params,
tags: input.tags,
});
},
newExecution: (input) => {
const query = `
SELECT
id,
user_id,
name,
start_time as timestamp,
project_id,
environment
FROM traces_all_amt FINAL
WHERE (project_id = {projectId: String})
AND (session_id = {sessionId: String})
ORDER BY start_time ASC;
`;
return queryClickhouse<{
id: string;
user_id: string;
name: string;
timestamp: string;
environment: string;
}>({
query,
params: input.params,
tags: input.tags,
});
},
});
@@ -862,29 +1053,59 @@ export const deleteTracesByProjectId = async (projectId: string) => {
};
export const hasAnyUser = async (projectId: string) => {
const query = `
SELECT 1
FROM traces
WHERE project_id = {projectId: String}
AND user_id IS NOT NULL
AND user_id != ''
LIMIT 1
`;
const rows = await queryClickhouse<{ 1: number }>({
query,
params: {
return measureAndReturn({
operationName: "hasAnyUser",
projectId,
input: {
projectId,
tags: {
feature: "tracing",
type: "user",
kind: "hasAny",
projectId,
},
},
tags: {
feature: "tracing",
type: "user",
kind: "hasAny",
projectId,
existingExecution: async (input) => {
const query = `
SELECT 1
FROM traces
WHERE project_id = {projectId: String}
AND user_id IS NOT NULL
AND user_id != ''
LIMIT 1
`;
const rows = await queryClickhouse<{ 1: number }>({
query,
params: {
projectId: input.projectId,
},
tags: input.tags,
});
return rows.length > 0;
},
newExecution: async (input) => {
const query = `
SELECT 1
FROM traces_all_amt
WHERE project_id = {projectId: String}
AND user_id IS NOT NULL
AND user_id != ''
LIMIT 1
`;
const rows = await queryClickhouse<{ 1: number }>({
query,
params: {
projectId: input.projectId,
},
tags: input.tags,
});
return rows.length > 0;
},
});
return rows.length > 0;
};
export const getTotalUserCount = async (
@@ -903,26 +1124,59 @@ export const getTotalUserCount = async (
const tracesFilterRes = tracesFilter.apply();
const search = clickhouseSearchCondition(searchQuery, undefined, "t");
const query = `
SELECT COUNT(DISTINCT t.user_id) AS totalCount
FROM traces t
WHERE ${tracesFilterRes.query}
${search.query}
AND t.user_id IS NOT NULL
AND t.user_id != ''
`;
return queryClickhouse({
query,
params: {
...tracesFilterRes.params,
...search.params,
return measureAndReturn({
operationName: "getTotalUserCount",
projectId,
input: {
params: {
...tracesFilterRes.params,
...search.params,
},
tags: {
feature: "tracing",
type: "trace",
kind: "analytic",
projectId,
},
},
tags: {
feature: "tracing",
type: "trace",
kind: "analytic",
projectId,
existingExecution: async (input) => {
const query = `
SELECT COUNT(DISTINCT t.user_id) AS totalCount
FROM traces t
WHERE ${tracesFilterRes.query}
${search.query}
AND t.user_id IS NOT NULL
AND t.user_id != ''
`;
return queryClickhouse({
query,
params: input.params,
tags: input.tags,
});
},
newExecution: async (input) => {
// Extract the timestamp from filter for AMT table selection
const fromTimestamp = filter?.find(
(f) =>
f.column === "timestamp" &&
(f.operator === ">=" || f.operator === ">"),
)?.value as Date | undefined;
const traceAmt = getTimeframesTracesAMT(fromTimestamp);
const query = `
SELECT COUNT(DISTINCT t.user_id) AS totalCount
FROM ${traceAmt} t
WHERE ${tracesFilterRes.query}
${search.query}
AND t.user_id IS NOT NULL
AND t.user_id != ''
`;
return queryClickhouse({
query,
params: input.params,
tags: input.tags,
});
},
});
};
@@ -1241,35 +1495,6 @@ export const getTracesByIdsForAnyProject = async (traceIds: string[]) => {
}));
};
export const traceWithSessionIdExists = async (
projectId: string,
sessionId: string,
) => {
const query = `
SELECT id, project_id
FROM traces
WHERE session_id = {sessionId: String}
AND project_id = {projectId: String}
LIMIT 1
`;
const result = await queryClickhouse<{ id: string; project_id: string }>({
query,
params: {
sessionId,
projectId,
},
tags: {
feature: "tracing",
type: "trace",
kind: "exists",
projectId,
},
});
return result.length > 0;
};
export async function getAgentGraphData(params: {
projectId: string;
traceId: string;
@@ -9,7 +9,7 @@ type FetchDatasetItemsTableProps = {
filter: FilterState;
};
const getDatasetRunItemsTableGeneric = async <T>(
const getDatasetRunItemsTableGenericPg = async <T>(
props: FetchDatasetItemsTableProps,
) => {
const { select, projectId, filter } = props;
@@ -45,11 +45,11 @@ const getDatasetRunItemsTableGeneric = async <T>(
return res;
};
export const getDatasetRunItemsTableCount = async (props: {
export const getDatasetRunItemsTableCountPg = async (props: {
projectId: string;
filter: FilterState;
}) => {
const res = await getDatasetRunItemsTableGeneric<Array<{ count: bigint }>>({
const res = await getDatasetRunItemsTableGenericPg<Array<{ count: bigint }>>({
select: "count",
projectId: props.projectId,
filter: props.filter,
@@ -288,7 +288,7 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
sum(o.obs_count) as total_observations,
-- Use minIf, because ClickHouse fills 1970-01-01 on left joins. We assume that no
-- LLM session started on that date so this behaviour should yield better results.
date_diff('millisecond', minIf(min_start_time, min_start_time > '1970-01-01'), max(max_end_time)) as duration,
date_diff('second', minIf(min_start_time, min_start_time > '1970-01-01'), max(max_end_time)) as duration,
sumMap(o.sum_usage_details) as session_usage_details,
sumMap(o.sum_cost_details) as session_cost_details,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sumMap(o.sum_cost_details)))) as session_input_cost,
@@ -21,7 +21,9 @@ import {
parseClickhouseUTCDateTimeFormat,
queryClickhouse,
reduceUsageOrCostDetails,
getTimeframesTracesAMT,
} from "../repositories";
import { measureAndReturn } from "../clickhouse/measureAndReturn";
import { TracingSearchType } from "../../interfaces/search";
import { ObservationLevelType, TraceDomain } from "../../domain";
import { ClickHouseClientConfigOptions } from "@clickhouse/client";
@@ -217,54 +219,6 @@ async function getTracesTableGeneric(props: FetchTracesTableProps) {
clickhouseConfigs,
} = props;
let sqlSelect: string;
switch (select) {
case "count":
sqlSelect = "count(*) as count";
break;
case "metrics":
sqlSelect = `
t.id as id,
t.project_id as project_id,
t.timestamp as timestamp,
os.latency_milliseconds / 1000 as latency,
os.cost_details as cost_details,
os.usage_details as usage_details,
os.aggregated_level as level,
os.error_count as error_count,
os.warning_count as warning_count,
os.default_count as default_count,
os.debug_count as debug_count,
os.observation_count as observation_count,
s.scores_avg as scores_avg,
s.score_categories as score_categories,
t.public as public`;
break;
case "rows":
sqlSelect = `
t.id as id,
t.project_id as project_id,
t.timestamp as timestamp,
t.tags as tags,
t.bookmarked as bookmarked,
t.name as name,
t.release as release,
t.version as version,
t.user_id as user_id,
t.environment as environment,
t.session_id as session_id,
t.public as public`;
break;
case "identifiers":
sqlSelect = `
t.id as id,
t.project_id as projectId,
t.timestamp as timestamp`;
break;
default:
throw new Error(`Unknown select type: ${select}`);
}
const { tracesFilter, scoresFilter, observationsFilter } =
getProjectIdDefaultFilter(projectId, { tracesPrefix: "t" });
@@ -329,149 +283,318 @@ async function getTracesTableGeneric(props: FetchTracesTableProps) {
const scoresFilterRes = scoresFilter.apply();
const observationFilterRes = observationsFilter.apply();
const search = clickhouseSearchCondition(searchQuery, searchType, "t");
const defaultOrder = orderBy?.order && orderBy?.column === "timestamp";
const orderByCols = [
...tracesTableUiColumnDefinitions,
{
clickhouseSelect: "toDate(t.timestamp)",
uiTableName: "timestamp_to_date",
uiTableId: "timestamp_to_date",
clickhouseTableName: "traces",
},
{
clickhouseSelect: "t.event_ts",
uiTableName: "event_ts",
uiTableId: "event_ts",
clickhouseTableName: "traces",
},
];
const chOrderBy = orderByToClickhouseSql(
[
defaultOrder
? [
{
column: "timestamp_to_date",
order: orderBy.order,
},
{ column: "timestamp", order: orderBy.order },
{ column: "event_ts", order: "DESC" as "DESC" },
]
: null,
orderBy ?? null,
].flat(),
orderByCols,
);
// complex query ahead:
// - we only join scores and observations if we really need them to speed up default views
// - we use FINAL on traces only in case we not need to order by something different than time. Otherwise we cannot guarantee correct reads.
// - we filter the observations and scores as much as possible before joining them to traces.
// - we order by todate(timestamp), event_ts desc per default and do not use FINAL.
// In this case, CH is able to read the data only from the latest date from disk and filtering them in memory. No need to read all data e.g. for 1 month from disk.
const query = `
const observationsAndScoresCTE = `
WITH observations_stats AS (
SELECT
COUNT(*) AS observation_count,
sumMap(usage_details) as usage_details,
SUM(total_cost) AS total_cost,
date_diff('millisecond', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds,
countIf(level = 'ERROR') as error_count,
countIf(level = 'WARNING') as warning_count,
countIf(level = 'DEFAULT') as default_count,
countIf(level = 'DEBUG') as debug_count,
multiIf(
arrayExists(x -> x = 'ERROR', groupArray(level)), 'ERROR',
arrayExists(x -> x = 'WARNING', groupArray(level)), 'WARNING',
arrayExists(x -> x = 'DEFAULT', groupArray(level)), 'DEFAULT',
'DEBUG'
) AS aggregated_level,
sumMap(cost_details) as cost_details,
trace_id,
project_id
FROM observations o FINAL
sumMap(usage_details) as usage_details,
SUM(total_cost) AS total_cost,
date_diff('millisecond', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds,
countIf(level = 'ERROR') as error_count,
countIf(level = 'WARNING') as warning_count,
countIf(level = 'DEFAULT') as default_count,
countIf(level = 'DEBUG') as debug_count,
multiIf(
arrayExists(x -> x = 'ERROR', groupArray(level)), 'ERROR',
arrayExists(x -> x = 'WARNING', groupArray(level)), 'WARNING',
arrayExists(x -> x = 'DEFAULT', groupArray(level)), 'DEFAULT',
'DEBUG'
) AS aggregated_level,
sumMap(cost_details) as cost_details,
trace_id,
project_id
FROM observations o FINAL
WHERE o.project_id = {projectId: String}
${timeStampFilter ? `AND o.start_time >= {traceTimestamp: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}` : ""}
${observationsFilter ? `AND ${observationFilterRes.query}` : ""}
${timeStampFilter ? `AND o.start_time >= {traceTimestamp: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}` : ""}
${observationsFilter ? `AND ${observationFilterRes.query}` : ""}
GROUP BY trace_id, project_id
),
scores_avg AS (
SELECT
project_id,
trace_id,
-- For numeric scores, use tuples of (name, avg_value)
groupArrayIf(
tuple(name, avg_value),
data_type IN ('NUMERIC', 'BOOLEAN')
) AS scores_avg,
-- For categorical scores, use name:value format for improved query performance
groupArrayIf(
concat(name, ':', string_value),
data_type = 'CATEGORICAL' AND notEmpty(string_value)
) AS score_categories
FROM (
SELECT
project_id,
trace_id,
name,
data_type,
string_value,
avg(value) as avg_value
FROM scores s FINAL
WHERE
project_id = {projectId: String}
${timeStampFilter ? `AND s.timestamp >= {traceTimestamp: DateTime64(3)} - ${SCORE_TO_TRACE_OBSERVATIONS_INTERVAL}` : ""}
${scoresFilterRes ? `AND ${scoresFilterRes.query}` : ""}
GROUP BY
project_id,
trace_id,
name,
data_type,
string_value
) tmp
GROUP BY project_id, trace_id
)
SELECT ${sqlSelect}
-- FINAL is used for non default ordering and count.
FROM traces t ${["metrics", "rows", "identifiers"].includes(select) && defaultOrder ? "" : "FINAL"}
${select === "metrics" || requiresObservationsJoin ? `LEFT JOIN observations_stats os on os.project_id = t.project_id and os.trace_id = t.id` : ""}
${select === "metrics" || requiresScoresJoin ? `LEFT JOIN scores_avg s on s.project_id = t.project_id and s.trace_id = t.id` : ""}
WHERE t.project_id = {projectId: String}
${tracesFilterRes ? `AND ${tracesFilterRes.query}` : ""}
${search.query}
${chOrderBy}
-- This is used for metrics and row queries. Count has only one result.
-- This is only used for default ordering. Otherwise, we use final.
${["metrics", "rows", "identifiers"].includes(select) && defaultOrder ? "LIMIT 1 BY id, project_id" : ""}
${limit !== undefined && page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
scores_avg AS (
SELECT
project_id,
trace_id,
-- For numeric scores, use tuples of (name, avg_value)
groupArrayIf(
tuple(name, avg_value),
data_type IN ('NUMERIC', 'BOOLEAN')
) AS scores_avg,
-- For categorical scores, use name:value format for improved query performance
groupArrayIf(
concat(name, ':', string_value),
data_type = 'CATEGORICAL' AND notEmpty(string_value)
) AS score_categories
FROM (
SELECT
project_id,
trace_id,
name,
data_type,
string_value,
avg(value) as avg_value
FROM scores s FINAL
WHERE
project_id = {projectId: String}
${timeStampFilter ? `AND s.timestamp >= {traceTimestamp: DateTime64(3)} - ${SCORE_TO_TRACE_OBSERVATIONS_INTERVAL}` : ""}
${scoresFilterRes ? `AND ${scoresFilterRes.query}` : ""}
GROUP BY
project_id,
trace_id,
name,
data_type,
string_value
) tmp
GROUP BY project_id, trace_id
)
`;
const res = await queryClickhouse<
SelectReturnTypeMap[keyof SelectReturnTypeMap]
>({
query: query,
params: {
limit: limit,
offset: limit && page ? limit * page : 0,
traceTimestamp: timeStampFilter?.value.getTime(),
projectId: projectId,
...tracesFilterRes.params,
...observationFilterRes.params,
...scoresFilterRes.params,
...search.params,
},
tags: {
...(props.tags ?? {}),
feature: "tracing",
type: "traces-table",
projectId,
},
clickhouseConfigs,
});
return measureAndReturn({
operationName: "getTracesTableGeneric",
projectId: props.projectId,
input: props,
existingExecution: async (props) => {
let sqlSelect: string;
switch (select) {
case "count":
sqlSelect = "count(*) as count";
break;
case "metrics":
sqlSelect = `
t.id as id,
t.project_id as project_id,
t.timestamp as timestamp,
os.latency_milliseconds / 1000 as latency,
os.cost_details as cost_details,
os.usage_details as usage_details,
os.aggregated_level as level,
os.error_count as error_count,
os.warning_count as warning_count,
os.default_count as default_count,
os.debug_count as debug_count,
os.observation_count as observation_count,
s.scores_avg as scores_avg,
s.score_categories as score_categories,
t.public as public`;
break;
case "rows":
sqlSelect = `
t.id as id,
t.project_id as project_id,
t.timestamp as timestamp,
t.tags as tags,
t.bookmarked as bookmarked,
t.name as name,
t.release as release,
t.version as version,
t.user_id as user_id,
t.environment as environment,
t.session_id as session_id,
t.public as public`;
break;
case "identifiers":
sqlSelect = `
t.id as id,
t.project_id as projectId,
t.timestamp as timestamp`;
break;
default:
throw new Error(`Unknown select type: ${select}`);
}
return res;
const search = clickhouseSearchCondition(searchQuery, searchType, "t");
const defaultOrder = orderBy?.order && orderBy?.column === "timestamp";
const orderByCols = [
...tracesTableUiColumnDefinitions,
{
clickhouseSelect: "toDate(t.timestamp)",
uiTableName: "timestamp_to_date",
uiTableId: "timestamp_to_date",
clickhouseTableName: "traces",
},
{
clickhouseSelect: "t.event_ts",
uiTableName: "event_ts",
uiTableId: "event_ts",
clickhouseTableName: "traces",
},
];
const chOrderBy = orderByToClickhouseSql(
[
defaultOrder
? [
{
column: "timestamp_to_date",
order: orderBy.order,
},
{ column: "timestamp", order: orderBy.order },
{ column: "event_ts", order: "DESC" as "DESC" },
]
: null,
orderBy ?? null,
].flat(),
orderByCols,
);
// complex query ahead:
// - we only join scores and observations if we really need them to speed up default views
// - we use FINAL on traces only in case we not need to order by something different than time. Otherwise we cannot guarantee correct reads.
// - we filter the observations and scores as much as possible before joining them to traces.
// - we order by todate(timestamp), event_ts desc per default and do not use FINAL.
// In this case, CH is able to read the data only from the latest date from disk and filtering them in memory. No need to read all data e.g. for 1 month from disk.
const query = `
${observationsAndScoresCTE}
SELECT ${sqlSelect}
-- FINAL is used for non default ordering and count.
FROM traces t ${["metrics", "rows", "identifiers"].includes(select) && defaultOrder ? "" : "FINAL"}
${select === "metrics" || requiresObservationsJoin ? `LEFT JOIN observations_stats os on os.project_id = t.project_id and os.trace_id = t.id` : ""}
${select === "metrics" || requiresScoresJoin ? `LEFT JOIN scores_avg s on s.project_id = t.project_id and s.trace_id = t.id` : ""}
WHERE t.project_id = {projectId: String}
${tracesFilterRes ? `AND ${tracesFilterRes.query}` : ""}
${search.query}
${chOrderBy}
-- This is used for metrics and row queries. Count has only one result.
-- This is only used for default ordering. Otherwise, we use final.
${["metrics", "rows", "identifiers"].includes(select) && defaultOrder ? "LIMIT 1 BY id, project_id" : ""}
${limit !== undefined && page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
`;
const res = await queryClickhouse<
SelectReturnTypeMap[keyof SelectReturnTypeMap]
>({
query: query,
params: {
limit: limit,
offset: limit && page ? limit * page : 0,
traceTimestamp: timeStampFilter?.value.getTime(),
projectId: projectId,
...tracesFilterRes.params,
...observationFilterRes.params,
...scoresFilterRes.params,
...search.params,
},
tags: {
...(props.tags ?? {}),
feature: "tracing",
type: "traces-table",
projectId,
},
clickhouseConfigs,
});
return res;
},
newExecution: async () => {
let sqlSelect: string;
switch (select) {
case "count":
sqlSelect = "count(*) as count";
break;
case "metrics":
sqlSelect = `
t.id as id,
t.project_id as project_id,
t.timestamp as timestamp,
os.latency_milliseconds / 1000 as latency,
os.cost_details as cost_details,
os.usage_details as usage_details,
os.aggregated_level as level,
os.error_count as error_count,
os.warning_count as warning_count,
os.default_count as default_count,
os.debug_count as debug_count,
os.observation_count as observation_count,
s.scores_avg as scores_avg,
s.score_categories as score_categories,
t.public as public`;
break;
case "rows":
sqlSelect = `
t.id as id,
t.project_id as project_id,
t.timestamp as timestamp,
t.tags as tags,
finalizeAggregation(t.bookmarked) as bookmarked,
t.name as name,
t.release as release,
t.version as version,
t.user_id as user_id,
t.environment as environment,
t.session_id as session_id,
finalizeAggregation(t.public) as public`;
break;
case "identifiers":
sqlSelect = `
t.id as id,
t.project_id as projectId,
t.timestamp as timestamp`;
break;
default:
throw new Error(`Unknown select type: ${select}`);
}
const search = clickhouseSearchCondition(
searchQuery,
searchType,
"t",
true,
);
const defaultOrder = orderBy?.order && orderBy?.column === "timestamp";
const chOrderBy = orderByToClickhouseSql(
[
defaultOrder ? [{ column: "timestamp", order: orderBy.order }] : null,
orderBy ?? null,
].flat(),
tracesTableUiColumnDefinitions,
);
const tracesAmt =
select === "metrics"
? "traces_all_amt"
: getTimeframesTracesAMT(timeStampFilter?.value);
const query = `
${observationsAndScoresCTE}
SELECT ${sqlSelect}
FROM ${tracesAmt} t FINAL
${select === "metrics" || requiresObservationsJoin ? `LEFT JOIN observations_stats os on os.project_id = t.project_id and os.trace_id = t.id` : ""}
${select === "metrics" || requiresScoresJoin ? `LEFT JOIN scores_avg s on s.project_id = t.project_id and s.trace_id = t.id` : ""}
WHERE t.project_id = {projectId: String}
${tracesFilterRes ? `AND ${tracesFilterRes.query}` : ""}
${search.query}
${chOrderBy}
${limit !== undefined && page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
`;
const res = await queryClickhouse<
SelectReturnTypeMap[keyof SelectReturnTypeMap]
>({
query: query,
params: {
limit: limit,
offset: limit && page ? limit * page : 0,
traceTimestamp: timeStampFilter?.value.getTime(),
projectId: projectId,
...tracesFilterRes.params,
...observationFilterRes.params,
...scoresFilterRes.params,
...search.params,
},
tags: {
...(props.tags ?? {}),
feature: "tracing",
type: "traces-table",
projectId,
},
clickhouseConfigs,
});
return res;
},
});
}
export const getTracesTableCount = async (props: {
@@ -3,6 +3,7 @@ import {
TraceRecordInsertType,
ObservationRecordInsertType,
ScoreRecordInsertType,
DatasetRunItemRecordInsertType,
convertTraceToTraceMt,
} from "../repositories/definitions";
import { env } from "../../env";
@@ -41,3 +42,13 @@ export const createScoresCh = async (scores: ScoreRecordInsertType[]) => {
values: scores,
});
};
export const createDatasetRunItemsCh = async (
datasetRunItems: DatasetRunItemRecordInsertType[],
) => {
return await clickhouseClient().insert({
table: "dataset_run_items",
format: "JSONEachRow",
values: datasetRunItems,
});
};
@@ -3,6 +3,7 @@ import {
TraceRecordInsertType,
ObservationRecordInsertType,
ScoreRecordInsertType,
DatasetRunItemRecordInsertType,
} from "../repositories/definitions";
export const createTrace = (
@@ -33,6 +34,32 @@ export const createTrace = (
};
};
export const createDatasetRunItem = (
datasetRunItem: Partial<DatasetRunItemRecordInsertType>,
): DatasetRunItemRecordInsertType => {
return {
id: v4(),
project_id: v4(),
trace_id: v4(),
observation_id: null,
dataset_run_id: v4(),
dataset_item_id: v4(),
dataset_id: v4(),
dataset_run_name: "test-run-name" + v4(),
dataset_run_metadata: { key: "value" },
dataset_item_input: "{}",
dataset_item_expected_output: "{}",
dataset_item_metadata: { key: "value" },
dataset_run_created_at: Date.now(),
created_at: Date.now(),
updated_at: Date.now(),
event_ts: Date.now(),
is_deleted: 0,
error: datasetRunItem.error ?? null,
...datasetRunItem,
};
};
export const createObservation = (
observation: Partial<ObservationRecordInsertType>,
): ObservationRecordInsertType => {
@@ -0,0 +1,90 @@
import { encrypt, decrypt } from "../../encryption";
export function mergeHeaders(
legacyHeaders: Record<string, string> = {},
requestHeaders: Record<string, { secret: boolean; value: string }> = {},
): Record<string, { secret: boolean; value: string }> {
const merged: Record<string, { secret: boolean; value: string }> = {};
// Add legacy headers as non-secret
for (const [key, value] of Object.entries(legacyHeaders)) {
merged[key] = { secret: false, value };
}
// requestHeaders takes precedence
for (const [key, headerObj] of Object.entries(requestHeaders)) {
merged[key] = headerObj;
}
return merged;
}
export function createDisplayHeaders(
mergedHeaders: Record<string, { secret: boolean; value: string }>,
): Record<string, { secret: boolean; value: string }> {
const displayHeaders: Record<string, { secret: boolean; value: string }> = {};
for (const [key, headerObj] of Object.entries(mergedHeaders)) {
displayHeaders[key] = {
secret: headerObj.secret,
value: headerObj.secret
? maskSecretValue(headerObj.value)
: headerObj.value,
};
}
return displayHeaders;
}
export function encryptSecretHeaders(
headers: Record<string, { secret: boolean; value: string }>,
): Record<string, { secret: boolean; value: string }> {
const processedRequestHeaders: Record<
string,
{ secret: boolean; value: string }
> = {};
for (const [key, headerObj] of Object.entries(headers)) {
if (headerObj.secret) {
processedRequestHeaders[key] = {
secret: true,
value: encrypt(headerObj.value),
};
} else {
processedRequestHeaders[key] = { secret: false, value: headerObj.value };
}
}
return processedRequestHeaders;
}
export function decryptSecretHeaders(
requestHeaders: Record<string, { secret: boolean; value: string }>,
): Record<string, { secret: boolean; value: string }> {
const decryptedRequestHeaders: Record<
string,
{ secret: boolean; value: string }
> = {};
for (const [key, headerObj] of Object.entries(requestHeaders)) {
if (headerObj.secret) {
try {
decryptedRequestHeaders[key] = {
secret: true,
value: decrypt(headerObj.value),
};
} catch (error) {
console.error(`Failed to decrypt header ${key}:`, error);
}
} else {
decryptedRequestHeaders[key] = { secret: false, value: headerObj.value };
}
}
return decryptedRequestHeaders;
}
function maskSecretValue(value: string): string {
if (value.length <= 8) return "****";
return `${value.substring(0, 4)}...${value.substring(value.length - 4)}`;
}
+1510 -1449
View File
File diff suppressed because it is too large Load Diff
+31 -38
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.83.0",
"version": "3.85.2",
"private": true,
"license": "MIT",
"engines": {
@@ -24,9 +24,9 @@
"@anthropic-ai/tokenizer": "^0.0.4",
"@appsignal/opentelemetry-instrumentation-bullmq": "^0.7.3",
"@baselime/trpc-opentelemetry-middleware": "^0.1.2",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/language": "^6.10.1",
"@codemirror/lint": "^6.4.2",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.11.2",
"@codemirror/lint": "^6.8.5",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
@@ -58,32 +58,30 @@
"@opentelemetry/sdk-trace-node": "^1.26.0",
"@paralleldrive/cuid2": "^2.2.2",
"@prisma/instrumentation": "^6.10.1",
"@radix-ui/react-accordion": "^1.2.1",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-collapsible": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-hover-card": "^1.1.2",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-radio-group": "^1.2.1",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.1",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.3",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@remixicon/react": "^4.2.0",
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@sentry/nextjs": "^8.52.0",
"@t3-oss/env-nextjs": "^0.11.1",
"@tailwindcss/container-queries": "^0.1.1",
@@ -96,7 +94,7 @@
"@trpc/react-query": "^10.45.0",
"@trpc/server": "^10.45.0",
"@uiw/codemirror-themes": "^4.23.7",
"@uiw/react-codemirror": "^4.21.24",
"@uiw/react-codemirror": "^4.24.1",
"ai": "^3.4.9",
"bcryptjs": "^2.4.3",
"bullmq": "^5.34.10",
@@ -164,6 +162,8 @@
"@mermaid-js/mermaid-cli": "^11.2.0",
"@next/bundle-analyzer": "^15.4.1",
"@playwright/test": "^1.47.2",
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@tailwindcss/forms": "^0.5.7",
"@testing-library/jest-dom": "^6.4.6",
"@testing-library/react": "^15.0.7",
@@ -174,7 +174,7 @@
"@types/eslint": "^8.56.7",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.10",
"@types/node": "20.10.5",
"@types/node": "20.11.29",
"@types/react": "~18.2.79",
"@types/react-dom": "~18.2.25",
"@types/react-grid-layout": "^1.3.5",
@@ -195,17 +195,10 @@
"tailwindcss": "^3.4.17",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.19.1",
"typescript": "^5.4.5",
"wait-for-expect": "^3.0.2"
},
"ct3aMetadata": {
"initVersion": "7.13.0"
},
"pnpm": {
"overrides": {
"nanoid": "^3.3.8",
"katex": "0.16.21"
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
import {
createOrgProjectAndApiKey,
getDatasetRunItemsTableCount,
getDatasetRunItemsTableCountPg,
} from "@langfuse/shared/src/server";
import { v4 as uuidv4 } from "uuid";
import { prisma } from "@langfuse/shared/src/db";
@@ -65,7 +65,7 @@ describe("trpc.datasets", () => {
});
describe("GET datasetItems.countAll", () => {
it("should GET all dataset run items with no filter", async () => {
const { totalCount } = await getDatasetRunItemsTableCount({
const { totalCount } = await getDatasetRunItemsTableCountPg({
projectId: projectId,
filter: [],
});
@@ -74,7 +74,7 @@ describe("trpc.datasets", () => {
});
it("should GET all dataset run items with filter", async () => {
const { totalCount } = await getDatasetRunItemsTableCount({
const { totalCount } = await getDatasetRunItemsTableCountPg({
projectId: projectId,
filter: generateFilter([datasetIds[0]]),
});
@@ -617,4 +617,400 @@ describe("prompts trpc", () => {
});
});
});
describe("prompts.all with search", () => {
it("should find prompts by searching in content, name, tags, and labels", async () => {
const { project, caller } = await prepare();
// Create test prompts with different searchable content
await prisma.prompt.create({
data: {
id: v4(),
projectId: project.id,
name: "customer-service-prompt",
version: 1,
type: "text",
prompt: {
text: "You are a helpful customer support agent. Answer questions about billing and account issues.",
},
createdBy: "test-user",
tags: ["support", "customer"],
labels: ["production"],
},
});
await prisma.prompt.create({
data: {
id: v4(),
projectId: project.id,
name: "marketing-prompt",
version: 1,
type: "text",
prompt: {
text: "Create engaging marketing content that drives sales and conversions.",
},
createdBy: "test-user",
tags: ["marketing", "content"],
labels: ["staging"],
},
});
await prisma.prompt.create({
data: {
id: v4(),
projectId: project.id,
name: "technical-docs",
version: 1,
type: "text",
prompt: {
text: "Generate comprehensive technical documentation for APIs and software systems.",
},
createdBy: "test-user",
tags: ["technical", "documentation"],
labels: ["latest"],
},
});
// Test 1: Search by prompt content
const contentSearchResults = await caller.prompts.all({
projectId: project.id,
page: 0,
limit: 10,
filter: [],
orderBy: { column: "createdAt", order: "DESC" },
searchQuery: "customer support agent",
searchType: ["content"],
});
expect(contentSearchResults.prompts).toHaveLength(1);
expect(contentSearchResults.prompts[0].name).toBe(
"customer-service-prompt",
);
// Test 2: Search by prompt name
const nameSearchResults = await caller.prompts.all({
projectId: project.id,
page: 0,
limit: 10,
filter: [],
orderBy: { column: "createdAt", order: "DESC" },
searchQuery: "marketing",
searchType: ["id"],
});
expect(nameSearchResults.prompts).toHaveLength(1);
expect(nameSearchResults.prompts[0].name).toBe("marketing-prompt");
// Test 3: Search by tags
const tagSearchResults = await caller.prompts.all({
projectId: project.id,
page: 0,
limit: 10,
filter: [],
orderBy: { column: "createdAt", order: "DESC" },
searchQuery: "documentation",
searchType: ["id"],
});
expect(tagSearchResults.prompts).toHaveLength(1);
expect(tagSearchResults.prompts[0].name).toBe("technical-docs");
// Test 4: Search with no matches
const noMatchResults = await caller.prompts.all({
projectId: project.id,
page: 0,
limit: 10,
filter: [],
orderBy: { column: "createdAt", order: "DESC" },
searchQuery: "nonexistent content",
searchType: ["id", "content"],
});
expect(noMatchResults.prompts).toHaveLength(0);
// Test 5: Case insensitive search
const caseInsensitiveResults = await caller.prompts.all({
projectId: project.id,
page: 0,
limit: 10,
filter: [],
orderBy: { column: "createdAt", order: "DESC" },
searchQuery: "TECHNICAL",
searchType: ["id"],
});
expect(caseInsensitiveResults.prompts).toHaveLength(1);
expect(caseInsensitiveResults.prompts[0].name).toBe("technical-docs");
});
it("should find prompts with multiple versions when searching content", async () => {
const { project, caller } = await prepare();
// Create multiple versions of the same prompt with different content
await prisma.prompt.create({
data: {
id: v4(),
projectId: project.id,
name: "evolving-prompt",
version: 1,
type: "text",
prompt: { text: "You are a basic chatbot. Answer simple questions." },
createdBy: "test-user",
tags: ["basic"],
labels: [],
},
});
await prisma.prompt.create({
data: {
id: v4(),
projectId: project.id,
name: "evolving-prompt",
version: 2,
type: "text",
prompt: {
text: "You are an advanced AI assistant with expertise in machine learning.",
},
createdBy: "test-user",
tags: ["advanced"],
labels: ["latest"],
},
});
// Search for content that exists in version 2 but not version 1
const searchResults = await caller.prompts.all({
projectId: project.id,
page: 0,
limit: 10,
filter: [],
orderBy: { column: "createdAt", order: "DESC" },
searchQuery: "machine learning",
searchType: ["content"],
});
// Should find the prompt because one of its versions contains the search term
expect(searchResults.prompts).toHaveLength(1);
expect(searchResults.prompts[0].name).toBe("evolving-prompt");
// The returned prompt should be the latest version
expect(searchResults.prompts[0].version).toBe(2);
});
});
describe("prompts.count with search and searchType", () => {
it("should count prompts correctly with different search types", async () => {
const { project, caller } = await prepare();
// Create test prompts with different searchable content
await prisma.prompt.create({
data: {
id: v4(),
projectId: project.id,
name: "customer-service-prompt",
version: 1,
type: "text",
prompt: {
text: "You are a helpful customer support agent. Answer questions about billing and account issues.",
},
createdBy: "test-user",
tags: ["support", "customer"],
labels: ["production"],
},
});
await prisma.prompt.create({
data: {
id: v4(),
projectId: project.id,
name: "marketing-prompt",
version: 1,
type: "text",
prompt: {
text: "Create engaging marketing content that drives sales and conversions.",
},
createdBy: "test-user",
tags: ["marketing", "content"],
labels: ["staging"],
},
});
await prisma.prompt.create({
data: {
id: v4(),
projectId: project.id,
name: "technical-docs",
version: 1,
type: "text",
prompt: {
text: "Generate comprehensive technical documentation for APIs and software systems.",
},
createdBy: "test-user",
tags: ["technical", "documentation"],
labels: ["latest"],
},
});
await prisma.prompt.create({
data: {
id: v4(),
projectId: project.id,
name: "sales-support-prompt",
version: 1,
type: "text",
prompt: {
text: "Assist sales teams with lead qualification and customer outreach.",
},
createdBy: "test-user",
tags: ["sales", "support"],
labels: ["production"],
},
});
// Test 1: Count all prompts (no search)
const totalCountResult = await caller.prompts.count({
projectId: project.id,
});
expect(totalCountResult.totalCount).toBe(BigInt(4));
// Test 2: Count with ID search type (default) - search in names and tags
const idSearchCountResult = await caller.prompts.count({
projectId: project.id,
searchQuery: "support",
searchType: ["id"],
});
// Should find "customer-service-prompt" and "sales-support-prompt" (by name and tags)
expect(idSearchCountResult.totalCount).toBe(BigInt(2));
// Test 3: Count with content search type - search in prompt content
const contentSearchCountResult = await caller.prompts.count({
projectId: project.id,
searchQuery: "customer",
searchType: ["content"],
});
// Should find "customer-service-prompt" and "sales-support-prompt" (by content: "customer" appears in both)
expect(contentSearchCountResult.totalCount).toBe(BigInt(2));
// Test 4: Count with both ID and content search types
const combinedSearchCountResult = await caller.prompts.count({
projectId: project.id,
searchQuery: "marketing",
searchType: ["id", "content"],
});
// Should find "marketing-prompt" (by both name and content)
expect(combinedSearchCountResult.totalCount).toBe(BigInt(1));
// Test 5: Count with tag-specific search (ID search type)
const tagSearchCountResult = await caller.prompts.count({
projectId: project.id,
searchQuery: "documentation",
searchType: ["id"],
});
// Should find "technical-docs" (by tag)
expect(tagSearchCountResult.totalCount).toBe(BigInt(1));
// Test 6: Count with content that doesn't exist in names/tags but exists in prompt text
const contentOnlySearchResult = await caller.prompts.count({
projectId: project.id,
searchQuery: "billing",
searchType: ["content"],
});
// Should find "customer-service-prompt" (by content only)
expect(contentOnlySearchResult.totalCount).toBe(BigInt(1));
// Test 7: Count with no matches
const noMatchCountResult = await caller.prompts.count({
projectId: project.id,
searchQuery: "nonexistent",
searchType: ["id", "content"],
});
expect(noMatchCountResult.totalCount).toBe(BigInt(0));
// Test 8: Count with case insensitive search
const caseInsensitiveCountResult = await caller.prompts.count({
projectId: project.id,
searchQuery: "TECHNICAL",
searchType: ["id"],
});
// Should find "technical-docs" (case insensitive)
expect(caseInsensitiveCountResult.totalCount).toBe(BigInt(1));
// Test 9: Count with filters and search combined
const filteredSearchCountResult = await caller.prompts.count({
projectId: project.id,
searchQuery: "support",
searchType: ["id"],
filter: [
{
column: "labels",
type: "arrayOptions",
operator: "any of",
value: ["production"],
},
],
});
// Should find prompts with "support" AND "production" label
expect(filteredSearchCountResult.totalCount).toBe(BigInt(2));
});
it("should count prompts with folder path prefix and search", async () => {
const { project, caller } = await prepare();
// Create prompts in different folder structures
await prisma.prompt.create({
data: {
id: v4(),
projectId: project.id,
name: "folder1/customer-prompt",
version: 1,
type: "text",
prompt: { text: "Customer service prompt" },
createdBy: "test-user",
tags: ["customer"],
},
});
await prisma.prompt.create({
data: {
id: v4(),
projectId: project.id,
name: "folder1/support-prompt",
version: 1,
type: "text",
prompt: { text: "Technical support prompt" },
createdBy: "test-user",
tags: ["support"],
},
});
await prisma.prompt.create({
data: {
id: v4(),
projectId: project.id,
name: "folder2/customer-prompt",
version: 1,
type: "text",
prompt: { text: "Another customer prompt" },
createdBy: "test-user",
tags: ["customer"],
},
});
// Test: Count with path prefix and search
const folderSearchCountResult = await caller.prompts.count({
projectId: project.id,
pathPrefix: "folder1",
searchQuery: "customer",
searchType: ["id"],
});
// Should only find prompts in folder1 that match "customer"
expect(folderSearchCountResult.totalCount).toBe(BigInt(1));
// Test: Count with path prefix but no search
const folderCountResult = await caller.prompts.count({
projectId: project.id,
pathPrefix: "folder1",
});
// Should find all prompts in folder1
expect(folderCountResult.totalCount).toBe(BigInt(2));
});
});
});
@@ -52,7 +52,7 @@ describe("traces trpc", () => {
const ctx = createInnerTRPCContext({ session });
const caller = appRouter.createCaller({ ...ctx, prisma });
describe("sessions.byId", () => {
describe("sessions.byIdWithScores", () => {
it("access private session", async () => {
const sessionId = randomUUID();
@@ -92,7 +92,7 @@ describe("traces trpc", () => {
await createObservationsCh([observation, observation2, observation3]);
const sessionRes = await caller.sessions.byId({
const sessionRes = await caller.sessions.byIdWithScores({
projectId,
sessionId,
});
@@ -105,6 +105,7 @@ describe("traces trpc", () => {
environment: "default",
bookmarked: false,
public: false,
scores: [],
traces: expect.arrayContaining([
expect.objectContaining({
id: trace.id,
@@ -383,7 +383,6 @@ describe("trpc.sessions", () => {
expect(sessions[0]).toBeDefined();
expect(sessions[0]?.trace_count).toBe(2);
expect(parseInt(sessions[0]?.duration as any)).toBeGreaterThan(995);
expect(parseInt(sessions[0]?.duration as any)).toBeLessThan(1005);
expect(parseInt(sessions[0]?.duration as any)).toBe(1);
});
});
@@ -486,7 +486,7 @@ describe("OTel Resource Span Mapping", () => {
expect(traceEvent.body).toMatchObject({
id: "95f3b926c7d009925bcb5dbc27311120",
timestamp: "2025-05-05T13:42:33.936Z",
name: undefined,
name: "my-span-with-custom-trace-id",
environment: "production",
});
});
@@ -5,7 +5,6 @@ import {
type ChatMessage,
ChatMessageRole,
ChatMessageType,
SYSTEM_ROLES,
type ChatMessageWithId,
type LLMToolCall,
type PlaceholderMessage,
@@ -223,9 +222,6 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
[message.id, message.type, updateMessage],
);
const showDragHandle = !(
"role" in message && SYSTEM_ROLES.includes(message.role)
);
const showToolCallSelect = message.type === ChatMessageType.ToolResult;
const isPlaceholder = message.type === ChatMessageType.Placeholder;
@@ -242,20 +238,15 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
)}
>
<div className="flex flex-row justify-center">
{showDragHandle && (
<div
{...attributes}
{...listeners}
className="flex w-3 cursor-move items-center justify-center opacity-50 transition-opacity hover:opacity-100"
>
<GripVertical className="h-3 w-3" />
</div>
)}
<div
{...attributes}
{...listeners}
className="flex w-3 cursor-move items-center justify-center opacity-50 transition-opacity hover:opacity-100"
>
<GripVertical className="h-3 w-3" />
</div>
<CardContent
className={cn(
"flex flex-1 flex-row items-center gap-2 p-0",
showDragHandle ? "pl-1" : "pl-4",
)}
className={cn("flex flex-1 flex-row items-center gap-2 p-0 pl-1")}
>
<div className="flex w-[4rem] flex-shrink-0 flex-col gap-1">
{isPlaceholder ? (
@@ -1,5 +1,5 @@
import { type LLMToolCall } from "@langfuse/shared";
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
import { PrettyJsonView } from "@/src/components/ui/PrettyJsonView";
export const ToolCallCard: React.FC<{ toolCall: LLMToolCall }> = ({
toolCall,
@@ -19,9 +19,10 @@ export const ToolCallCard: React.FC<{ toolCall: LLMToolCall }> = ({
<div className="text-xs text-gray-500 dark:text-gray-400">
Arguments
</div>
<JSONView
json={JSON.stringify(toolCall.args, null, 2)}
<PrettyJsonView
json={toolCall.args}
codeClassName="border-none p-1"
currentView="pretty"
/>
</div>
<div className="flex w-[25%] flex-col overflow-hidden">
+7 -15
View File
@@ -12,7 +12,6 @@ import {
ChatMessageRole,
ChatMessageType,
type ChatMessageWithId,
SYSTEM_ROLES,
} from "@langfuse/shared";
import { ChatMessageComponent } from "./ChatMessageComponent";
@@ -67,14 +66,7 @@ export const ChatMessages: React.FC<ChatMessagesProps> = (props) => {
if (newIndex < 0 || oldIndex < 0) {
return;
}
// prevent reordering system messages, placeholders can be reordered
const targetMessage = messages[newIndex];
if (
targetMessage.type !== ChatMessageType.Placeholder &&
SYSTEM_ROLES.includes(targetMessage.role)
) {
return;
}
// allow any message to be reordered
const newMessages = arrayMove(messages, oldIndex, newIndex);
props.setMessages(newMessages);
}
@@ -90,7 +82,7 @@ export const ChatMessages: React.FC<ChatMessagesProps> = (props) => {
>
<div className="flex h-full flex-col">
<div className="flex-1 overflow-auto scroll-smooth" ref={scrollAreaRef}>
<div className="mb-4 flex-1 space-y-2">
<div className="flex-1 space-y-2">
<SortableContext
items={props.messages.map((message) => message.id)}
strategy={verticalListSortingStrategy}
@@ -110,11 +102,11 @@ export const ChatMessages: React.FC<ChatMessagesProps> = (props) => {
);
})}
</SortableContext>
<div className="mb-4 py-3">
<AddMessageButton {...props} />
</div>
</div>
</div>
<div className="py-3">
<AddMessageButton {...props} />
</div>
</div>
</DndContext>
);
@@ -171,7 +163,7 @@ const AddMessageButton: React.FC<AddMessageButtonProps> = ({
onClick={addRegularMessage}
>
<PlusCircleIcon size={14} className="mr-2" />
<p>Add message</p>
<p>Message</p>
</Button>
<TooltipProvider>
<Tooltip>
@@ -183,7 +175,7 @@ const AddMessageButton: React.FC<AddMessageButtonProps> = ({
onClick={addPlaceholderMessage}
>
<PlusCircleIcon size={14} className="mr-2" />
<p>Add message placeholder</p>
<p>Placeholder</p>
</Button>
</TooltipTrigger>
<TooltipContent>
+192 -67
View File
@@ -41,6 +41,7 @@ export type ModelParamsContext = {
formDisabled?: boolean;
modelParamsDescription?: string;
customHeader?: React.ReactNode;
layout?: "vertical" | "compact";
};
export const ModelParameters: React.FC<ModelParamsContext> = ({
@@ -52,6 +53,7 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
formDisabled = false,
modelParamsDescription,
customHeader,
layout = "vertical",
}) => {
const projectId = useProjectIdFromURL();
const [modelSettingsOpen, setModelSettingsOpen] = useState(false);
@@ -89,77 +91,159 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
);
}
// Settings button component for reuse
const SettingsButton = (
<Popover open={modelSettingsOpen} onOpenChange={setModelSettingsOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="icon"
className="relative h-7 w-7"
disabled={formDisabled}
>
<Settings2 size={14} />
{modelSettingsUsed && (
<div className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-primary" />
)}
</Button>
</PopoverTrigger>
<PopoverContent
className="p-4"
align={layout === "compact" ? "start" : "end"}
sideOffset={5}
>
<div className="mb-3">
<h4 className="mb-1 text-sm font-medium">Model Advanced Settings</h4>
<p className="text-xs text-muted-foreground">
Configure advanced parameters for your model.
</p>
</div>
<div className="space-y-4">
<ModelParamsSlider
title="Temperature"
modelParamsKey="temperature"
formDisabled={formDisabled}
enabled={modelParams.temperature.enabled}
setModelParamEnabled={setModelParamEnabled}
value={modelParams.temperature.value}
min={0}
max={modelParams.maxTemperature.value}
step={0.01}
tooltip="The sampling temperature. Higher values will make the output more random, while lower values will make it more focused and deterministic."
updateModelParam={updateModelParamValue}
/>
<ModelParamsSlider
title="Output token limit"
modelParamsKey="max_tokens"
formDisabled={formDisabled}
enabled={modelParams.max_tokens.enabled}
setModelParamEnabled={setModelParamEnabled}
value={modelParams.max_tokens.value}
min={1}
max={16384}
step={1}
tooltip="The maximum number of tokens that can be generated in the chat completion."
updateModelParam={updateModelParamValue}
/>
<ModelParamsSlider
title="Top P"
modelParamsKey="top_p"
formDisabled={formDisabled}
enabled={modelParams.top_p.enabled}
setModelParamEnabled={setModelParamEnabled}
value={modelParams.top_p.value}
min={0}
max={1}
step={0.01}
tooltip="An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We generally recommend altering this or temperature but not both."
updateModelParam={updateModelParamValue}
/>
<LLMApiKeyComponent {...{ projectId, modelParams }} />
</div>
</PopoverContent>
</Popover>
);
// Compact layout - single horizontal row following standard codebase patterns
if (layout === "compact") {
// Create combined options in "Provider: model" format
// We create combinations of all available providers with all available models
const combinedOptions: string[] = [];
availableProviders.forEach((provider) => {
availableModels.forEach((model) => {
combinedOptions.push(`${provider}: ${model}`);
});
});
// Current combined value in "Provider: model" format
const currentCombinedValue = `${modelParams.provider.value}: ${modelParams.model.value}`;
const handleCombinedSelection = (combinedValue: string) => {
// Parse the combined value back into provider and model
const colonIndex = combinedValue.indexOf(": ");
if (colonIndex !== -1) {
const provider = combinedValue.substring(0, colonIndex);
const model = combinedValue.substring(colonIndex + 2);
updateModelParamValue("provider", provider);
updateModelParamValue("model", model);
}
};
return (
<div className="flex flex-col space-y-2 pb-1 pr-1 pt-2">
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1 space-y-1">
<Select
disabled={formDisabled}
onValueChange={handleCombinedSelection}
value={currentCombinedValue}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{combinedOptions.map((option) => (
<SelectItem value={option} key={option}>
{option}
</SelectItem>
))}
<SelectSeparator />
<CreateLLMApiKeyDialog />
</SelectContent>
</Select>
{modelParamsDescription ? (
<FormDescription className="mt-1 text-xs">
{modelParamsDescription}
</FormDescription>
) : undefined}
</div>
<div className="flex-shrink-0">{SettingsButton}</div>
</div>
{modelParams.model.value?.startsWith("o1-") ? (
<p className="mt-1 text-xs text-dark-yellow">
For {modelParams.model.value}, the system message and the
temperature, max_tokens and top_p setting are not supported while it
is in beta.{" "}
<a
href="https://platform.openai.com/docs/guides/reasoning/beta-limitations"
target="_blank"
rel="noreferrer noopener"
>
More info
</a>
</p>
) : null}
</div>
);
}
// Vertical layout (default) - existing behavior
return (
<div className="flex flex-col space-y-2 pb-1 pr-1 pt-2">
<div className="flex items-center justify-between">
{customHeader ? customHeader : <p className="font-semibold">Model</p>}
<Popover open={modelSettingsOpen} onOpenChange={setModelSettingsOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="icon"
className="relative h-7 w-7"
disabled={formDisabled}
>
<Settings2 size={14} />
{modelSettingsUsed && (
<div className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-primary" />
)}
</Button>
</PopoverTrigger>
<PopoverContent className="p-4" align="end" sideOffset={5}>
<div className="mb-3">
<h4 className="mb-1 text-sm font-medium">
Model Advanced Settings
</h4>
<p className="text-xs text-muted-foreground">
Configure advanced parameters for your model.
</p>
</div>
<div className="space-y-4">
<ModelParamsSlider
title="Temperature"
modelParamsKey="temperature"
formDisabled={formDisabled}
enabled={modelParams.temperature.enabled}
setModelParamEnabled={setModelParamEnabled}
value={modelParams.temperature.value}
min={0}
max={modelParams.maxTemperature.value}
step={0.01}
tooltip="The sampling temperature. Higher values will make the output more random, while lower values will make it more focused and deterministic."
updateModelParam={updateModelParamValue}
/>
<ModelParamsSlider
title="Output token limit"
modelParamsKey="max_tokens"
formDisabled={formDisabled}
enabled={modelParams.max_tokens.enabled}
setModelParamEnabled={setModelParamEnabled}
value={modelParams.max_tokens.value}
min={1}
max={16384}
step={1}
tooltip="The maximum number of tokens that can be generated in the chat completion."
updateModelParam={updateModelParamValue}
/>
<ModelParamsSlider
title="Top P"
modelParamsKey="top_p"
formDisabled={formDisabled}
enabled={modelParams.top_p.enabled}
setModelParamEnabled={setModelParamEnabled}
value={modelParams.top_p.value}
min={0}
max={1}
step={0.01}
tooltip="An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We generally recommend altering this or temperature but not both."
updateModelParam={updateModelParamValue}
/>
<LLMApiKeyComponent {...{ projectId, modelParams }} />
</div>
</PopoverContent>
</Popover>
{SettingsButton}
</div>
<div className="space-y-4">
@@ -171,6 +255,7 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
value={modelParams.provider.value}
options={availableProviders}
updateModelParam={updateModelParamValue}
layout="vertical"
/>
<ModelParamsSelect
title="Model name"
@@ -180,6 +265,7 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
options={[...new Set(availableModels)]}
updateModelParam={updateModelParamValue}
modelParamsDescription={modelParamsDescription}
layout="vertical"
/>
</div>
@@ -210,6 +296,7 @@ type ModelParamsSelectProps = {
updateModelParam: ModelParamsContext["updateModelParamValue"];
disabled?: boolean;
modelParamsDescription?: string;
layout?: "vertical" | "compact";
};
const ModelParamsSelect = ({
title,
@@ -219,7 +306,45 @@ const ModelParamsSelect = ({
updateModelParam,
disabled,
modelParamsDescription,
layout = "vertical",
}: ModelParamsSelectProps) => {
// Compact layout - simplified, space-efficient (no individual labels)
if (layout === "compact") {
return (
<div className="space-y-1">
<Select
disabled={disabled}
onValueChange={(value) =>
updateModelParam(
modelParamsKey,
value as (typeof supportedModels)[LLMAdapter][number],
)
}
value={value}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem value={option} key={option}>
{option}
</SelectItem>
))}
<SelectSeparator />
<CreateLLMApiKeyDialog />
</SelectContent>
</Select>
{modelParamsDescription ? (
<FormDescription className="mt-1 text-xs">
{modelParamsDescription}
</FormDescription>
) : undefined}
</div>
);
}
// Vertical layout (default) - existing behavior
return (
<div className="flex items-center gap-4">
<div className="w-24 flex-shrink-0">
@@ -58,6 +58,11 @@ interface SearchConfig {
tableAllowsFullTextSearch?: boolean;
setSearchType: ((newSearchType: TracingSearchType[]) => void) | undefined;
searchType: TracingSearchType[] | undefined;
customDropdownLabels?: {
metadata: string;
fullText: string;
};
hidePerformanceWarning?: boolean;
}
interface TableViewControllers {
@@ -185,8 +190,10 @@ export function DataTableToolbar<TData, TValue>({
<span className="flex items-center gap-1 truncate">
{searchConfig.tableAllowsFullTextSearch &&
(searchConfig.searchType ?? []).includes("content")
? "Full Text"
: "IDs / Names"}
? (searchConfig.customDropdownLabels?.fullText ??
"Full Text")
: (searchConfig.customDropdownLabels?.metadata ??
"IDs / Names")}
<DocPopup
description={
searchConfig.tableAllowsFullTextSearch &&
@@ -196,8 +203,8 @@ export function DataTableToolbar<TData, TValue>({
<p className="text-xs font-normal text-primary">
Searches in Input/Output and{" "}
{searchConfig.metadataSearchFields.join(", ")}.
For improved performance, please filter the table
down.
{!searchConfig.hidePerformanceWarning &&
" For improved performance, please filter the table down."}
</p>
) : (
<p className="text-xs font-normal text-primary">
@@ -234,13 +241,15 @@ export function DataTableToolbar<TData, TValue>({
}}
>
<DropdownMenuRadioItem value="metadata">
IDs / Names
{searchConfig.customDropdownLabels?.metadata ??
"IDs / Names"}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value="metadata_fulltext"
disabled={!searchConfig.tableAllowsFullTextSearch}
>
Full Text
{searchConfig.customDropdownLabels?.fullText ??
"Full Text"}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
+26 -18
View File
@@ -1,11 +1,10 @@
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
import { PrettyJsonView } from "@/src/components/ui/PrettyJsonView";
import { z } from "zod/v4";
import { type Prisma, deepParseJson } from "@langfuse/shared";
import { cn } from "@/src/utils/tailwind";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/src/components/ui/button";
import { Fragment } from "react";
import { StringOrMarkdownSchema } from "@/src/components/schemas/MarkdownSchema";
import {
ChatMlArraySchema,
type ChatMlMessageSchema,
@@ -84,11 +83,8 @@ export const IOPreview: React.FC<{
Array.isArray(output) ? output : [output],
);
const inMarkdown = StringOrMarkdownSchema.safeParse(input);
const outMarkdown = StringOrMarkdownSchema.safeParse(output);
const isPrettyViewAvailable =
inChatMlArray.success || inMarkdown.success || outMarkdown.success;
// Pretty view is available for ChatML content OR any JSON content
const isPrettyViewAvailable = true; // Always show the toggle, let individual components decide how to render
useEffect(() => {
props.setIsPrettyViewAvailable?.(isPrettyViewAvailable);
@@ -154,24 +150,28 @@ export const IOPreview: React.FC<{
: undefined
}
media={media ?? []}
currentView={selectedView}
/>
) : (
<>
{!(hideIfNull && !input) && !hideInput ? (
<MarkdownJsonView
<PrettyJsonView
title="Input"
className="ph-no-capture"
content={input}
json={input ?? null}
isLoading={isLoading}
media={media?.filter((m) => m.field === "input") ?? []}
currentView={selectedView}
/>
) : null}
{!(hideIfNull && !output) && !hideOutput ? (
<MarkdownJsonView
<PrettyJsonView
title="Output"
className="ph-no-capture"
content={output}
customCodeHeaderClassName="bg-secondary"
json={outputClean}
isLoading={isLoading}
media={media?.filter((m) => m.field === "output") ?? []}
currentView={selectedView}
/>
) : null}
</>
@@ -181,21 +181,23 @@ export const IOPreview: React.FC<{
{selectedView === "json" || !isPrettyViewAvailable ? (
<>
{!(hideIfNull && !input) && !hideInput ? (
<JSONView
<PrettyJsonView
title="Input"
className="ph-no-capture"
json={input ?? null}
isLoading={isLoading}
media={media?.filter((m) => m.field === "input") ?? []}
currentView={selectedView}
/>
) : null}
{!(hideIfNull && !output) && !hideOutput ? (
<JSONView
<PrettyJsonView
title="Output"
className="ph-no-capture"
json={outputClean}
isLoading={isLoading}
media={media?.filter((m) => m.field === "output") ?? []}
currentView={selectedView}
/>
) : null}
</>
@@ -212,6 +214,7 @@ export const OpenAiMessageView: React.FC<{
media?: MediaReturnType[];
additionalInput?: Record<string, unknown>;
projectIdForPromptButtons?: string;
currentView?: "pretty" | "json";
}> = ({
title,
messages,
@@ -220,6 +223,7 @@ export const OpenAiMessageView: React.FC<{
collapseLongHistory = true,
additionalInput,
projectIdForPromptButtons,
currentView = "json",
}) => {
const COLLAPSE_THRESHOLD = 3;
const [isCollapsed, setCollapsed] = useState(
@@ -272,10 +276,11 @@ export const OpenAiMessageView: React.FC<{
customCodeHeaderClassName={cn("bg-primary-foreground")}
/>
) : (
<JSONView
<PrettyJsonView
title="Placeholder"
json={message.name || "Unnamed placeholder"}
projectIdForPromptButtons={projectIdForPromptButtons}
currentView={currentView}
/>
)
) : (
@@ -298,7 +303,7 @@ export const OpenAiMessageView: React.FC<{
audio={message.audio}
/>
) : (
<JSONView
<PrettyJsonView
title={message.name ?? message.role}
json={message.content}
projectIdForPromptButtons={projectIdForPromptButtons}
@@ -307,11 +312,12 @@ export const OpenAiMessageView: React.FC<{
!isPlaceholderMessage(message) &&
"rounded-b-none",
)}
currentView={currentView}
/>
))}
{shouldRenderJson(message) &&
!isPlaceholderMessage(message) && (
<JSONView
<PrettyJsonView
title={
message.content
? undefined
@@ -322,6 +328,7 @@ export const OpenAiMessageView: React.FC<{
className={cn(
!!message.content && "rounded-t-none border-t-0",
)}
currentView={shouldRenderMarkdown ? "pretty" : "json"}
/>
)}
</>
@@ -341,10 +348,11 @@ export const OpenAiMessageView: React.FC<{
))}
</div>
{additionalInput && (
<JSONView
<PrettyJsonView
title="Additional Input"
json={additionalInput}
projectIdForPromptButtons={projectIdForPromptButtons}
currentView={shouldRenderMarkdown ? "pretty" : "json"}
/>
)}
{media && media.length > 0 && (
@@ -1,4 +1,4 @@
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
import { PrettyJsonView } from "@/src/components/ui/PrettyJsonView";
import { AnnotationQueueObjectType, type APIScoreV2 } from "@langfuse/shared";
import { Badge } from "@/src/components/ui/badge";
import { type ObservationReturnType } from "@/src/server/api/routers/traces";
@@ -408,22 +408,24 @@ export const ObservationPreview = ({
</div>
<div>
{preloadedObservation.statusMessage && (
<JSONView
<PrettyJsonView
key={preloadedObservation.id + "-status"}
title="Status Message"
json={preloadedObservation.statusMessage}
currentView={currentView}
/>
)}
</div>
<div>
{observationWithInputAndOutput.data?.metadata && (
<JSONView
<PrettyJsonView
key={observationWithInputAndOutput.data.id + "-metadata"}
title="Metadata"
json={observationWithInputAndOutput.data.metadata}
media={observationMedia.data?.filter(
(m) => m.field === "metadata",
)}
currentView={currentView}
/>
)}
</div>
+3 -2
View File
@@ -1,4 +1,4 @@
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
import { PrettyJsonView } from "@/src/components/ui/PrettyJsonView";
import {
type APIScoreV2,
type TraceDomain,
@@ -279,13 +279,14 @@ export const TracePreview = ({
/>
</div>
<div>
<JSONView
<PrettyJsonView
key={trace.id + "-metadata"}
title="Metadata"
json={trace.metadata}
media={
traceMedia.data?.filter((m) => m.field === "metadata") ?? []
}
currentView={currentView}
/>
</div>
</div>
+45 -6
View File
@@ -1,6 +1,13 @@
import { memo, useMemo, useState } from "react";
import { Button } from "@/src/components/ui/button";
import { Check, ChevronsDownUp, ChevronsUpDown, Copy } from "lucide-react";
import {
Check,
ChevronsDownUp,
ChevronsUpDown,
Copy,
FoldVertical,
UnfoldVertical,
} from "lucide-react";
import { cn } from "@/src/utils/tailwind";
import { default as React18JsonView } from "react18-json-view";
import "react18-json-view/src/dark.css";
@@ -21,6 +28,7 @@ export function JSONView(props: {
canEnableMarkdown?: boolean;
json?: unknown;
title?: string;
hideTitle?: boolean;
className?: string;
isLoading?: boolean;
codeClassName?: string;
@@ -29,18 +37,23 @@ export function JSONView(props: {
scrollable?: boolean;
projectIdForPromptButtons?: string;
controlButtons?: React.ReactNode;
externalJsonCollapsed?: boolean;
onToggleCollapse?: () => void;
}) {
// some users ingest stringified json nested in json, parse it
const parsedJson = useMemo(() => deepParseJson(props.json), [props.json]);
const { resolvedTheme } = useTheme();
const { setIsMarkdownEnabled } = useMarkdownContext();
const capture = usePostHogClientCapture();
const [internalCollapsed, setInternalCollapsed] = useState(false);
const collapseStringsAfterLength =
props.collapseStringsAfterLength === null
? 100_000_000 // if null, show all (100M chars)
: (props.collapseStringsAfterLength ?? 500);
const isCollapsed = props.externalJsonCollapsed ?? internalCollapsed;
const handleOnCopy = (event?: React.MouseEvent<HTMLButtonElement>) => {
if (event) {
event.preventDefault();
@@ -61,6 +74,14 @@ export function JSONView(props: {
});
};
const handleToggleCollapse = () => {
if (props.onToggleCollapse) {
props.onToggleCollapse();
} else {
setInternalCollapsed(!internalCollapsed);
}
};
const body = (
<>
<div
@@ -90,7 +111,7 @@ export function JSONView(props: {
src={parsedJson}
theme="github"
dark={resolvedTheme === "dark"}
collapseObjectsAfterLength={20}
collapseObjectsAfterLength={isCollapsed ? 0 : 20}
collapseStringsAfterLength={collapseStringsAfterLength}
collapseStringMode="word"
customizeCollapseStringUI={(fullSTring, truncated) =>
@@ -100,7 +121,7 @@ export function JSONView(props: {
""
)
}
displaySize={"collapsed"}
displaySize={isCollapsed ? "collapsed" : "expanded"}
matchesURL={true}
customizeCopy={(node) => stringifyJsonNode(node)}
className="w-full"
@@ -134,13 +155,30 @@ export function JSONView(props: {
props.scrollable ? "overflow-hidden" : "",
)}
>
{props.title ? (
{props.title && !props.hideTitle ? (
<MarkdownJsonViewHeader
title={props.title}
canEnableMarkdown={props.canEnableMarkdown ?? false}
handleOnValueChange={handleOnValueChange}
handleOnCopy={handleOnCopy}
controlButtons={props.controlButtons}
controlButtons={
<>
{props.controlButtons}
<Button
variant="ghost"
size="icon-xs"
onClick={handleToggleCollapse}
className="-mr-2 hover:bg-border"
title={isCollapsed ? "Expand all" : "Collapse all"}
>
{isCollapsed ? (
<UnfoldVertical className="h-3 w-3" />
) : (
<FoldVertical className="h-3 w-3" />
)}
</Button>
</>
}
/>
) : null}
{props.scrollable ? (
@@ -304,7 +342,7 @@ export const IOTableCell = ({
</div>
) : (
<JSONView
json={stringifiedJson}
json={data}
className={cn(
"ph-no-capture h-full w-full self-stretch rounded-sm",
className,
@@ -343,6 +381,7 @@ export const JsonSkeleton = ({
);
};
// TODO: deduplicate with PrettyJsonView.tsx
function stringifyJsonNode(node: unknown) {
// return single string nodes without quotes
if (typeof node === "string") {
+6 -25
View File
@@ -4,14 +4,12 @@ import {
} from "@/src/components/schemas/ChatMlSchema";
import { StringOrMarkdownSchema } from "@/src/components/schemas/MarkdownSchema";
import { Button } from "@/src/components/ui/button";
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
import { PrettyJsonView } from "@/src/components/ui/PrettyJsonView";
import { MarkdownView } from "@/src/components/ui/MarkdownViewer";
import { type MediaReturnType } from "@/src/features/media/validation";
import { useMarkdownContext } from "@/src/features/theming/useMarkdownContext";
import { Check, Copy } from "lucide-react";
import { useMemo, useState } from "react";
import { type z } from "zod/v4";
import { cn } from "@/src/utils/tailwind";
type MarkdownJsonViewHeaderProps = {
title: string;
@@ -23,34 +21,18 @@ type MarkdownJsonViewHeaderProps = {
export function MarkdownJsonViewHeader({
title,
handleOnValueChange,
handleOnValueChange: _handleOnValueChange,
handleOnCopy,
canEnableMarkdown = true,
canEnableMarkdown: _canEnableMarkdown = true,
controlButtons,
}: MarkdownJsonViewHeaderProps) {
const [isCopied, setIsCopied] = useState(false);
const { isMarkdownEnabled } = useMarkdownContext();
return (
<div className="flex flex-row items-center justify-between px-1 py-1 text-sm font-medium capitalize">
{title}
<div className="mr-1 flex min-w-0 flex-shrink flex-row items-center gap-1">
{controlButtons}
{canEnableMarkdown && (
<Button
title={isMarkdownEnabled ? "Disable Markdown" : "Enable Markdown"}
variant="ghost"
size="sm"
type="button"
onClick={handleOnValueChange}
className={cn(
"hover:bg-border",
!isMarkdownEnabled ? "opacity-50" : "opacity-100",
)}
>
{isMarkdownEnabled ? "View as JSON" : "View as markdown"}
</Button>
)}
<Button
title="Copy to clipboard"
variant="ghost"
@@ -105,7 +87,6 @@ export function MarkdownJsonView({
[content],
);
const { isMarkdownEnabled } = useMarkdownContext();
const canEnableMarkdown = isSupportedMarkdownFormat(
content,
validatedOpenAIContent,
@@ -113,7 +94,7 @@ export function MarkdownJsonView({
return (
<>
{isMarkdownEnabled && canEnableMarkdown ? (
{canEnableMarkdown ? (
<MarkdownView
markdown={stringOrValidatedMarkdown.data ?? content}
title={title}
@@ -122,12 +103,12 @@ export function MarkdownJsonView({
media={media}
/>
) : (
<JSONView
<PrettyJsonView
json={content ?? (audio ? { audio } : null)}
canEnableMarkdown={canEnableMarkdown}
title={title}
className={className}
media={media}
currentView="pretty"
/>
)}
</>
+799
View File
@@ -0,0 +1,799 @@
import { useMemo, useState, useEffect, memo, useRef, useCallback } from "react";
import { cn } from "@/src/utils/tailwind";
import { deepParseJson } from "@langfuse/shared";
import { Skeleton } from "@/src/components/ui/skeleton";
import { type MediaReturnType } from "@/src/features/media/validation";
import { LangfuseMediaView } from "@/src/components/ui/LangfuseMediaView";
import { MarkdownJsonViewHeader } from "@/src/components/ui/MarkdownJsonView";
import { copyTextToClipboard } from "@/src/utils/clipboard";
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
import { Button } from "@/src/components/ui/button";
import {
ChevronDown,
ChevronRight,
UnfoldVertical,
FoldVertical,
} from "lucide-react";
import {
useReactTable,
getCoreRowModel,
getExpandedRowModel,
flexRender,
type ExpandedState,
type Row,
} from "@tanstack/react-table";
import { type LangfuseColumnDef } from "@/src/components/table/types";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/src/components/ui/table";
import { ChatMlArraySchema } from "@/src/components/schemas/ChatMlSchema";
import { MarkdownView } from "@/src/components/ui/MarkdownViewer";
import {
StringOrMarkdownSchema,
containsAnyMarkdown,
} from "@/src/components/schemas/MarkdownSchema";
// Constants for array display logic
const SMALL_ARRAY_THRESHOLD = 5;
const ARRAY_PREVIEW_ITEMS = 3;
const OBJECT_PREVIEW_KEYS = 2;
// Constants for table layout
const INDENTATION_PER_LEVEL = 16;
const INDENTATION_BASE = 8;
const BUTTON_WIDTH = 16;
const MARGIN_LEFT_1 = 4;
const CELL_PADDING_X = 8; // px-2
const ASSISTANT_TITLES = ["assistant", "Output"];
const SYSTEM_TITLES = ["system", "Input"];
const MONO_TEXT_CLASSES = "font-mono text-xs break-words";
const PREVIEW_TEXT_CLASSES = "italic text-gray-500 dark:text-gray-400";
function getEmptyValueDisplay(value: unknown): string | null {
if (value === null) return "null";
if (value === undefined) return "undefined";
if (value === "") return "empty string";
if (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.keys(value).length === 0
) {
return "empty object";
}
return null;
}
function getContainerClasses(
title: string | undefined,
scrollable: boolean | undefined,
codeClassName: string | undefined,
baseClasses = "whitespace-pre-wrap break-words p-3 text-xs",
) {
return cn(
baseClasses,
ASSISTANT_TITLES.includes(title || "")
? "bg-accent-light-green dark:border-accent-dark-green"
: "",
SYSTEM_TITLES.includes(title || "") ? "bg-primary-foreground" : "",
scrollable ? "" : "rounded-sm border",
codeClassName,
);
}
function isChatMLFormat(json: unknown): boolean {
if (!json || typeof json !== "object") return false;
if (Array.isArray(json)) {
const directArray = ChatMlArraySchema.safeParse(json);
if (directArray.success) return true;
}
if ("messages" in json && Array.isArray((json as any).messages)) {
const messagesArray = ChatMlArraySchema.safeParse((json as any).messages);
if (messagesArray.success) return true;
}
if (Array.isArray(json) && json.length === 1 && Array.isArray(json[0])) {
const nestedArray = ChatMlArraySchema.safeParse(json[0]);
if (nestedArray.success) return true;
}
return false;
}
function isMarkdownContent(json: unknown): {
isMarkdown: boolean;
content?: string;
} {
if (typeof json === "string") {
const markdownResult = StringOrMarkdownSchema.safeParse(json);
if (markdownResult.success) {
return { isMarkdown: true, content: json };
}
}
// Check if render as markdown: object has one key and the value is a markdown like string
if (
typeof json === "object" &&
json !== null &&
!Array.isArray(json) &&
json.constructor === Object
) {
const entries = Object.entries(json);
if (entries.length === 1) {
const [, value] = entries[0];
if (typeof value === "string") {
if (containsAnyMarkdown(value)) {
return { isMarkdown: true, content: value };
}
}
}
}
return { isMarkdown: false };
}
interface JsonTableRow {
id: string;
key: string;
value: unknown;
type:
| "string"
| "number"
| "boolean"
| "object"
| "array"
| "null"
| "undefined";
hasChildren: boolean;
level: number;
subRows?: JsonTableRow[];
}
function getValueType(value: unknown): JsonTableRow["type"] {
if (value === null) return "null";
if (value === undefined) return "undefined";
if (Array.isArray(value)) return "array";
return typeof value as JsonTableRow["type"];
}
function hasChildren(value: unknown, valueType: JsonTableRow["type"]): boolean {
return (
(valueType === "object" &&
Object.keys(value as Record<string, unknown>).length > 0) ||
(valueType === "array" && Array.isArray(value) && value.length > 0)
);
}
function transformJsonToTableData(
json: unknown,
parentKey = "",
level = 0,
parentId = "",
): JsonTableRow[] {
const rows: JsonTableRow[] = [];
if (typeof json !== "object" || json === null) {
return [
{
id: parentId || "0",
key: parentKey || "root",
value: json,
type: getValueType(json),
hasChildren: false,
level,
},
];
}
const entries = Array.isArray(json)
? json.map((item, index) => [index.toString(), item])
: Object.entries(json);
entries.forEach(([key, value]) => {
const id = parentId ? `${parentId}-${key}` : key;
const valueType = getValueType(value);
const childrenExist = hasChildren(value, valueType);
const row: JsonTableRow = {
id,
key,
value,
type: valueType,
hasChildren: childrenExist,
level,
};
if (childrenExist) {
const children = transformJsonToTableData(value, key, level + 1, id);
row.subRows = children;
}
rows.push(row);
});
return rows;
}
function renderArrayValue(arr: unknown[]): JSX.Element {
if (arr.length === 0) {
return <span className={PREVIEW_TEXT_CLASSES}>empty list</span>;
}
if (arr.length <= SMALL_ARRAY_THRESHOLD) {
// Show inline values for small arrays
const displayItems = arr
.map((item) => {
const itemType = getValueType(item);
if (itemType === "string") return `"${String(item)}"`;
if (itemType === "object" && item !== null) {
const obj = item as Record<string, unknown>;
const keys = Object.keys(obj);
if (keys.length === 0) return "{}";
if (keys.length <= OBJECT_PREVIEW_KEYS) {
const keyPreview = keys.map((k) => `"${k}": ...`).join(", ");
return `{${keyPreview}}`;
} else {
return `{"${keys[0]}": ...}`;
}
}
if (itemType === "array") return "...";
return String(item);
})
.join(", ");
return <span className={PREVIEW_TEXT_CLASSES}>[{displayItems}]</span>;
} else {
// Show truncated values for large arrays
const preview = arr
.slice(0, ARRAY_PREVIEW_ITEMS)
.map((item) => {
const itemType = getValueType(item);
if (itemType === "string") return `"${String(item)}"`;
if (itemType === "object" || itemType === "array") return "...";
return String(item);
})
.join(", ");
return (
<span className={PREVIEW_TEXT_CLASSES}>
[{preview}, ...{arr.length - ARRAY_PREVIEW_ITEMS} more]
</span>
);
}
}
function renderObjectValue(obj: Record<string, unknown>): JSX.Element {
const keys = Object.keys(obj);
if (keys.length === 0) {
return <span className={PREVIEW_TEXT_CLASSES}>empty object</span>;
}
return <span className={PREVIEW_TEXT_CLASSES}>{keys.length} items</span>;
}
const ValueCell = memo(({ row }: { row: Row<JsonTableRow> }) => {
const { value, type } = row.original;
const renderValue = () => {
switch (type) {
case "string":
return (
<span className="whitespace-pre-line text-green-600 dark:text-green-400">
&quot;{String(value)}&quot;
</span>
);
case "number":
return (
<span className="text-blue-600 dark:text-blue-400">
{String(value)}
</span>
);
case "boolean":
return (
<span className="text-orange-600 dark:text-orange-400">
{String(value)}
</span>
);
case "null":
return (
<span className="italic text-gray-500 dark:text-gray-400">null</span>
);
case "undefined":
return (
<span className="text-gray-500 dark:text-gray-400">undefined</span>
);
case "array":
return renderArrayValue(value as unknown[]);
case "object":
return renderObjectValue(value as Record<string, unknown>);
default:
return (
<span className="text-gray-600 dark:text-gray-400">
{String(value)}
</span>
);
}
};
return (
<div className={`${MONO_TEXT_CLASSES} max-w-full`}>{renderValue()}</div>
);
});
ValueCell.displayName = "ValueCell";
function JsonPrettyTable({
data,
expandAllRef,
onExpandStateChange,
noBorder = false,
}: {
data: JsonTableRow[];
expandAllRef?: React.MutableRefObject<(() => void) | null>;
onExpandStateChange?: (allExpanded: boolean) => void;
noBorder?: boolean;
}) {
const [expanded, setExpanded] = useState<ExpandedState>({});
const columns: LangfuseColumnDef<JsonTableRow, unknown>[] = [
{
accessorKey: "key",
header: "Path",
size: 35,
cell: ({ row }) => {
// we need to calculate the indentation here for a good line break
// because of the padding, we don't know when to break the line otherwise
const indentationWidth =
row.original.level * INDENTATION_PER_LEVEL + INDENTATION_BASE;
const buttonWidth = row.original.hasChildren ? BUTTON_WIDTH : 0;
const availableTextWidth = `calc(100% - ${indentationWidth + buttonWidth + CELL_PADDING_X + MARGIN_LEFT_1}px)`;
return (
<div className="flex items-start break-words">
<div
className="flex flex-shrink-0 items-center justify-end"
style={{ width: `${indentationWidth}px` }}
>
{row.original.hasChildren && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
row.toggleExpanded();
}}
className="h-4 w-4 p-0"
>
{row.getIsExpanded() ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</Button>
)}
</div>
<span
className={`ml-1 ${MONO_TEXT_CLASSES} font-medium`}
style={{ maxWidth: availableTextWidth }}
>
{row.original.key}
</span>
</div>
);
},
},
{
accessorKey: "value",
header: "Value",
size: 65,
cell: ({ row }) => <ValueCell row={row} />,
},
];
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getSubRows: (row) => row.subRows,
state: {
expanded,
},
onExpandedChange: setExpanded,
enableColumnResizing: false,
});
const allRowsExpanded = useMemo(() => {
const allRows = table.getRowModel().flatRows;
const expandableRows = allRows.filter((row) => row.original.hasChildren);
return (
expandableRows.length > 0 &&
expandableRows.every((row) => row.getIsExpanded())
);
// expanded is required for the collapse button to work
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [table, expanded]);
// Notify parent of expand state changes
useEffect(() => {
onExpandStateChange?.(allRowsExpanded);
}, [allRowsExpanded, onExpandStateChange]);
const handleToggleExpandAll = useCallback(() => {
if (allRowsExpanded) {
table.toggleAllRowsExpanded(false);
} else {
table.toggleAllRowsExpanded(true);
}
}, [allRowsExpanded, table]);
useEffect(() => {
if (expandAllRef) {
expandAllRef.current = handleToggleExpandAll;
}
}, [expandAllRef, handleToggleExpandAll]);
useEffect(() => {
setExpanded({});
onExpandStateChange?.(false);
}, [data, onExpandStateChange]);
return (
<div className={cn("w-full", !noBorder && "rounded-sm border")}>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className="h-8 bg-transparent px-2 py-1"
style={{ width: `${header.column.columnDef.size}%` }}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
onClick={() => {
if (row.original.hasChildren) {
row.toggleExpanded();
}
}}
className={row.original.hasChildren ? "cursor-pointer" : ""}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="whitespace-normal px-2 py-1"
style={{ width: `${cell.column.columnDef.size}%` }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
export function PrettyJsonView(props: {
json?: unknown;
title?: string;
className?: string;
isLoading?: boolean;
codeClassName?: string;
collapseStringsAfterLength?: number | null;
media?: MediaReturnType[];
scrollable?: boolean;
projectIdForPromptButtons?: string;
controlButtons?: React.ReactNode;
currentView?: "pretty" | "json";
}) {
const parsedJson = useMemo(() => deepParseJson(props.json), [props.json]);
const actualCurrentView = props.currentView ?? "pretty";
const expandAllRef = useRef<(() => void) | null>(null);
const [allRowsExpanded, setAllRowsExpanded] = useState(false);
const [jsonIsCollapsed, setJsonIsCollapsed] = useState(false);
const isChatML = useMemo(() => isChatMLFormat(parsedJson), [parsedJson]);
const markdownCheck = useMemo(
() => isMarkdownContent(parsedJson),
[parsedJson],
);
const tableData = useMemo(() => {
try {
if (
actualCurrentView === "pretty" &&
parsedJson !== null &&
parsedJson !== undefined &&
!isChatML &&
!markdownCheck.isMarkdown
) {
// Helper function to create rows from object entries at level 0
const createTopLevelRows = (
obj: Record<string, unknown>,
): JsonTableRow[] => {
const entries = Object.entries(obj);
const rows: JsonTableRow[] = [];
entries.forEach(([key, value]) => {
const valueType = getValueType(value);
const childrenExist = hasChildren(value, valueType);
const row: JsonTableRow = {
id: key,
key,
value,
type: valueType,
hasChildren: childrenExist,
level: 0,
};
if (childrenExist) {
const children = transformJsonToTableData(value, key, 1, key);
row.subRows = children;
}
rows.push(row);
});
return rows;
};
// If top-level is an object, start with its properties directly
if (
typeof parsedJson === "object" &&
parsedJson !== null &&
!Array.isArray(parsedJson) &&
parsedJson.constructor === Object
) {
return createTopLevelRows(parsedJson as Record<string, unknown>);
}
return transformJsonToTableData(parsedJson);
}
return [];
} catch (error) {
console.error("Error transforming JSON to table data:", error);
return [];
}
}, [parsedJson, actualCurrentView, isChatML, markdownCheck.isMarkdown]);
const handleOnCopy = (event?: React.MouseEvent<HTMLButtonElement>) => {
if (event) {
event.preventDefault();
}
const textToCopy = stringifyJsonNode(parsedJson);
void copyTextToClipboard(textToCopy);
if (event) {
event.currentTarget.focus();
}
};
const handleJsonToggleCollapse = () => {
setJsonIsCollapsed(!jsonIsCollapsed);
};
const emptyValueDisplay = getEmptyValueDisplay(parsedJson);
const isMarkdownMode =
markdownCheck.isMarkdown && actualCurrentView === "pretty";
const shouldUseTableView =
actualCurrentView === "pretty" &&
!isChatML &&
!markdownCheck.isMarkdown &&
!emptyValueDisplay;
const getBackgroundColorClass = () =>
cn(
ASSISTANT_TITLES.includes(props.title || "")
? "bg-accent-light-green"
: "",
SYSTEM_TITLES.includes(props.title || "") ? "bg-primary-foreground" : "",
);
const body = (
<>
{emptyValueDisplay && actualCurrentView === "pretty" ? (
<div
className={cn(
"flex items-center",
getContainerClasses(
props.title,
props.scrollable,
props.codeClassName,
),
)}
>
{props.isLoading ? (
<Skeleton className="h-3 w-3/4" />
) : (
<span className={`font-mono ${PREVIEW_TEXT_CLASSES}`}>
{emptyValueDisplay}
</span>
)}
</div>
) : isMarkdownMode ? (
props.isLoading ? (
<Skeleton className="h-3 w-3/4" />
) : (
<MarkdownView markdown={markdownCheck.content || ""} />
)
) : shouldUseTableView ? (
<div
className={getContainerClasses(
props.title,
props.scrollable,
props.codeClassName,
"flex whitespace-pre-wrap break-words text-xs",
)}
>
{props.isLoading ? (
<Skeleton className="m-3 h-3 w-3/4" />
) : (
<JsonPrettyTable
data={tableData}
expandAllRef={expandAllRef}
onExpandStateChange={setAllRowsExpanded}
noBorder={true}
/>
)}
</div>
) : (
<JSONView
json={props.json}
title={props.title} // Title value used for background styling
hideTitle={true} // But hide the title, we display it
className=""
isLoading={props.isLoading}
codeClassName={props.codeClassName}
collapseStringsAfterLength={props.collapseStringsAfterLength}
media={props.media}
scrollable={props.scrollable}
projectIdForPromptButtons={props.projectIdForPromptButtons}
externalJsonCollapsed={jsonIsCollapsed}
onToggleCollapse={handleJsonToggleCollapse}
/>
)}
{props.media && props.media.length > 0 && (
<>
<div className="mx-3 border-t px-2 py-1 text-xs text-muted-foreground">
Media
</div>
<div className="flex flex-wrap gap-2 p-4 pt-1">
{props.media.map((m) => (
<LangfuseMediaView
mediaAPIReturnValue={m}
asFileIcon={true}
key={m.mediaId}
/>
))}
</div>
</>
)}
</>
);
return (
<div
className={cn(
"flex max-h-full min-h-0 flex-col",
props.className,
props.scrollable ? "overflow-hidden" : "",
)}
>
{props.title ? (
<MarkdownJsonViewHeader
title={props.title}
canEnableMarkdown={false}
handleOnValueChange={() => {}} // No-op, parent handles state
handleOnCopy={handleOnCopy}
controlButtons={
<>
{props.controlButtons}
{shouldUseTableView && (
<Button
variant="ghost"
size="icon-xs"
onClick={() => expandAllRef.current?.()}
className="-mr-2 hover:bg-border"
title={
allRowsExpanded ? "Collapse all rows" : "Expand all rows"
}
>
{allRowsExpanded ? (
<FoldVertical className="h-3 w-3" />
) : (
<UnfoldVertical className="h-3 w-3" />
)}
</Button>
)}
{actualCurrentView === "json" && (
<Button
variant="ghost"
size="icon-xs"
onClick={handleJsonToggleCollapse}
className="-mr-2 hover:bg-border"
title={jsonIsCollapsed ? "Expand all" : "Collapse all"}
>
{jsonIsCollapsed ? (
<UnfoldVertical className="h-3 w-3" />
) : (
<FoldVertical className="h-3 w-3" />
)}
</Button>
)}
</>
}
/>
) : null}
{props.scrollable ? (
<div
className={cn(
"flex h-full min-h-0 overflow-hidden",
isMarkdownMode ? getBackgroundColorClass() : "rounded-sm border",
)}
>
<div className="max-h-full min-h-0 w-full overflow-y-auto">
{body}
</div>
</div>
) : isMarkdownMode ? (
<div className={getBackgroundColorClass()}>{body}</div>
) : (
body
)}
</div>
);
}
// TODO: deduplicate with CodeJsonViewer.tsx
function stringifyJsonNode(node: unknown) {
// return single string nodes without quotes
if (typeof node === "string") {
return node;
}
try {
return JSON.stringify(
node,
(key, value) => {
switch (typeof value) {
case "bigint":
return String(value) + "n";
case "number":
case "boolean":
case "object":
case "string":
return value as string;
default:
return String(value);
}
},
4,
);
} catch (error) {
console.error("JSON stringify error", error);
return "Error: JSON.stringify failed";
}
}
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.83.0";
export const VERSION = "v3.85.2";
@@ -97,7 +97,7 @@ export const AutomationDetails: React.FC<AutomationDetailsProps> = ({
config: automation.action.config as {
type: "WEBHOOK";
url: string;
headers: Record<string, string>;
displayHeaders: Record<string, { secret: boolean; value: string }>;
apiVersion: Record<"prompt", "v1">;
displaySecretKey: string;
},
@@ -1,9 +1,9 @@
import { type UseFormReturn, type FieldValues } from "react-hook-form";
import {
type ActionCreate,
type ActionDomain,
type ActionType,
type AutomationDomain,
type SafeWebhookActionConfig,
} from "@langfuse/shared";
export interface BaseActionHandler<
@@ -21,9 +21,7 @@ export interface BaseActionHandler<
};
// Build the action config for API submission
buildActionConfig(
formData: TFormData,
): Omit<SafeWebhookActionConfig, "displaySecretKey">;
buildActionConfig(formData: TFormData): ActionCreate;
// Render the action form UI - using any for form to allow flexibility
renderForm(props: {
@@ -15,7 +15,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/src/components/ui/select";
import { X, Plus, RefreshCw } from "lucide-react";
import { X, Plus, RefreshCw, Lock, LockOpen } from "lucide-react";
import { useFieldArray, type UseFormReturn } from "react-hook-form";
import { z } from "zod/v4";
import {
@@ -60,6 +60,9 @@ export const webhookSchema = z.object({
},
),
value: z.string(),
displayValue: z.string(),
isSecret: z.boolean(),
wasSecret: z.boolean(),
}),
),
apiVersion: AvailableWebhookApiSchema,
@@ -100,7 +103,19 @@ export const WebhookActionForm: React.FC<WebhookActionFormProps> = ({
// Function to add a new header pair
const addHeader = () => {
appendHeader({ name: "", value: "" });
appendHeader({
name: "",
value: "",
displayValue: "",
isSecret: false,
wasSecret: false,
});
};
// Function to toggle secret status of a header
const toggleHeaderSecret = (index: number) => {
const currentValue = form.watch(`webhook.headers.${index}.isSecret`);
form.setValue(`webhook.headers.${index}.isSecret`, !currentValue);
};
return (
@@ -171,7 +186,10 @@ export const WebhookActionForm: React.FC<WebhookActionFormProps> = ({
...WebhookDefaultHeaders,
"x-langfuse-signature": `t=<timestamp>,v1=<signature>`,
}).map(([key, value]) => (
<div key={key} className="mb-2 grid grid-cols-[1fr,1fr,auto] gap-2">
<div
key={key}
className="mb-2 grid grid-cols-[1fr,1fr,auto,auto] gap-2"
>
<FormItem>
<FormControl>
<Input value={key} disabled={true} className="bg-muted/50" />
@@ -186,8 +204,6 @@ export const WebhookActionForm: React.FC<WebhookActionFormProps> = ({
/>
</FormControl>
</FormItem>
<div className="w-10" />{" "}
{/* Spacer to align with editable headers */}
</div>
))}
</div>
@@ -202,10 +218,17 @@ export const WebhookActionForm: React.FC<WebhookActionFormProps> = ({
const originalIndex = headerFields.findIndex(
(f) => f.id === field.id,
);
const isSecret = form.watch(
`webhook.headers.${originalIndex}.isSecret`,
);
const displayValue = form.watch(
`webhook.headers.${originalIndex}.displayValue`,
);
return (
<div
key={field.id}
className="mb-2 grid grid-cols-[1fr,1fr,auto] gap-2"
className="mb-2 grid grid-cols-[1fr,1fr,auto,auto] gap-2"
>
<FormField
control={form.control}
@@ -230,15 +253,34 @@ export const WebhookActionForm: React.FC<WebhookActionFormProps> = ({
<FormItem>
<FormControl>
<Input
placeholder="Value"
placeholder={
isSecret && displayValue
? displayValue
: displayValue || "Value"
}
{...field}
disabled={disabled}
type={isSecret ? "password" : "text"}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => toggleHeaderSecret(originalIndex)}
disabled={disabled}
title={isSecret ? "Make header public" : "Make header secret"}
>
{isSecret ? (
<Lock className="h-4 w-4 text-orange-500" />
) : (
<LockOpen className="h-4 w-4 text-muted-foreground" />
)}
</Button>
<Button
type="button"
variant="ghost"
@@ -425,19 +467,28 @@ export const RegenerateWebhookSecretButton = ({
// Function to convert the array of header objects to a Record for API
export const formatWebhookHeaders = (
headers: { name: string; value: string }[],
): Record<string, string> => {
const headersObject: Record<string, string> = {};
headers: {
name: string;
value: string;
displayValue: string;
isSecret: boolean;
wasSecret: boolean;
}[],
): Record<string, { secret: boolean; value: string }> => {
const requestHeaders: Record<string, { secret: boolean; value: string }> = {};
const defaultHeaderKeys = Object.keys(WebhookDefaultHeaders);
headers.forEach((header) => {
if (header.name.trim() && header.value.trim()) {
if (header.name.trim()) {
// Exclude default headers - they will be added automatically by the API
if (!defaultHeaderKeys.includes(header.name.trim().toLowerCase())) {
headersObject[header.name.trim()] = header.value.trim();
requestHeaders[header.name.trim()] = {
secret: header.isSecret || false,
value: header.value.trim(),
};
}
}
});
return headersObject;
return requestHeaders;
};
@@ -6,7 +6,7 @@ import {
type AutomationDomain,
AvailableWebhookApiSchema,
WebhookDefaultHeaders,
type SafeWebhookActionConfig,
type ActionCreate,
type ActionDomain,
} from "@langfuse/shared";
import { z } from "zod/v4";
@@ -20,6 +20,9 @@ const WebhookActionFormSchema = z.object({
z.object({
name: z.string(),
value: z.string(),
displayValue: z.string(),
isSecret: z.boolean(),
wasSecret: z.boolean(),
}),
)
.default([]),
@@ -33,6 +36,9 @@ type WebhookActionFormData = z.infer<typeof WebhookActionFormSchema>;
type HeaderPair = {
name: string;
value: string;
displayValue: string;
isSecret: boolean;
wasSecret: boolean;
};
export class WebhookActionHandler
@@ -45,14 +51,18 @@ export class WebhookActionHandler
if (
automation?.action?.type === "WEBHOOK" &&
automation?.action?.config &&
"headers" in automation.action.config &&
automation.action.config.headers
"displayHeaders" in automation.action.config &&
automation.action.config.displayHeaders
) {
try {
const headersObject = automation.action.config.headers;
return Object.entries(headersObject).map(([name, value]) => ({
const displayHeaders = automation.action.config.displayHeaders;
return Object.entries(displayHeaders).map(([name, headerObj]) => ({
name,
value: value as string,
value: headerObj.secret ? "" : headerObj.value,
displayValue: headerObj.value,
isSecret: headerObj.secret,
wasSecret: headerObj.secret,
}));
} catch (e) {
console.error("Failed to parse headers:", e);
@@ -108,9 +118,14 @@ export class WebhookActionHandler
if (!header.name.trim()) {
errors.push(`Header ${index + 1}: Name cannot be empty`);
}
if (!header.value.trim()) {
if (!header.value.trim() && !header.isSecret) {
errors.push(`Header ${index + 1}: Value cannot be empty`);
}
if (header.wasSecret !== header.isSecret && !header.value.trim()) {
errors.push(
`Header ${index + 1}: A value must be provided when making a header ${header.wasSecret ? "public" : "secret"}`,
);
}
// Check if header name conflicts with default headers
if (
@@ -123,6 +138,19 @@ export class WebhookActionHandler
}
}
});
// check if header name is already in the form
// Check for duplicate header names (case-insensitive)
const headerNames = formData.webhook.headers
.filter((h) => h.name.trim()) // Only check non-empty header names
.map((h) => h.name.trim().toLowerCase());
const uniqueHeaderNames = new Set(headerNames);
if (uniqueHeaderNames.size < headerNames.length) {
errors.push(
"Duplicate header names are not allowed (case-insensitive)",
);
}
}
return {
@@ -131,11 +159,9 @@ export class WebhookActionHandler
};
}
buildActionConfig(
formData: WebhookActionFormData,
): Omit<SafeWebhookActionConfig, "displaySecretKey"> {
// Convert headers array to object
let headersObject: Record<string, string> = {};
buildActionConfig(formData: WebhookActionFormData): ActionCreate {
// Convert headers array to requestHeaders format
let headersObject: Record<string, { secret: boolean; value: string }> = {};
if (formData.webhook?.headers) {
headersObject = formatWebhookHeaders(formData.webhook.headers);
@@ -144,7 +170,7 @@ export class WebhookActionHandler
return {
type: "WEBHOOK",
url: formData.webhook?.url || "",
headers: headersObject,
requestHeaders: headersObject,
apiVersion: formData.webhook?.apiVersion || { prompt: "v1" },
};
}
+23 -4
View File
@@ -2,10 +2,12 @@ import { createTRPCRouter } from "@/src/server/api/trpc";
import { protectedProjectProcedure } from "@/src/server/api/trpc";
import { z } from "zod/v4";
import {
type WebhookActionConfigWithSecrets,
ActionCreateSchema,
ActionType,
ActionTypeSchema,
JobConfigState,
singleFilter,
} from "@langfuse/shared";
import { throwIfNoProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { v4 } from "uuid";
@@ -17,7 +19,10 @@ import {
logger,
} from "@langfuse/shared/src/server";
import { generateWebhookSecret, encrypt } from "@langfuse/shared/encryption";
import { processWebhookActionConfig } from "./webhookHelpers";
import {
processWebhookActionConfig,
convertToSafeWebhookConfig,
} from "./webhookHelpers";
import { TRPCError } from "@trpc/server";
import { auditLog } from "@/src/features/audit-logs/auditLog";
@@ -26,7 +31,7 @@ export const CreateAutomationInputSchema = z.object({
name: z.string().min(1, "Name is required"),
eventSource: z.string(),
eventAction: z.array(z.string()),
filter: z.array(z.any()).nullable(),
filter: z.array(singleFilter).nullable(),
status: z.enum(JobConfigState).default(JobConfigState.ACTIVE),
// Action fields
actionType: z.enum(ActionType),
@@ -303,7 +308,12 @@ export const automationsRouter = createTRPCRouter({
logger.info(`Created automation ${trigger.id} for action ${action.id}`);
return {
action,
action: {
...action,
config: convertToSafeWebhookConfig(
action.config as WebhookActionConfigWithSecrets,
),
},
trigger,
automation,
webhookSecret: newUnencryptedWebhookSecret, // Return webhook secret at top level for one-time display
@@ -408,7 +418,16 @@ export const automationsRouter = createTRPCRouter({
},
});
return { action, trigger, automation };
return {
action: {
...action,
config: convertToSafeWebhookConfig(
action.config as WebhookActionConfigWithSecrets,
),
},
trigger,
automation,
};
}),
// Delete an automation (both trigger and action)
@@ -3,8 +3,19 @@ import {
encrypt,
generateWebhookSecret,
} from "@langfuse/shared/encryption";
import { type ActionCreate, type ActionConfig } from "@langfuse/shared";
import { getActionByIdWithSecrets } from "@langfuse/shared/src/server";
import {
type ActionCreate,
type ActionConfig,
type SafeWebhookActionConfig,
type WebhookActionConfigWithSecrets,
} from "@langfuse/shared";
import {
getActionByIdWithSecrets,
mergeHeaders,
createDisplayHeaders,
encryptSecretHeaders,
} from "@langfuse/shared/src/server";
import { TRPCError } from "@trpc/server";
interface WebhookConfigOptions {
actionConfig: ActionCreate;
@@ -14,9 +25,13 @@ interface WebhookConfigOptions {
/**
* Processes webhook action configuration by:
* 1. Adding default headers
* 2. Generating or preserving webhook secrets
* 3. Encrypting secrets for storage
* 1. Merging legacy headers with new requestHeaders
* 2. Handling header removal (headers not in input are removed)
* 3. Preserving existing values when empty values are submitted
* 4. Encrypting secret headers based on secret flag
* 5. Generating display values for secret headers
* 6. Generating or preserving webhook secrets
* 7. Encrypting secrets for storage
*/
export async function processWebhookActionConfig({
actionConfig,
@@ -40,24 +55,110 @@ export async function processWebhookActionConfig({
const { secretKey: newSecretKey, displaySecretKey: newDisplaySecretKey } =
generateWebhookSecret();
const finalActionConfig = {
...actionConfig,
headers: {
...actionConfig.headers,
},
secretKey: existingAction?.config.secretKey ?? encrypt(newSecretKey),
displaySecretKey:
existingAction?.config.displaySecretKey ?? newDisplaySecretKey,
};
// Process headers and generate final action config
const finalActionConfig = processWebhookHeaders(
actionConfig,
existingAction?.config as WebhookActionConfigWithSecrets | undefined,
);
return {
finalActionConfig,
finalActionConfig: {
...finalActionConfig,
secretKey: existingAction?.config.secretKey ?? encrypt(newSecretKey),
displaySecretKey:
existingAction?.config.displaySecretKey ?? newDisplaySecretKey,
},
newUnencryptedWebhookSecret: existingAction?.config.secretKey
? undefined
: newSecretKey,
};
}
/**
* Processes webhook headers by:
* 1. Merging legacy headers with new requestHeaders
* 2. Handling header removal (headers not in input are removed)
* 3. Preserving existing values when empty values are submitted
* 4. Encrypting secret headers based on secret flag
* 5. Generating display values for secret headers
*/
function processWebhookHeaders(
actionConfig: ActionCreate,
existingConfig: WebhookActionConfigWithSecrets | undefined,
): ActionConfig {
// Get existing headers for comparison
const existingLegacyHeaders = existingConfig?.headers ?? {}; // legacy headers
const existingRequestHeaders = existingConfig?.requestHeaders ?? {}; // new headers
const mergedExistingHeaders = mergeHeaders(
existingLegacyHeaders,
existingRequestHeaders,
);
// Process new headers from input
const inputRequestHeaders = actionConfig.requestHeaders || {};
// Start with empty headers - only include what's in the input
const finalRequestHeaders: Record<
string,
{ secret: boolean; value: string }
> = {};
// If no headers are provided in input, preserve all existing headers
// This allows URL-only updates without requiring all headers to be resent
if (Object.keys(inputRequestHeaders).length === 0) {
for (const [key, headerObj] of Object.entries(mergedExistingHeaders)) {
finalRequestHeaders[key] = headerObj;
}
} else {
// Process each header from input
for (const [key, headerObj] of Object.entries(inputRequestHeaders)) {
const existingHeader = mergedExistingHeaders[key];
// Validate secret toggle: can only change secret status when providing a value
if (
headerObj.secret &&
headerObj.value.trim() === "" &&
!existingHeader
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Header "${key}" cannot be made secret without providing a value`,
});
}
// If changing secret status, ensure a value is provided
if (
existingHeader &&
headerObj.secret !== existingHeader.secret &&
headerObj.value.trim() === ""
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Header "${key}" secret status can only be changed when providing a value`,
});
}
// If value is empty, preserve existing value if it exists
if (headerObj.value.trim() === "" && existingHeader) {
finalRequestHeaders[key] = existingHeader;
} else if (headerObj.value.trim() !== "") {
// Only process non-empty values
finalRequestHeaders[key] = headerObj;
}
// If value is empty and no existing header, skip it (effectively removing it)
}
}
return {
...actionConfig,
headers: {}, // remove legacy headers on write
requestHeaders: encryptSecretHeaders(finalRequestHeaders),
displayHeaders: createDisplayHeaders(finalRequestHeaders),
secretKey: "", // will be overwritten by the caller
displaySecretKey: "", // will be overwritten by the caller
};
}
/**
* Extracts webhook secret for one-time display after creation
*/
@@ -75,3 +176,18 @@ export function extractWebhookSecret(
return undefined;
}
}
/**
* Converts webhook config with secrets to safe config by only including allowed fields
*/
export function convertToSafeWebhookConfig(
webhookConfig: WebhookActionConfigWithSecrets,
): SafeWebhookActionConfig {
return {
type: webhookConfig.type,
url: webhookConfig.url,
displayHeaders: webhookConfig.displayHeaders,
apiVersion: webhookConfig.apiVersion,
displaySecretKey: webhookConfig.displaySecretKey,
};
}
@@ -90,7 +90,7 @@ export const DatasetActionButton = (props: DatasetActionButtonProps) => {
) : (
<LockIcon className="mr-2 h-4 w-4" aria-hidden="true" />
)}
Rename
Edit
</Button>
)
) : props.mode === "delete" ? (
@@ -63,11 +63,13 @@ export function DatasetRunItemsTable(
pageIndex: withDefault(NumberParam, 0),
pageSize: withDefault(NumberParam, 20),
});
const runItems = api.datasets.runitemsByRunIdOrItemId.useQuery({
...props,
page: paginationState.pageIndex,
limit: paginationState.pageSize,
});
const [rowHeight, setRowHeight] = useRowHeightLocalStorage("traces", "m");
useEffect(() => {
@@ -23,9 +23,9 @@ import {
getRunItemsByRunIdOrItemId,
} from "@/src/features/datasets/server/service";
import {
getDatasetRunItemsTableCount,
logger,
getRunScoresGroupedByNameSourceType,
getDatasetRunItemsTableCountPg,
} from "@langfuse/shared/src/server";
import { createId as createCuid } from "@paralleldrive/cuid2";
import { composeAggregateScoreKey } from "@/src/features/scores/lib/aggregateScores";
@@ -203,7 +203,7 @@ export const datasetRouter = createTRPCRouter({
}),
)
.query(async ({ input }) => {
const count = await getDatasetRunItemsTableCount({
const count = await getDatasetRunItemsTableCountPg({
projectId: input.projectId,
filter: input.filter ?? [],
});
@@ -325,7 +325,7 @@ export const datasetRouter = createTRPCRouter({
}),
)
.query(async ({ input, ctx }) => {
return fetchDatasetItems({
return await fetchDatasetItems({
projectId: input.projectId,
datasetId: input.datasetId,
limit: input.limit,
@@ -527,6 +527,7 @@ export const datasetRouter = createTRPCRouter({
},
},
});
await auditLog({
session: ctx.session,
resourceType: "dataset",
@@ -536,6 +537,7 @@ export const datasetRouter = createTRPCRouter({
});
return deletedDataset;
}),
deleteDatasetItem: protectedProjectProcedure
.input(
z.object({
@@ -811,7 +813,6 @@ export const datasetRouter = createTRPCRouter({
return;
}),
runitemsByRunIdOrItemId: protectedProjectProcedure
.input(
z
@@ -850,34 +851,33 @@ export const datasetRouter = createTRPCRouter({
datasetRunName: string;
}>
>`
SELECT
di.id AS "datasetItemId",
di.created_at AS "datasetItemCreatedAt",
dri.id,
dri.trace_id AS "traceId",
dri.observation_id AS "observationId",
dri.created_at AS "createdAt",
dri.updated_at AS "updatedAt",
dri.project_id AS "projectId",
dri.dataset_run_id AS "datasetRunId",
dr.name AS "datasetRunName"
FROM dataset_run_items dri
INNER JOIN dataset_items di
ON dri.dataset_item_id = di.id
AND dri.project_id = di.project_id
INNER JOIN dataset_runs dr
ON dri.dataset_run_id = dr.id
AND dri.project_id = dr.project_id
WHERE
dri.project_id = ${input.projectId}
${filterQuery}
ORDER BY
di.created_at DESC,
di.id DESC
LIMIT ${input.limit}
OFFSET ${input.page * input.limit}
`;
SELECT
di.id AS "datasetItemId",
di.created_at AS "datasetItemCreatedAt",
dri.id,
dri.trace_id AS "traceId",
dri.observation_id AS "observationId",
dri.created_at AS "createdAt",
dri.updated_at AS "updatedAt",
dri.project_id AS "projectId",
dri.dataset_run_id AS "datasetRunId",
dr.name AS "datasetRunName"
FROM dataset_run_items dri
INNER JOIN dataset_items di
ON dri.dataset_item_id = di.id
AND dri.project_id = di.project_id
INNER JOIN dataset_runs dr
ON dri.dataset_run_id = dr.id
AND dri.project_id = dr.project_id
WHERE
dri.project_id = ${input.projectId}
${filterQuery}
ORDER BY
di.created_at DESC,
di.id DESC
LIMIT ${input.limit}
OFFSET ${input.page * input.limit}
`;
if (runItems.length === 0) return { totalRunItems: 0, runItems: [] };
const totalRunItems = await ctx.prisma.datasetRunItems.count({
@@ -902,7 +902,6 @@ export const datasetRouter = createTRPCRouter({
...ri,
datasetRunName: runItemNameMap[ri.id],
}));
// Note: We early return in case of no run items, when adding parameters here, make sure to update the early return above
return {
totalRunItems,
+4 -6
View File
@@ -146,7 +146,7 @@ export const createDatasetRunsTable = async (input: DatasetRunsTableInput) => {
}
};
export const insertPostgresDatasetRunsIntoClickhouse = async (
const insertPostgresDatasetRunsIntoClickhouse = async (
runs: PostgresDatasetRun[],
tableName: string,
projectId: string,
@@ -175,7 +175,7 @@ export const insertPostgresDatasetRunsIntoClickhouse = async (
});
};
export const createTempTableInClickhouse = async (tableName: string) => {
const createTempTableInClickhouse = async (tableName: string) => {
const query = `
CREATE TABLE IF NOT EXISTS ${tableName} ${env.CLICKHOUSE_CLUSTER_ENABLED === "true" ? "ON CLUSTER " + env.CLICKHOUSE_CLUSTER_NAME : ""}
(
@@ -196,7 +196,7 @@ export const createTempTableInClickhouse = async (tableName: string) => {
});
};
export const deleteTempTableInClickhouse = async (tableName: string) => {
const deleteTempTableInClickhouse = async (tableName: string) => {
const query = `
DROP TABLE IF EXISTS ${tableName} ${env.CLICKHOUSE_CLUSTER_ENABLED === "true" ? "ON CLUSTER " + env.CLICKHOUSE_CLUSTER_NAME : ""}
`;
@@ -207,9 +207,7 @@ export const deleteTempTableInClickhouse = async (tableName: string) => {
});
};
export const getDatasetRunsFromPostgres = async (
input: DatasetRunsTableInput,
) => {
const getDatasetRunsFromPostgres = async (input: DatasetRunsTableInput) => {
return await prisma.$queryRaw<PostgresDatasetRun[]>(
Prisma.sql`
SELECT
@@ -11,6 +11,7 @@ const entitlements = [
"trace-deletion", // Not in use anymore, but necessary to use the TableAction type.
"audit-logs",
"data-retention",
"scheduled-blob-exports",
"prompt-protected-labels",
"admin-api",
] as const;
@@ -21,7 +22,10 @@ const cloudAllPlansEntitlements: Entitlement[] = [
"trace-deletion",
];
const selfHostedAllPlansEntitlements: Entitlement[] = ["trace-deletion"];
const selfHostedAllPlansEntitlements: Entitlement[] = [
"trace-deletion",
"scheduled-blob-exports",
];
// Entitlement Limits: Limits on the number of resources that can be created/used
const entitlementLimits = [
@@ -85,6 +89,7 @@ export const entitlementAccess: Record<
"cloud-multi-tenant-sso",
"prompt-protected-labels",
"admin-api",
"scheduled-blob-exports",
],
entitlementLimits: {
"annotation-queue-count": false,
@@ -103,6 +108,7 @@ export const entitlementAccess: Record<
"cloud-multi-tenant-sso",
"prompt-protected-labels",
"admin-api",
"scheduled-blob-exports",
],
entitlementLimits: {
"annotation-queue-count": false,
@@ -21,7 +21,7 @@ import {
import { Separator } from "@/src/components/ui/separator";
import { type FilterOption } from "@langfuse/shared";
import { Input } from "@/src/components/ui/input";
import { useRef, useState } from "react";
import { useRef, useState, useMemo, useCallback } from "react";
import { PropertyHoverCard } from "@/src/features/widgets/components/WidgetPropertySelectItem";
const getFreeTextInput = (
@@ -54,7 +54,7 @@ export function MultiSelect({
isCustomSelectEnabled?: boolean;
labelTruncateCutOff?: number;
}) {
const selectedValues = new Set(values);
const selectedValues = useMemo(() => new Set(values), [values]);
const optionValues = new Set(options.map((option) => option.value));
const freeTextInput = getFreeTextInput(
isCustomSelectEnabled,
@@ -63,6 +63,35 @@ export function MultiSelect({
);
const [freeText, setFreeText] = useState(freeTextInput || "");
const selectableOptions = useMemo(
() => options.filter((option) => option.value.length > 0),
[options],
);
const allSelectedState = useMemo(() => {
if (selectableOptions.length === 0) return false;
return selectableOptions.every((option) =>
selectedValues.has(option.value),
);
}, [selectableOptions, selectedValues]);
const handleSelectAll = useCallback(() => {
const newSelectedValues = new Set(selectedValues);
if (allSelectedState) {
// Deselect all selectable options
selectableOptions.forEach((option) =>
newSelectedValues.delete(option.value),
);
} else {
// Select all selectable options
selectableOptions.forEach((option) =>
newSelectedValues.add(option.value),
);
}
const filterValues = Array.from(newSelectedValues);
onValueChange(filterValues.length ? filterValues : []);
}, [allSelectedState, selectableOptions, selectedValues, onValueChange]);
const debounceTimeout = useRef<NodeJS.Timeout | null>(null);
const handleDebouncedChange = (value: string) => {
const freeTextInput = getFreeTextInput(
@@ -150,6 +179,26 @@ export function MultiSelect({
<InputCommandEmpty>No results found.</InputCommandEmpty>
)}
<InputCommandGroup>
{selectableOptions.length > 0 && (
<>
<InputCommandItem key="select-all" onSelect={handleSelectAll}>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
allSelectedState
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible",
)}
>
<Check className={cn("h-4 w-4")} />
</div>
<div className="font-medium">
{allSelectedState ? "Deselect All" : "Select All"}
</div>
</InputCommandItem>
<InputCommandSeparator />
</>
)}
{options.map((option) => {
if (option.value.length === 0) return;
const isSelected = selectedValues.has(option.value);
@@ -435,7 +435,6 @@ export class OtelIngestionProcessor {
isLangfuseSDKSpans,
isRootSpan,
hasTraceUpdates,
parentObservationId,
span,
} = params;
@@ -447,14 +446,12 @@ export class OtelIngestionProcessor {
};
// Create full trace for root spans or spans with trace updates
if (isRootSpan || hasTraceUpdates) {
if (isRootSpan) {
trace = {
...trace,
name:
(attributes[LangfuseOtelSpanAttributes.TRACE_NAME] as string) ??
(!parentObservationId
? this.extractName(span.name, attributes)
: undefined),
this.extractName(span.name, attributes),
metadata: {
...resourceAttributeMetadata,
...this.extractMetadata(attributes, "trace"),
@@ -477,17 +474,47 @@ export class OtelIngestionProcessor {
null,
userId: this.extractUserId(attributes),
sessionId: this.extractSessionId(attributes),
public:
attributes?.[LangfuseOtelSpanAttributes.TRACE_PUBLIC] === true ||
attributes?.[LangfuseOtelSpanAttributes.TRACE_PUBLIC] === "true" ||
attributes?.["langfuse.public"] === true ||
attributes?.["langfuse.public"] === "true",
public: this.isTracePublic(attributes),
tags: this.extractTags(attributes),
environment: this.extractEnvironment(attributes, resourceAttributes),
...this.extractInputAndOutput(span?.events ?? [], attributes, "trace"),
};
}
if (hasTraceUpdates && !isRootSpan) {
trace = {
...trace,
name: attributes[LangfuseOtelSpanAttributes.TRACE_NAME] as string,
metadata: {
...resourceAttributeMetadata,
...this.extractMetadata(attributes, "trace"),
...(isLangfuseSDKSpans
? {}
: { attributes: spanAttributesInMetadata }),
resourceAttributes,
scope: {
...(scopeSpan.scope || {}),
attributes: scopeAttributes,
},
} as Record<string, string | Record<string, string | number>>,
version:
(attributes?.[LangfuseOtelSpanAttributes.VERSION] as string) ??
resourceAttributes?.["service.version"] ??
null,
release:
(attributes?.[LangfuseOtelSpanAttributes.RELEASE] as string) ??
resourceAttributes?.[LangfuseOtelSpanAttributes.RELEASE] ??
null,
userId: this.extractUserId(attributes),
sessionId: this.extractSessionId(attributes),
public: this.isTracePublic(attributes),
tags: this.extractTags(attributes),
environment: this.extractEnvironment(attributes, resourceAttributes),
input: attributes[LangfuseOtelSpanAttributes.TRACE_INPUT],
output: attributes[LangfuseOtelSpanAttributes.TRACE_OUTPUT],
};
}
if (isRootSpan) {
this.traceEventCounts.rootSpanClosed += 1;
} else if (hasTraceUpdates) {
@@ -504,6 +531,18 @@ export class OtelIngestionProcessor {
};
}
private isTracePublic(
attributes?: Record<string, unknown>,
): boolean | undefined {
const value =
attributes?.[LangfuseOtelSpanAttributes.TRACE_PUBLIC] ??
attributes?.["langfuse.public"];
if (value == null) return;
return value === true || value === "true" ? true : false;
}
private createObservationEvent(
params: CreateObservationEventParams,
): IngestionEventType {
@@ -0,0 +1,100 @@
import React, { useState } from "react";
import { Button } from "@/src/components/ui/button";
import { Badge } from "@/src/components/ui/badge";
import { ChevronRight, ChevronDown } from "lucide-react";
import { cn } from "@/src/utils/tailwind";
interface CollapsibleSectionProps {
title: string;
badge?: string;
count?: number;
defaultExpanded?: boolean;
actionButton?: React.ReactNode;
children: React.ReactNode;
isEmpty?: boolean;
emptyMessage?: string;
className?: string;
summaryContent?: string | React.ReactNode;
}
export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
title,
badge,
count,
defaultExpanded = false,
actionButton,
children,
isEmpty = false,
emptyMessage,
className,
summaryContent,
}) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const toggleExpanded = () => {
setIsExpanded(!isExpanded);
};
const hasContent = count !== undefined ? count > 0 : !isEmpty;
return (
<div className={cn("space-y-1", className)}>
{/* Header */}
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
onClick={toggleExpanded}
className="h-8 gap-2 px-1 hover:bg-transparent"
>
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
<span className="text-sm font-semibold">{title}</span>
{badge && (
<Badge variant="secondary" className="h-4 text-xs">
{badge}
</Badge>
)}
{count !== undefined && count > 0 && (
<Badge variant="outline" className="h-4 text-xs">
{count}
</Badge>
)}
</Button>
{actionButton && (
<div className="flex items-center gap-1">{actionButton}</div>
)}
</div>
{/* Content */}
{isExpanded && (
<div className="space-y-2">
{hasContent ? (
children
) : (
<div className="px-2 py-1">
<p className="text-xs text-muted-foreground">
{emptyMessage || "No items configured."}
</p>
</div>
)}
</div>
)}
{/* Compact summary when collapsed */}
{!isExpanded && (hasContent || summaryContent) && (
<div className="px-2 py-0.5">
<p className="text-xs text-muted-foreground">
{summaryContent ||
(count !== undefined && count > 0
? `${count} item${count === 1 ? "" : "s"} configured`
: "Configured")}
</p>
</div>
)}
</div>
);
};
@@ -0,0 +1,185 @@
import React from "react";
import { Button } from "@/src/components/ui/button";
import { Badge } from "@/src/components/ui/badge";
import { ChevronDown, Wrench, Braces, Variable } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/src/components/ui/popover";
import { usePlaygroundContext } from "../context";
import { usePlaygroundWindowSize } from "../hooks/usePlaygroundWindowSize";
import { PlaygroundTools, PlaygroundToolsPopover } from "./PlaygroundTools";
import {
StructuredOutputSchemaSection,
StructuredOutputSchemaPopover,
} from "./StructuredOutputSchemaSection";
import { Variables } from "./Variables";
import { MessagePlaceholders } from "./MessagePlaceholders";
export const ConfigurationDropdowns: React.FC = () => {
const { containerRef, isVeryCompact, isCompact } = usePlaygroundWindowSize();
const {
tools,
structuredOutputSchema,
promptVariables,
messagePlaceholders,
} = usePlaygroundContext();
const toolsCount = tools.length;
const hasSchema = structuredOutputSchema ? 1 : 0;
const variablesCount = promptVariables.length + messagePlaceholders.length;
// Helper function to get responsive content (text or icon)
const getResponsiveContent = (
fullText: string,
IconComponent: React.ComponentType<{ className?: string }>,
abbreviation?: string,
) => {
if (isVeryCompact) {
return <IconComponent className="h-3 w-3" />;
}
if (isCompact) {
return (
<>
<IconComponent className="h-3 w-3" />
<span className="text-sm">{abbreviation ?? fullText}</span>
</>
);
}
return (
<>
<IconComponent className="h-3 w-3" />
<span className="text-sm">{fullText}</span>
</>
);
};
return (
<div
ref={containerRef}
className="flex-shrink-0 border-b bg-muted/25 px-3 py-2"
>
<div className="flex items-center justify-start gap-2">
{/* Tools Dropdown */}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 gap-2">
{getResponsiveContent("Tools", Wrench)}
{toolsCount > 0 && (
<Badge variant="secondary" className="h-4 text-xs">
{toolsCount}
</Badge>
)}
<ChevronDown className="h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-4" align="start">
<div className="mb-3">
<h4 className="mb-1 text-sm font-medium">Tools</h4>
<p className="text-xs text-muted-foreground">
Configure tools for your model to use.
</p>
</div>
{toolsCount > 0 ? (
<div className="mb-3">
<PlaygroundTools />
</div>
) : (
<div className="mb-3">
<p className="text-xs text-muted-foreground">
No tools attached.
</p>
</div>
)}
<div className="border-t pt-3">
<PlaygroundToolsPopover />
</div>
</PopoverContent>
</Popover>
{/* Structured Output Dropdown */}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 gap-2">
{getResponsiveContent("Schema", Braces)}
{hasSchema > 0 && (
<Badge variant="secondary" className="h-4 text-xs">
{hasSchema}
</Badge>
)}
<ChevronDown className="h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-4" align="start">
<div className="mb-3">
<h4 className="mb-1 text-sm font-medium">Structured Output</h4>
<p className="text-xs text-muted-foreground">
Configure JSON schema for structured output.
</p>
</div>
{structuredOutputSchema ? (
<div className="mb-3">
<StructuredOutputSchemaSection />
</div>
) : (
<div className="mb-3">
<p className="text-xs text-muted-foreground">
No schema provided.
</p>
</div>
)}
<div className="border-t pt-3">
<StructuredOutputSchemaPopover />
</div>
</PopoverContent>
</Popover>
{/* Variables & Placeholders Dropdown */}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 gap-2">
{getResponsiveContent("Variables", Variable, "Vars")}
{variablesCount > 0 && (
<Badge variant="secondary" className="h-4 text-xs">
{variablesCount}
</Badge>
)}
<ChevronDown className="h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-4" align="start">
<div className="mb-3">
<h4 className="mb-1 text-sm font-medium">
Variables & Message Placeholders
</h4>
<p className="text-xs text-muted-foreground">
Configure variables and message placeholders for your prompts.
</p>
</div>
{variablesCount > 0 ? (
<div className="mb-3 space-y-4">
<div>
<h5 className="mb-2 text-xs font-medium">Variables</h5>
<Variables />
</div>
<div>
<h5 className="mb-2 text-xs font-medium">
Message Placeholders
</h5>
<MessagePlaceholders />
</div>
</div>
) : (
<div className="mb-3">
<p className="text-xs text-muted-foreground">
No variables or message placeholders defined.
</p>
</div>
)}
</PopoverContent>
</Popover>
</div>
</div>
);
};
@@ -1,11 +1,18 @@
import { Terminal } from "lucide-react";
import { Terminal, ChevronDown } from "lucide-react";
import { useEffect, useState, useMemo } from "react";
import { useRouter } from "next/router";
import { z } from "zod/v4";
import { v4 as uuidv4 } from "uuid";
import { createEmptyMessage } from "@/src/components/ChatMessages/utils/createEmptyMessage";
import { Button } from "@/src/components/ui/button";
import usePlaygroundCache from "@/src/features/playground/page/hooks/usePlaygroundCache";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/src/components/ui/dropdown-menu";
import { usePersistedWindowIds } from "@/src/features/playground/page/hooks/usePersistedWindowIds";
import {
type PlaygroundCache,
type PlaygroundSchema,
@@ -38,6 +45,7 @@ import {
} from "@/src/features/trace-graph-view/types";
import { api } from "@/src/utils/api";
import { cn } from "@/src/utils/tailwind";
import usePlaygroundCache from "@/src/features/playground/page/hooks/usePlaygroundCache";
type JumpToPlaygroundButtonProps = (
| {
@@ -65,10 +73,21 @@ export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
const router = useRouter();
const capture = usePostHogClientCapture();
const projectId = useProjectIdFromURL();
const { setPlaygroundCache } = usePlaygroundCache();
const { addWindowWithId, clearAllCache } = usePersistedWindowIds();
const [capturedState, setCapturedState] = useState<PlaygroundCache>(null);
const [isAvailable, setIsAvailable] = useState<boolean>(false);
// Generate a stable window ID based on the source data
const stableWindowId = useMemo(() => {
if (props.source === "prompt") {
return `playground-prompt-${props.prompt.id}`;
} else if (props.source === "generation") {
return `playground-generation-${props.generation.id}`;
}
return `playground-${uuidv4()}`;
}, [props]);
const { setPlaygroundCache } = usePlaygroundCache(stableWindowId);
const apiKeys = api.llmApiKey.all.useQuery(
{
projectId: projectId as string,
@@ -112,35 +131,84 @@ export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
}
}, [capturedState, setIsAvailable]);
const handleClick = () => {
capture(props.analyticsEventName);
setPlaygroundCache(capturedState);
const handlePlaygroundAction = (useFreshPlayground: boolean) => {
capture(props.analyticsEventName, {
playgroundMode: useFreshPlayground ? "fresh" : "add_to_existing",
});
router.push(`/project/${projectId}/playground`);
// First, ensure we have state to save
if (!capturedState) {
console.warn("No captured state available for playground");
return;
}
if (useFreshPlayground) {
// Clear all existing playground data and reset to single window
clearAllCache(stableWindowId);
} else {
// Add to existing playground
const addedWindowId = addWindowWithId(stableWindowId);
if (!addedWindowId) {
console.warn(
"Failed to add window to existing playground, maximum windows reached",
);
return;
}
}
// Use requestAnimationFrame to ensure the state update has been processed
requestAnimationFrame(() => {
try {
setPlaygroundCache(capturedState);
console.log(
`Cache saved for existing playground window ${stableWindowId}`,
);
// Navigate after cache is successfully saved
router.push(`/project/${projectId}/playground`);
} catch (error) {
console.error("Failed to save playground cache:", error);
// Navigate anyway, but user might not see their data
router.push(`/project/${projectId}/playground`);
}
});
};
const tooltipMessage = isAvailable
? "Test in LLM playground"
: "Test in LLM playground is not available since messages are not in valid ChatML format or tool calls have been used. If you think this is not correct, please open a GitHub issue.";
return (
<Button
variant={props.variant ?? "secondary"}
disabled={!isAvailable}
title={
isAvailable
? "Test in LLM playground"
: "Test in LLM playground is not available since messages are not in valid ChatML format or tool calls have been used. If you think this is not correct, please open a GitHub issue."
}
onClick={handleClick}
asChild
className={
!isAvailable ? "cursor-not-allowed opacity-50" : "cursor-pointer"
}
>
<span>
<Terminal className="h-4 w-4" />
<span className={cn("hidden md:ml-2 md:inline", props.className)}>
Playground
</span>
</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant={props.variant ?? "secondary"}
disabled={!isAvailable}
title={tooltipMessage}
className={cn(
"flex items-center gap-1",
!isAvailable ? "cursor-not-allowed opacity-50" : "cursor-pointer",
)}
>
<Terminal className="h-4 w-4" />
<span className={cn("hidden md:inline", props.className)}>
Playground
</span>
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handlePlaygroundAction(true)}>
<Terminal className="mr-2 h-4 w-4" />
Fresh playground
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handlePlaygroundAction(false)}>
<Terminal className="mr-2 h-4 w-4" />
Add to existing
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -8,38 +8,33 @@ export const MessagePlaceholders = () => {
return (
<div className="flex h-full flex-col">
<p className="font-semibold">Message Placeholders</p>
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
{messagePlaceholders.length === 0 ? (
<div className="mt-4 text-xs">
<p className="mb-2">No message placeholders defined.</p>
<p>
Placeholders can be used to e.g. inject message histories into
prompts.
</p>
</div>
) : (
<>
{messagePlaceholders
.slice()
.sort((a, b) => {
if (a.isUsed && !b.isUsed) return -1;
if (!a.isUsed && b.isUsed) return 1;
return a.name.localeCompare(b.name);
})
.map((placeholder, index) => (
<div key={placeholder.name}>
<MessagePlaceholderComponent
messagePlaceholder={placeholder}
/>
{index !== messagePlaceholders.length - 1 && (
<Divider className="my-2 text-muted-foreground" />
)}
</div>
))}
</>
)}
</div>
{messagePlaceholders.length === 0 ? (
<div className="text-xs">
<p className="mb-2">No message placeholders defined.</p>
<p>
Placeholders can be used to e.g. inject message histories into
prompts.
</p>
</div>
) : (
<div className="h-full overflow-auto">
{messagePlaceholders
.slice()
.sort((a, b) => {
if (a.isUsed && !b.isUsed) return -1;
if (!a.isUsed && b.isUsed) return 1;
return a.name.localeCompare(b.name);
})
.map((placeholder, index) => (
<div key={placeholder.name}>
<MessagePlaceholderComponent messagePlaceholder={placeholder} />
{index !== messagePlaceholders.length - 1 && (
<Divider className="my-2 text-muted-foreground" />
)}
</div>
))}
</div>
)}
</div>
);
};
@@ -10,7 +10,6 @@ import { Switch } from "@/src/components/ui/switch";
import { Settings } from "lucide-react";
import useLocalStorage from "@/src/components/useLocalStorage";
import { env } from "@/src/env.mjs";
import useCommandEnter from "@/src/features/playground/page/hooks/useCommandEnter";
import { GenerationOutput } from "./GenerationOutput";
import { ChatMessages } from "@/src/components/ChatMessages";
@@ -30,7 +29,7 @@ export const Messages: React.FC<MessagesContext> = (props) => {
</ResizablePanel>
<ResizableHandle withHandle className="bg-transparent" />
<ResizablePanel
minSize={10}
minSize={20}
defaultSize={20}
className="flex flex-col space-y-4"
>
@@ -51,11 +50,6 @@ const SubmitButton = () => {
defaultStreamingEnabled,
);
// Handle command+enter with streaming preference
useCommandEnter(!isStreaming, async () => {
await handleSubmit(streamingEnabled);
});
return (
<div className="flex items-center gap-2">
<Button
@@ -65,7 +59,7 @@ const SubmitButton = () => {
}}
loading={isStreaming}
>
<p>Submit (Ctrl + Enter)</p>
<p>Submit</p>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -0,0 +1,198 @@
import React, { useMemo, useCallback } from "react";
import { PlaygroundProvider } from "../context";
import { SaveToPromptButton } from "./SaveToPromptButton";
import { Button } from "@/src/components/ui/button";
import { Copy, X } from "lucide-react";
import { MULTI_WINDOW_CONFIG, type MultiWindowState } from "../types";
import { ModelParameters } from "@/src/components/ModelParameters";
import { usePlaygroundContext } from "../context";
import { Messages } from "@/src/features/playground/page/components/Messages";
import { ConfigurationDropdowns } from "@/src/features/playground/page/components/ConfigurationDropdowns";
/**
* MultiWindowPlayground Component
*
* Container component that renders multiple playground windows for side-by-side
* prompt comparison and testing. Receives window state and management functions
* from the parent page component.
*
* Key Features:
* - Responsive layout with horizontal scrolling
* - Window-specific state isolation
* - Equal-width distribution with minimum width constraints
* - Individual window controls (save, copy, close)
* - Window state copying for rapid iteration
*
* Architecture:
* - Receives window state from parent (page-level management)
* - Each window gets its own PlaygroundProvider with unique windowId
* - State copying handled by parent component through hooks
* - Clean separation of concerns with parent handling global actions
*/
interface MultiWindowPlaygroundProps {
windowState: MultiWindowState;
onRemoveWindow: (windowId: string) => void;
onAddWindow: (sourceWindowId?: string) => void;
}
export default function MultiWindowPlayground({
windowState,
onRemoveWindow,
onAddWindow,
}: MultiWindowPlaygroundProps) {
/**
* Calculate responsive window width based on screen size and window count
* Ensures minimum width while distributing available space equally
*/
const windowWidth = useMemo(() => {
const minWidth = MULTI_WINDOW_CONFIG.MIN_WINDOW_WIDTH;
const windowCount = windowState.windowIds.length;
// Calculate ideal width: 100% divided by number of windows
const idealWidth = `${100 / windowCount}%`;
// Use CSS minmax to ensure minimum width is respected
return `minmax(${minWidth}px, ${idealWidth})`;
}, [windowState.windowIds.length]);
/**
* Handle copying a specific window to create a new window
* This is called when the individual window "Copy" button is clicked
*
* @param sourceWindowId - The window ID to copy from
*/
const handleCopyWindow = useCallback(
(sourceWindowId: string) => {
onAddWindow(sourceWindowId);
},
[onAddWindow],
);
const firstWindowId = windowState.windowIds[0];
if (!firstWindowId) return null;
return (
<>
{/* Mobile layout: single window only - visible below md breakpoint */}
<div className="block h-full p-4 md:hidden">
<PlaygroundProvider windowId={firstWindowId}>
<PlaygroundWindowContent
windowId={firstWindowId}
onRemove={onRemoveWindow}
onCopy={handleCopyWindow}
canRemove={false}
isMobile={true}
/>
</PlaygroundProvider>
</div>
{/* Desktop layout: multi-window with horizontal grid - visible at md breakpoint and above */}
<div className="hidden h-full md:block">
<div
className="h-full overflow-x-auto"
style={{
display: "grid",
gridAutoFlow: "column",
gridAutoColumns: windowWidth,
gap: "1rem",
padding: "1rem",
scrollBehavior: "smooth",
}}
>
{windowState.windowIds.map((windowId) => (
<PlaygroundProvider key={windowId} windowId={windowId}>
<PlaygroundWindowContent
windowId={windowId}
onRemove={onRemoveWindow}
onCopy={handleCopyWindow}
canRemove={windowState.windowIds.length > 1}
isMobile={false}
/>
</PlaygroundProvider>
))}
</div>
</div>
</>
);
}
/**
* Inner component that has access to the PlaygroundProvider context
*/
function PlaygroundWindowContent({
windowId,
onRemove,
onCopy,
canRemove,
isMobile,
}: {
windowId: string;
onRemove: (windowId: string) => void;
onCopy: (windowId: string) => void;
canRemove: boolean;
windowCount?: number;
isMobile?: boolean;
}) {
const playgroundContext = usePlaygroundContext();
const handleRemove = useCallback(() => {
onRemove(windowId);
}, [windowId, onRemove]);
const handleCopy = useCallback(() => {
onCopy(windowId);
}, [windowId, onCopy]);
return (
<div className="playground-window flex h-full min-w-0 flex-col rounded-lg border bg-background shadow-sm">
{/* Window Header */}
<div className="relative flex-shrink-0 border-b bg-muted/50 px-3 py-1">
<div className="flex items-center pr-24">
<div className="flex items-center gap-2">
<ModelParameters {...playgroundContext} layout="compact" />
</div>
<div className="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-2">
<SaveToPromptButton />
{/* Hide copy button on mobile */}
{!isMobile && (
<Button
variant="ghost"
onClick={handleCopy}
className="h-6 w-6 p-0 hover:bg-muted"
title="Duplicate window configuration"
>
<Copy size={14} />
<span className="sr-only">Copy window</span>
</Button>
)}
{canRemove && (
<Button
variant="ghost"
onClick={handleRemove}
className="h-6 w-6 p-0 hover:bg-destructive/10 hover:text-destructive"
title="Remove window"
>
<X size={14} />
<span className="sr-only">Remove window</span>
</Button>
)}
</div>
</div>
</div>
{/* Window Content */}
<div className="flex-1 overflow-hidden">
<div className="flex h-full flex-col">
<ConfigurationDropdowns />
<div className="flex-1 overflow-auto p-4">
<Messages {...playgroundContext} />
</div>
</div>
</div>
</div>
);
}
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect } from "react";
import { usePlaygroundContext } from "@/src/features/playground/page/context";
import { Button } from "@/src/components/ui/button";
@@ -17,18 +17,128 @@ import {
CommandList,
CommandSeparator,
} from "@/src/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/src/components/ui/popover";
import { type PlaygroundTool } from "@/src/features/playground/page/types";
// Popover content component for use in CollapsibleSection action buttons
export const PlaygroundToolsPopover = () => {
const { setTools } = usePlaygroundContext();
const projectId = useProjectIdFromURL();
const { data: savedTools = [] } = api.llmTools.getAll.useQuery(
{
projectId: projectId as string,
},
{
enabled: Boolean(projectId),
staleTime: 1000 * 60 * 5, // 5 minutes
},
);
const handleSelectTool = (selectedLLMTool: LlmTool) => {
setTools((prev: PlaygroundTool[]) => {
let existingToolIndex = -1;
existingToolIndex = prev.findIndex((t) => t.id === selectedLLMTool.id);
if (existingToolIndex === -1) {
const unsavedToolIndexWithSameName = prev.findIndex(
(t) => t.name === selectedLLMTool.name,
);
if (unsavedToolIndexWithSameName !== -1) {
existingToolIndex = unsavedToolIndexWithSameName;
}
}
const newTool: PlaygroundTool = {
id: selectedLLMTool.id,
name: selectedLLMTool.name,
description: selectedLLMTool.description,
parameters: selectedLLMTool.parameters as Record<string, unknown>,
existingLlmTool: selectedLLMTool,
};
if (existingToolIndex !== -1) {
const newTools = [...prev];
newTools[existingToolIndex] = newTool;
return newTools;
}
return [...prev, newTool];
});
};
const handleRemoveTool = (toolId: string) => {
setTools(
(prev: PlaygroundTool[]) =>
prev.filter((t) => !(t.id === toolId)) as PlaygroundTool[],
);
};
return (
<Command className="flex flex-col">
<CommandInput
placeholder="Search tools..."
className="h-8 border-none p-1 focus:ring-0 focus:ring-offset-0"
/>
<CommandList className="max-h-[300px] overflow-y-auto">
<CommandEmpty>No tools found.</CommandEmpty>
<CommandGroup>
{savedTools.map((tool) => (
<CommandItem
key={tool.id}
value={tool.name}
onSelect={() => handleSelectTool(tool)}
className="flex items-center justify-between px-1 py-2"
>
<div className="flex items-center gap-2">
<WrenchIcon size={12} className="text-muted-foreground" />
<div className="flex-1 overflow-hidden">
<div className="truncate font-medium">{tool.name}</div>
<div className="line-clamp-1 text-xs text-muted-foreground">
{tool.description}
</div>
</div>
</div>
<CreateOrEditLLMToolDialog
projectId={projectId as string}
onSave={handleSelectTool}
onDelete={() => handleRemoveTool(tool.id)}
existingLlmTool={tool}
>
<Button
variant="ghost"
size="icon"
className="ml-2 h-7 w-7 shrink-0"
onClick={(e) => e.stopPropagation()}
>
<PencilIcon className="h-3.5 w-3.5" />
</Button>
</CreateOrEditLLMToolDialog>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</CommandList>
<div className="mt-auto p-1">
<CreateOrEditLLMToolDialog
projectId={projectId as string}
onSave={handleSelectTool}
>
<Button variant="outline" size="default" className="w-full">
<PlusIcon className="mr-2 h-4 w-4" />
Create new tool
</Button>
</CreateOrEditLLMToolDialog>
</div>
</Command>
);
};
// Main component for embedding in CollapsibleSection content
export const PlaygroundTools = () => {
const { tools, setTools } = usePlaygroundContext();
const projectId = useProjectIdFromURL();
const [isSearchOpen, setIsSearchOpen] = useState(false);
const { data: savedTools = [] } = api.llmTools.getAll.useQuery(
{
@@ -99,13 +209,11 @@ export const PlaygroundTools = () => {
if (existingToolIndex !== -1) {
const newTools = [...prev];
newTools[existingToolIndex] = newTool;
return newTools;
}
return [...prev, newTool];
});
setIsSearchOpen(false);
};
const handleRemoveTool = (toolId: string) => {
@@ -116,159 +224,71 @@ export const PlaygroundTools = () => {
};
return (
<div className="flex h-full flex-col pr-1">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<p className="font-semibold">Tools</p>
<a
href="https://github.com/orgs/langfuse/discussions/3166"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center"
title="Tool calling is currently in beta. Click here to learn more and provide feedback!"
>
<span className="rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
Beta
</span>
</a>
<ScrollArea className="h-full">
{tools.length === 0 ? (
<div className="flex h-16 flex-col items-center justify-center p-4 text-center">
<p className="text-xs text-muted-foreground">No tools attached.</p>
</div>
<Popover open={isSearchOpen} onOpenChange={setIsSearchOpen}>
<PopoverTrigger asChild>
<Button className="h-7 w-7" variant="outline" size="icon">
<PlusIcon size={14} />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="p-1">
<Command className="flex flex-col">
<CommandInput
placeholder="Search tools..."
className="h-8 border-none p-1 focus:ring-0 focus:ring-offset-0"
/>
<CommandList className="max-h-[300px] overflow-y-auto">
<CommandEmpty>No tools found.</CommandEmpty>
<CommandGroup>
{savedTools.map((tool) => (
<CommandItem
key={tool.id}
value={tool.name}
onSelect={() => handleSelectTool(tool)}
className="flex items-center justify-between px-1 py-2"
) : (
<div className="space-y-1">
{tools.map((tool) => (
<CreateOrEditLLMToolDialog
key={tool.id}
projectId={projectId as string}
onSave={handleSelectTool}
onDelete={() => handleRemoveTool(tool.id)}
existingLlmTool={tool.existingLlmTool}
defaultValues={
!isToolSaved(tool)
? {
name: tool.name,
description: tool.description,
parameters: JSON.stringify(tool.parameters, null, 2),
}
: undefined
}
>
<div className="cursor-pointer rounded-md border bg-background p-2 transition-colors duration-200 hover:bg-accent/50">
<div className="mb-1 flex items-center justify-between">
<div className="flex items-center gap-1">
<WrenchIcon className="h-4 w-4 text-muted-foreground" />
<h3
className="max-w-[200px] truncate text-ellipsis text-sm font-medium"
title={tool.name}
>
<div className="flex items-center gap-2">
<WrenchIcon
size={12}
className="text-muted-foreground"
/>
<div className="flex-1 overflow-hidden">
<div className="truncate font-medium">
{tool.name}
</div>
<div className="line-clamp-1 text-xs text-muted-foreground">
{tool.description}
</div>
</div>
</div>
<CreateOrEditLLMToolDialog
projectId={projectId as string}
onSave={handleSelectTool}
onDelete={() => handleRemoveTool(tool.id)}
existingLlmTool={tool}
>
<Button
variant="ghost"
size="icon"
className="ml-2 h-7 w-7 shrink-0"
>
<PencilIcon className="h-3.5 w-3.5" />
</Button>
</CreateOrEditLLMToolDialog>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</CommandList>
<div className="mt-auto p-1">
<CreateOrEditLLMToolDialog
projectId={projectId as string}
onSave={handleSelectTool}
>
<Button variant="outline" size="default" className="w-full">
<PlusIcon className="mr-2 h-4 w-4" />
Create new tool
</Button>
</CreateOrEditLLMToolDialog>
</div>
</Command>
</PopoverContent>
</Popover>
</div>
<ScrollArea className="h-[calc(100%-2rem)]">
{tools.length === 0 ? (
<div className="flex h-16 flex-col items-center justify-center p-4 text-center">
<p className="text-xs text-muted-foreground">No tools attached.</p>
</div>
) : (
<div className="space-y-1">
{tools.map((tool) => (
<CreateOrEditLLMToolDialog
key={tool.id}
projectId={projectId as string}
onSave={handleSelectTool}
onDelete={() => handleRemoveTool(tool.id)}
existingLlmTool={tool.existingLlmTool}
defaultValues={
!isToolSaved(tool)
? {
name: tool.name,
description: tool.description,
parameters: JSON.stringify(tool.parameters, null, 2),
}
: undefined
}
>
<div className="cursor-pointer rounded-md border bg-background p-2 transition-colors duration-200 hover:bg-accent/50">
<div className="mb-1 flex items-center justify-between">
<div className="flex items-center gap-1">
<WrenchIcon className="h-4 w-4 text-muted-foreground" />
<h3
className="max-w-[200px] truncate text-ellipsis text-sm font-medium"
title={tool.name}
>
{tool.name}
</h3>
{!isToolSaved(tool) ? (
<span className="rounded bg-muted px-1 py-0.5 text-xs text-muted-foreground">
Unsaved
</span>
) : null}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
handleRemoveTool(tool.id);
}}
>
<MinusCircle className="h-4 w-4" />
</Button>
</div>
{tool.name}
</h3>
{!isToolSaved(tool) ? (
<span className="rounded bg-muted px-1 py-0.5 text-xs text-muted-foreground">
Unsaved
</span>
) : null}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
handleRemoveTool(tool.id);
}}
>
<MinusCircle className="h-4 w-4" />
</Button>
</div>
<p
className="line-clamp-2 break-all text-xs text-muted-foreground"
title={tool.description}
>
{tool.description}
</p>
</div>
</CreateOrEditLLMToolDialog>
))}
</div>
)}
</ScrollArea>
</div>
<p
className="line-clamp-2 break-all text-xs text-muted-foreground"
title={tool.description}
>
{tool.description}
</p>
</div>
</CreateOrEditLLMToolDialog>
))}
</div>
)}
</ScrollArea>
);
};
@@ -2,26 +2,26 @@ import { ListRestartIcon } from "lucide-react";
import { useRouter } from "next/router";
import { Button } from "@/src/components/ui/button";
import usePlaygroundCache from "@/src/features/playground/page/hooks/usePlaygroundCache";
import { usePersistedWindowIds } from "@/src/features/playground/page/hooks/usePersistedWindowIds";
export const ResetPlaygroundButton: React.FC = () => {
const router = useRouter();
const { setPlaygroundCache } = usePlaygroundCache();
const { clearAllCache } = usePersistedWindowIds();
const handleClick = () => {
setPlaygroundCache(null);
clearAllCache();
router.reload();
};
return (
<Button
variant={"outline"}
variant="outline"
title="Reset playground state"
onClick={handleClick}
className="gap-1"
>
<ListRestartIcon className="mr-1 h-4 w-4" />
<span>Reset playground</span>
<ListRestartIcon className="h-4 w-4" />
<span className="hidden lg:inline">Reset playground</span>
</Button>
);
};
@@ -1,5 +1,4 @@
import { Check, FileInput } from "lucide-react";
import Link from "next/link";
import { Check, Save } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
@@ -26,7 +25,13 @@ import { cn } from "@/src/utils/tailwind";
import DocPopup from "@/src/components/layouts/doc-popup";
import { PromptType } from "@langfuse/shared";
export const SaveToPromptButton: React.FC = () => {
interface SaveToPromptButtonProps {
className?: string;
}
export const SaveToPromptButton: React.FC<SaveToPromptButtonProps> = ({
className,
}) => {
const [selectedPromptId, setSelectedPromptId] = useState("");
const { modelParams, messages, output, promptVariables } =
usePlaygroundContext();
@@ -82,11 +87,13 @@ export const SaveToPromptButton: React.FC = () => {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant={"outline"} title="Save to prompt" asChild>
<Link href={`/project/${projectId}/playground`}>
<FileInput className="mr-1 h-4 w-4" />
<span>Save as prompt</span>
</Link>
<Button
variant="ghost"
className={cn("h-6 w-6 p-0 hover:bg-muted", className)}
title="Save current configuration as a prompt template for reuse across your project"
>
<Save size={14} />
<span className="sr-only">Save as prompt</span>
</Button>
</PopoverTrigger>
<PopoverContent>
@@ -1,4 +1,4 @@
import React, { useState, useCallback, useEffect } from "react";
import React, { useCallback, useEffect } from "react";
import { usePlaygroundContext } from "@/src/features/playground/page/context";
import { Button } from "@/src/components/ui/button";
@@ -17,18 +17,144 @@ import {
CommandList,
CommandSeparator,
} from "@/src/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/src/components/ui/popover";
import { type PlaygroundSchema } from "@/src/features/playground/page/types";
// Popover content component for use in CollapsibleSection action buttons
export const StructuredOutputSchemaPopover = () => {
const { structuredOutputSchema, setStructuredOutputSchema } =
usePlaygroundContext();
const projectId = useProjectIdFromURL();
const { data: savedSchemas = [] } = api.llmSchemas.getAll.useQuery(
{
projectId: projectId as string,
},
{
enabled: Boolean(projectId),
staleTime: 1000 * 60 * 5, // 5 minutes
},
);
const isSchemaSaved = useCallback(
(schema: PlaygroundSchema) => {
return savedSchemas.some(
(savedSchema) =>
savedSchema.id === schema.id &&
savedSchema.description === schema.description &&
JSON.stringify(savedSchema.schema) === JSON.stringify(schema.schema),
);
},
[savedSchemas],
);
const handleSelectSchema = (selectedLLMSchema: LlmSchema) => {
if (
structuredOutputSchema &&
structuredOutputSchema.id === selectedLLMSchema.id
) {
// Schema already selected, just update it
setStructuredOutputSchema({
...structuredOutputSchema,
name: selectedLLMSchema.name,
description: selectedLLMSchema.description,
schema: selectedLLMSchema.schema as Record<string, unknown>,
existingLlmSchema: selectedLLMSchema,
});
} else if (
structuredOutputSchema &&
structuredOutputSchema.name === selectedLLMSchema.name &&
!isSchemaSaved(structuredOutputSchema)
) {
// Replace unsaved schema with same name
setStructuredOutputSchema({
id: selectedLLMSchema.id,
name: selectedLLMSchema.name,
description: selectedLLMSchema.description,
schema: selectedLLMSchema.schema as Record<string, unknown>,
existingLlmSchema: selectedLLMSchema,
});
} else {
// New schema
const newPlaygroundSchema: PlaygroundSchema = {
id: selectedLLMSchema.id,
name: selectedLLMSchema.name,
description: selectedLLMSchema.description,
schema: selectedLLMSchema.schema as Record<string, unknown>,
existingLlmSchema: selectedLLMSchema,
};
setStructuredOutputSchema(newPlaygroundSchema);
}
};
const handleRemoveSchema = () => {
setStructuredOutputSchema(null);
};
return (
<Command className="flex flex-col">
<CommandInput
placeholder="Search schemas..."
className="h-8 border-none p-1 focus:ring-0 focus:ring-offset-0"
/>
<CommandList className="max-h-[300px] overflow-y-auto">
<CommandEmpty>No schemas found.</CommandEmpty>
<CommandGroup>
{savedSchemas.map((schema) => (
<CommandItem
key={schema.id}
value={schema.name}
onSelect={() => handleSelectSchema(schema)}
className="flex items-center justify-between px-1 py-2"
>
<div className="flex items-center gap-2">
<BoxIcon className="h-4 w-4 text-muted-foreground" />
<div className="flex-1 overflow-hidden">
<div className="truncate font-medium">{schema.name}</div>
<div className="line-clamp-1 text-xs text-muted-foreground">
{schema.description}
</div>
</div>
</div>
<CreateOrEditLLMSchemaDialog
projectId={projectId as string}
onSave={handleSelectSchema}
onDelete={() => handleRemoveSchema()}
existingLlmSchema={schema}
>
<Button
variant="ghost"
size="icon"
className="ml-2 h-7 w-7 shrink-0"
onClick={(e) => e.stopPropagation()}
>
<PencilIcon className="h-3.5 w-3.5" />
</Button>
</CreateOrEditLLMSchemaDialog>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</CommandList>
<div className="mt-auto p-1">
<CreateOrEditLLMSchemaDialog
projectId={projectId as string}
onSave={handleSelectSchema}
>
<Button variant="outline" size="default" className="w-full">
<PlusIcon className="mr-2 h-4 w-4" />
Create new schema
</Button>
</CreateOrEditLLMSchemaDialog>
</div>
</Command>
);
};
// Main component for embedding in CollapsibleSection content
export const StructuredOutputSchemaSection = () => {
const { structuredOutputSchema, setStructuredOutputSchema } =
usePlaygroundContext();
const projectId = useProjectIdFromURL();
const [isSearchOpen, setIsSearchOpen] = useState(false);
const { data: savedSchemas = [] } = api.llmSchemas.getAll.useQuery(
{
@@ -105,7 +231,6 @@ export const StructuredOutputSchemaSection = () => {
};
setStructuredOutputSchema(newPlaygroundSchema);
}
setIsSearchOpen(false);
};
const handleRemoveSchema = () => {
@@ -113,158 +238,72 @@ export const StructuredOutputSchemaSection = () => {
};
return (
<div className="flex h-full flex-col pr-1">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<p className="font-semibold">Structured Output</p>
<a
href="https://github.com/orgs/langfuse/discussions/3166"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center"
title="Structured output is currently in beta. Click here to learn more and provide feedback!"
>
<span className="rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
Beta
</span>
</a>
<ScrollArea className="h-full">
{!structuredOutputSchema ? (
<div className="flex h-16 flex-col items-center justify-center p-4 text-center">
<p className="text-xs text-muted-foreground">No schema provided.</p>
</div>
<Popover open={isSearchOpen} onOpenChange={setIsSearchOpen}>
<PopoverTrigger asChild>
<Button className="h-7 w-7" variant="outline" size="icon">
<PlusIcon size={14} />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="p-1">
<Command className="flex flex-col">
<CommandInput
placeholder="Search schemas..."
className="h-8 border-none p-1 focus:ring-0 focus:ring-offset-0"
/>
<CommandList className="max-h-[300px] overflow-y-auto">
<CommandEmpty>No schemas found.</CommandEmpty>
<CommandGroup>
{savedSchemas.map((schema) => (
<CommandItem
key={schema.id}
value={schema.name}
onSelect={() => handleSelectSchema(schema)}
className="flex items-center justify-between px-1 py-2"
>
<div className="flex items-center gap-2">
<BoxIcon className="h-4 w-4 text-muted-foreground" />
<div className="flex-1 overflow-hidden">
<div className="truncate font-medium">
{schema.name}
</div>
<div className="line-clamp-1 text-xs text-muted-foreground">
{schema.description}
</div>
</div>
</div>
<CreateOrEditLLMSchemaDialog
projectId={projectId as string}
onSave={handleSelectSchema}
onDelete={() => handleRemoveSchema()}
existingLlmSchema={schema}
>
<Button
variant="ghost"
size="icon"
className="ml-2 h-7 w-7 shrink-0"
onClick={(e) => e.stopPropagation()}
>
<PencilIcon className="h-3.5 w-3.5" />
</Button>
</CreateOrEditLLMSchemaDialog>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</CommandList>
<div className="mt-auto p-1">
<CreateOrEditLLMSchemaDialog
projectId={projectId as string}
onSave={handleSelectSchema}
>
<Button variant="outline" size="default" className="w-full">
<PlusIcon className="mr-2 h-4 w-4" />
Create new schema
) : (
<div className="space-y-1">
<CreateOrEditLLMSchemaDialog
projectId={projectId as string}
onSave={handleSelectSchema}
onDelete={() => handleRemoveSchema()}
existingLlmSchema={structuredOutputSchema.existingLlmSchema}
defaultValues={
!isSchemaSaved(structuredOutputSchema)
? {
name: structuredOutputSchema.name,
description: structuredOutputSchema.description,
schema: JSON.stringify(
structuredOutputSchema.schema,
null,
2,
),
}
: undefined
}
>
<div className="cursor-pointer rounded-md border bg-background p-2 transition-colors duration-200 hover:bg-accent/50">
<div className="mb-1 flex items-center justify-between">
<div className="flex items-center gap-1">
<BoxIcon className="h-4 w-4 text-muted-foreground" />
<h3
className="max-w-[200px] truncate text-ellipsis text-sm font-medium"
title={structuredOutputSchema.name}
>
{structuredOutputSchema.name}
</h3>
{!isSchemaSaved(structuredOutputSchema) ? (
<span className="rounded bg-muted px-1 py-0.5 text-xs text-muted-foreground">
Unsaved
</span>
) : null}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
handleRemoveSchema();
}}
>
<MinusCircle className="h-4 w-4" />
</Button>
</CreateOrEditLLMSchemaDialog>
</div>
</Command>
</PopoverContent>
</Popover>
</div>
<ScrollArea className="h-[calc(100%-2rem)]">
{!structuredOutputSchema ? (
<div className="flex h-16 flex-col items-center justify-center p-4 text-center">
<p className="text-xs text-muted-foreground">No schema provided.</p>
</div>
) : (
<div className="space-y-1">
<CreateOrEditLLMSchemaDialog
projectId={projectId as string}
onSave={handleSelectSchema}
onDelete={() => handleRemoveSchema()}
existingLlmSchema={structuredOutputSchema.existingLlmSchema}
defaultValues={
!isSchemaSaved(structuredOutputSchema)
? {
name: structuredOutputSchema.name,
description: structuredOutputSchema.description,
schema: JSON.stringify(
structuredOutputSchema.schema,
null,
2,
),
}
: undefined
}
>
<div className="cursor-pointer rounded-md border bg-background p-2 transition-colors duration-200 hover:bg-accent/50">
<div className="mb-1 flex items-center justify-between">
<div className="flex items-center gap-1">
<BoxIcon className="h-4 w-4 text-muted-foreground" />
<h3
className="max-w-[200px] truncate text-ellipsis text-sm font-medium"
title={structuredOutputSchema.name}
>
{structuredOutputSchema.name}
</h3>
{!isSchemaSaved(structuredOutputSchema) ? (
<span className="rounded bg-muted px-1 py-0.5 text-xs text-muted-foreground">
Unsaved
</span>
) : null}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
handleRemoveSchema();
}}
>
<MinusCircle className="h-4 w-4" />
</Button>
</div>
</div>
<p
className="line-clamp-2 break-all text-xs text-muted-foreground"
title={structuredOutputSchema.description}
>
{structuredOutputSchema.description}
</p>
</div>
</CreateOrEditLLMSchemaDialog>
</div>
)}
</ScrollArea>
</div>
<p
className="line-clamp-2 break-all text-xs text-muted-foreground"
title={structuredOutputSchema.description}
>
{structuredOutputSchema.description}
</p>
</div>
</CreateOrEditLLMSchemaDialog>
</div>
)}
</ScrollArea>
);
};
@@ -5,8 +5,9 @@ import { PromptVariableComponent } from "./PromptVariableComponent";
export const Variables = () => {
const { promptVariables } = usePlaygroundContext();
const renderNoVariables = () => (
<div className="mt-4 text-xs">
<div className="text-xs">
<p className="mb-2">No variables defined.</p>
<p>
Use handlebars in your prompts to add a variable:
@@ -14,6 +15,7 @@ export const Variables = () => {
</p>
</div>
);
const renderVariables = () => (
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
{promptVariables
@@ -36,7 +38,6 @@ export const Variables = () => {
return (
<div className="flex h-full flex-col">
<p className="font-semibold">Variables</p>
{promptVariables.length === 0 ? renderNoVariables() : renderVariables()}
</div>
);
@@ -1,10 +1,10 @@
import React, {
createContext,
type PropsWithChildren,
useCallback,
useContext,
useEffect,
useState,
useRef,
} from "react";
import { v4 as uuidv4 } from "uuid";
@@ -37,7 +37,15 @@ import {
type PlaygroundSchema,
type PlaygroundTool,
type PlaceholderMessageFillIn,
type PlaygroundProviderProps,
type PlaygroundHandle,
PLAYGROUND_EVENTS,
MULTI_WINDOW_CONFIG,
} from "@/src/features/playground/page/types";
import {
getPlaygroundEventBus,
useWindowCoordination,
} from "@/src/features/playground/page/hooks/useWindowCoordination";
import { getFinalModelParams } from "@/src/utils/getFinalModelParams";
type PlaygroundContextType = {
@@ -78,12 +86,13 @@ export const usePlaygroundContext = () => {
return context;
};
export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({
children,
windowId,
}) => {
const capture = usePostHogClientCapture();
const projectId = useProjectIdFromURL();
const { playgroundCache, setPlaygroundCache } = usePlaygroundCache();
const { playgroundCache, setPlaygroundCache } = usePlaygroundCache(windowId);
const [promptVariables, setPromptVariables] = useState<PromptVariable[]>([]);
const [messagePlaceholders, setMessagePlaceholders] = useState<
PlaceholderMessageFillIn[]
@@ -92,6 +101,7 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
const [outputToolCalls, setOutputToolCalls] = useState<LLMToolCall[]>([]);
const [outputJson, setOutputJson] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const isStreamingRef = useRef(isStreaming);
const [tools, setTools] = useState<PlaygroundTool[]>([]);
const [structuredOutputSchema, setStructuredOutputSchema] =
useState<PlaygroundSchema | null>(null);
@@ -115,7 +125,8 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
availableModels,
updateModelParamValue,
setModelParamEnabled,
} = useModelParams();
} = useModelParams(windowId);
const { registerWindow, unregisterWindow } = useWindowCoordination();
const toolCallIds = messages.reduce((acc, m) => {
if (m.type === ChatMessageType.AssistantToolCall) {
@@ -125,14 +136,19 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
}, [] as string[]);
// Load state from cache
const [cacheLoaded, setCacheLoaded] = useState(false);
useEffect(() => {
if (!playgroundCache) return;
if (!playgroundCache) {
setCacheLoaded(true);
return;
}
const {
messages: cachedMessages,
modelParams: cachedModelParams,
output: cachedOutput,
promptVariables: cachedPromptVariables,
messagePlaceholders: cachedMessagePlaceholders,
tools: cachedTools,
structuredOutputSchema: cachedStructuredOutputSchema,
} = playgroundCache;
@@ -166,6 +182,10 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
setPromptVariables(cachedPromptVariables);
}
if (cachedMessagePlaceholders) {
setMessagePlaceholders(cachedMessagePlaceholders);
}
if (cachedTools) {
setTools(cachedTools);
}
@@ -173,6 +193,8 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
if (cachedStructuredOutputSchema) {
setStructuredOutputSchema(cachedStructuredOutputSchema);
}
setCacheLoaded(true);
}, [playgroundCache, setModelParams]);
const updatePromptVariables = useCallback(() => {
@@ -294,6 +316,11 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
messages,
messagePlaceholders,
);
if (finalMessages.length === 0) {
throw new Error("Please add at least one message with content");
}
const leftOverVariables = extractVariables(
finalMessages
.map((m) => (typeof m.content === "string" ? m.content : ""))
@@ -381,6 +408,7 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
modelParams,
output: response,
promptVariables,
messagePlaceholders,
tools,
structuredOutputSchema,
});
@@ -480,6 +508,106 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
useEffect(updateMessagePlaceholders, [messages, updateMessagePlaceholders]);
// Save state to cache whenever it changes
// This ensures that user changes are persisted across refreshes and navigation
useEffect(() => {
// Only save after cache has been loaded to avoid overwriting with initial state
if (!cacheLoaded) return;
// Don't save empty initial state to avoid overwriting valid cache
if (messages.length > 0 && modelParams.provider.value) {
setPlaygroundCache({
messages,
modelParams,
output,
promptVariables,
messagePlaceholders,
tools,
structuredOutputSchema,
});
}
}, [
messages,
modelParams,
output,
promptVariables,
messagePlaceholders,
tools,
structuredOutputSchema,
setPlaygroundCache,
cacheLoaded,
]);
// Window self-registration for global coordination
// This effect registers the window with the global coordination system
// and sets up event listeners for global actions like "Run All" and "Stop All"
useEffect(() => {
const effectiveWindowId = windowId || MULTI_WINDOW_CONFIG.DEFAULT_WINDOW_ID;
const playgroundEventBus = getPlaygroundEventBus();
const playgroundHandle: PlaygroundHandle = {
handleSubmit,
stopExecution: () => {
setIsStreaming(false);
isStreamingRef.current = false;
},
getIsStreaming: () => isStreamingRef.current,
};
registerWindow(effectiveWindowId, playgroundHandle);
const handleGlobalExecute = () => {
if (!isStreamingRef.current) {
handleSubmit(true);
}
};
const handleGlobalStop = () => {
if (isStreamingRef.current) {
setIsStreaming(false);
isStreamingRef.current = false;
}
};
playgroundEventBus.addEventListener(
PLAYGROUND_EVENTS.EXECUTE_ALL,
handleGlobalExecute,
);
playgroundEventBus.addEventListener(
PLAYGROUND_EVENTS.STOP_ALL,
handleGlobalStop,
);
return () => {
unregisterWindow(effectiveWindowId);
playgroundEventBus.removeEventListener(
PLAYGROUND_EVENTS.EXECUTE_ALL,
handleGlobalExecute,
);
playgroundEventBus.removeEventListener(
PLAYGROUND_EVENTS.STOP_ALL,
handleGlobalStop,
);
};
}, [windowId, handleSubmit, registerWindow, unregisterWindow]);
// Keep ref in sync with state for external consumers
useEffect(() => {
isStreamingRef.current = isStreaming;
// Dispatch execution state change event so global coordinator can update counts
const playgroundEventBus = getPlaygroundEventBus();
playgroundEventBus.dispatchEvent(
new CustomEvent(PLAYGROUND_EVENTS.WINDOW_EXECUTION_STATE_CHANGE, {
detail: {
windowId: windowId || MULTI_WINDOW_CONFIG.DEFAULT_WINDOW_ID,
isStreaming,
},
}),
);
}, [windowId, isStreaming]);
return (
<PlaygroundContext.Provider
value={{
@@ -9,8 +9,16 @@ import {
type UIModelParams,
} from "@langfuse/shared";
import { type ModelParamsContext } from "@/src/components/ModelParameters";
import { getModelNameKey, getModelProviderKey } from "../storage/keys";
export const useModelParams = () => {
/**
* Hook for managing model parameters with window isolation support
* Supports both single-window and multi-window scenarios through window-specific localStorage keys
*
* @param windowId - Optional window identifier for state isolation. Defaults to "default" for backward compatibility
* @returns Object with model parameters state and management functions
*/
export const useModelParams = (windowId?: string) => {
const [modelParams, setModelParams] = useState<UIModelParams>({
...getDefaultAdapterParams(LLMAdapter.OpenAI),
provider: { value: "", enabled: true },
@@ -26,13 +34,17 @@ export const useModelParams = () => {
{ enabled: Boolean(projectId) },
);
// Generate window-specific localStorage keys
const modelNameKey = getModelNameKey(windowId ?? "");
const modelProviderKey = getModelProviderKey(windowId ?? "");
const [persistedModelName, setPersistedModelName] = useLocalStorage<
string | null
>("llmModelName", null);
>(modelNameKey, null);
const [persistedModelProvider, setPersistedModelProvider] = useLocalStorage<
string | null
>("llmModelProvider", null);
>(modelProviderKey, null);
const availableProviders = useMemo(() => {
const adapter = availableLLMApiKeys.data?.data ?? [];
@@ -0,0 +1,116 @@
import { useCallback, useEffect, useState } from "react";
import { v4 as uuidv4 } from "uuid";
import { MULTI_WINDOW_CONFIG } from "../types";
import {
getWindowIds,
saveWindowIds,
cloneWindowState,
removeWindowState,
clearAllPlaygroundData,
} from "../storage/windowStorage";
/**
* Hook to persist window IDs across page refreshes.
* Manages the list of active playground windows by orchestrating with storage utilities.
*/
export function usePersistedWindowIds() {
const [windowIds, setWindowIds] = useState<string[]>([]);
const [isLoaded, setIsLoaded] = useState(false);
// Load window IDs from storage on initial mount
useEffect(() => {
const savedWindowIds = getWindowIds();
setWindowIds(savedWindowIds ?? [uuidv4()]);
setIsLoaded(true);
}, []);
// Save window IDs to storage whenever they change
useEffect(() => {
if (isLoaded) {
saveWindowIds(windowIds);
}
}, [windowIds, isLoaded]);
/**
* Adds a window with a specific ID, if it doesn't already exist.
* @param windowId - The ID of the window to add.
* @returns The windowId if it was successfully added or already existed, otherwise null.
*/
const addWindowWithId = useCallback(
(windowId: string) => {
if (windowIds.includes(windowId)) {
return windowId;
}
if (windowIds.length >= MULTI_WINDOW_CONFIG.MAX_WINDOWS) {
console.warn(
`Maximum window limit of ${MULTI_WINDOW_CONFIG.MAX_WINDOWS} reached`,
);
return null;
}
setWindowIds((prev) => [...prev, windowId]);
return windowId;
},
[windowIds],
);
/**
* Adds a new window, copying the state from a source window.
* @param sourceWindowId - Optional ID of the window to copy. Defaults to the most recent window.
* @returns The ID of the newly created window, or null if the window limit is reached.
*/
const addWindowWithCopy = useCallback(
(sourceWindowId?: string) => {
if (windowIds.length >= MULTI_WINDOW_CONFIG.MAX_WINDOWS) {
console.warn(
`Maximum window limit of ${MULTI_WINDOW_CONFIG.MAX_WINDOWS} reached`,
);
return null;
}
const newWindowId = uuidv4();
const sourceId = sourceWindowId ?? windowIds[windowIds.length - 1];
if (sourceId) {
cloneWindowState(sourceId, newWindowId);
}
setWindowIds((prev) => [...prev, newWindowId]);
return newWindowId;
},
[windowIds],
);
/**
* Removes a window and its associated storage.
* @param windowId - The ID of the window to remove.
*/
const removeWindowId = useCallback(
(windowId: string) => {
if (windowIds.length <= 1) {
console.warn("Cannot remove the last remaining window");
return;
}
removeWindowState(windowId);
setWindowIds((prev) => prev.filter((id) => id !== windowId));
},
[windowIds.length],
);
/**
* Clears all playground data from storage and resets to a single window.
*/
const clearAllCache = useCallback((windowId?: string) => {
clearAllPlaygroundData();
setWindowIds([windowId ?? uuidv4()]);
}, []);
return {
windowIds,
isLoaded,
addWindowWithId,
addWindowWithCopy,
removeWindowId,
clearAllCache,
};
}
@@ -1,26 +1,55 @@
import { useEffect, useState } from "react";
import { type PlaygroundCache } from "../types";
import { getCacheKey } from "../storage/keys";
const playgroundCacheKey = "playgroundCache";
export default function usePlaygroundCache() {
/**
* Hook for managing playground cache with window isolation support.
* Relies on storage utilities for key generation and persistence.
*
* @param windowId - Optional window identifier for state isolation.
* @returns Object with playgroundCache state and setPlaygroundCache function.
*/
export default function usePlaygroundCache(windowId?: string) {
const [cache, setCache] = useState<PlaygroundCache>(null);
const playgroundCacheKey = getCacheKey(windowId ?? "");
/**
* Set playground cache for this specific window.
* Stores the cache in sessionStorage with a window-specific key.
*
* @param cache - PlaygroundCache object to store, or null to clear
*/
const setPlaygroundCache = (cache: PlaygroundCache) => {
sessionStorage.setItem(playgroundCacheKey, JSON.stringify(cache));
if (cache === null) {
sessionStorage.removeItem(playgroundCacheKey);
} else {
sessionStorage.setItem(playgroundCacheKey, JSON.stringify(cache));
}
};
/**
* Load cached state from sessionStorage on mount and when windowId changes
* Attempts to parse JSON and handles errors gracefully
*/
useEffect(() => {
const savedCache = sessionStorage.getItem(playgroundCacheKey);
if (savedCache) {
try {
setCache(JSON.parse(savedCache));
} catch (e) {
console.error("Failed to parse playground cache", e);
console.error(
`Failed to parse playground cache for window ${windowId}`,
e,
);
// Clear corrupted cache
sessionStorage.removeItem(playgroundCacheKey);
setCache(null);
}
} else {
setCache(null);
}
}, []);
}, [playgroundCacheKey, windowId]);
return {
playgroundCache: cache,
@@ -0,0 +1,37 @@
import { useState, useEffect, useRef } from "react";
/**
* Custom hook to track playground window container size and provide responsive breakpoints
*
* Uses ResizeObserver to monitor the container width and provides boolean flags
* for different responsive states based on the playground window size rather than
* the browser window size.
*/
export const usePlaygroundWindowSize = () => {
const [width, setWidth] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
setWidth(entry.contentRect.width);
}
});
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
};
}, []);
return {
containerRef,
width,
isVeryCompact: width < 423,
isCompact: width < 460,
};
};
@@ -0,0 +1,256 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
type PlaygroundHandle,
type WindowCoordinationReturn,
PLAYGROUND_EVENTS,
} from "../types";
/**
* Playground window registry for coordinating actions across multiple playground windows
* This Map stores references to all active playground windows and their handles
* Key: windowId, Value: PlaygroundHandle interface
*/
const playgroundWindowRegistry = new Map<string, PlaygroundHandle>();
/**
* Playground event bus for coordinating actions across playground windows
* Uses the native EventTarget API for performance and simplicity
*/
const playgroundEventBus = new EventTarget();
/**
* Hook for managing global coordination between multiple playground windows
* Provides functions to register/unregister windows and execute global actions
*
* Key features:
* - Window registration/unregistration
* - Parallel execution of all windows
* - Global stop functionality
* - Execution status tracking
* - Event-based coordination (no React re-renders)
*
* @returns WindowCoordinationReturn interface with coordination functions
*/
export const useWindowCoordination = (): WindowCoordinationReturn => {
const [isExecutingAll, setIsExecutingAll] = useState(false);
const [executionVersion, setExecutionVersion] = useState(0);
const executionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
/**
* Register a playground window with the global coordination system
* Adds the window to the registry and dispatches a registration event
*
* @param windowId - Unique identifier for the window
* @param handle - PlaygroundHandle interface for the window
*/
const registerWindow = useCallback(
(windowId: string, handle: PlaygroundHandle) => {
playgroundWindowRegistry.set(windowId, handle);
// Dispatch registration event for potential listeners
playgroundEventBus.dispatchEvent(
new CustomEvent(PLAYGROUND_EVENTS.WINDOW_REGISTERED, {
detail: { windowId },
}),
);
},
[],
);
/**
* Unregister a playground window from the global coordination system
* Removes the window from the registry and dispatches an unregistration event
*
* @param windowId - Unique identifier for the window to remove
*/
const unregisterWindow = useCallback((windowId: string) => {
const wasRegistered = playgroundWindowRegistry.has(windowId);
playgroundWindowRegistry.delete(windowId);
if (wasRegistered) {
// Dispatch unregistration event for potential listeners
playgroundEventBus.dispatchEvent(
new CustomEvent(PLAYGROUND_EVENTS.WINDOW_UNREGISTERED, {
detail: { windowId },
}),
);
}
}, []);
/**
* Execute all registered playground windows in parallel
* Dispatches execute-all event to all windows and manages global execution state
*/
const executeAllWindows = useCallback(() => {
const registeredWindows = Array.from(playgroundWindowRegistry.values());
if (registeredWindows.length === 0) {
return;
}
// Check if any windows are already executing
const alreadyExecuting = registeredWindows.some((handle) =>
handle.getIsStreaming(),
);
if (alreadyExecuting) {
return;
}
setIsExecutingAll(true);
// Clear any existing timeout
if (executionTimeoutRef.current) {
clearTimeout(executionTimeoutRef.current);
}
// Dispatch execute-all event
playgroundEventBus.dispatchEvent(
new CustomEvent(PLAYGROUND_EVENTS.EXECUTE_ALL),
);
// Set a timeout to reset the execution state
// This provides a fallback in case some windows don't respond
executionTimeoutRef.current = setTimeout(() => {
setIsExecutingAll(false);
}, 30000); // 30 second timeout
// Monitor execution completion
const checkExecutionCompletion = () => {
const stillExecuting = Array.from(playgroundWindowRegistry.values()).some(
(handle) => handle.getIsStreaming(),
);
if (!stillExecuting) {
setIsExecutingAll(false);
if (executionTimeoutRef.current) {
clearTimeout(executionTimeoutRef.current);
executionTimeoutRef.current = null;
}
} else {
// Check again in a short interval
setTimeout(checkExecutionCompletion, 500);
}
};
// Start monitoring after a short delay to allow windows to start
setTimeout(checkExecutionCompletion, 1000);
}, []);
/**
* Stop all currently executing playground windows
* Dispatches stop-all event to all windows and resets global execution state
*/
const stopAllWindows = useCallback(() => {
setIsExecutingAll(false);
// Clear any existing timeout
if (executionTimeoutRef.current) {
clearTimeout(executionTimeoutRef.current);
executionTimeoutRef.current = null;
}
// Dispatch stop-all event
playgroundEventBus.dispatchEvent(
new CustomEvent(PLAYGROUND_EVENTS.STOP_ALL),
);
}, []);
/**
* Get current execution status across all registered windows
* Provides a summary of the execution state for display purposes
*
* @returns String describing current execution status or null if no windows
*/
const getExecutionStatus = useCallback((): string | null => {
const registeredWindows = Array.from(playgroundWindowRegistry.values());
if (registeredWindows.length === 0) {
return null;
}
const executingCount = registeredWindows.filter((handle) =>
handle.getIsStreaming(),
).length;
const totalCount = registeredWindows.length;
if (executingCount === 0) {
return null;
}
if (executingCount === totalCount) {
return `Executing all ${totalCount} windows`;
}
return `Executing ${executingCount} of ${totalCount} windows`;
}, []);
/**
* Cleanup function to clear timeouts when component unmounts
* Prevents memory leaks from lingering timeouts
*/
useEffect(() => {
const handleExecutionChange = () => {
setExecutionVersion((v) => v + 1);
};
playgroundEventBus.addEventListener(
PLAYGROUND_EVENTS.WINDOW_EXECUTION_STATE_CHANGE,
handleExecutionChange,
);
return () => {
playgroundEventBus.removeEventListener(
PLAYGROUND_EVENTS.WINDOW_EXECUTION_STATE_CHANGE,
handleExecutionChange,
);
if (executionTimeoutRef.current) {
clearTimeout(executionTimeoutRef.current);
}
};
}, []);
executionVersion;
return {
registerWindow,
unregisterWindow,
executeAllWindows,
stopAllWindows,
getExecutionStatus,
isExecutingAll,
};
};
/**
* Utility function to get the current playground window registry
* Useful for debugging and testing purposes
*
* @returns Map of windowId to PlaygroundHandle
*/
export const getPlaygroundWindowRegistry = (): Map<
string,
PlaygroundHandle
> => {
return playgroundWindowRegistry;
};
/**
* Utility function to get the playground event bus
* Useful for advanced coordination scenarios and testing
*
* @returns EventTarget instance
*/
export const getPlaygroundEventBus = (): EventTarget => {
return playgroundEventBus;
};
/**
* Utility function to get the current window count
* Useful for UI components that need to display window statistics
*
* @returns Number of currently registered windows
*/
export const getWindowCount = (): number => {
return playgroundWindowRegistry.size;
};
+177 -16
View File
@@ -1,32 +1,193 @@
import React, { useCallback } from "react";
import { Button } from "@/src/components/ui/button";
import { Plus, Play, Loader2 } from "lucide-react";
import { ResetPlaygroundButton } from "@/src/features/playground/page/components/ResetPlaygroundButton";
import { SaveToPromptButton } from "@/src/features/playground/page/components/SaveToPromptButton";
import { useWindowCoordination } from "@/src/features/playground/page/hooks/useWindowCoordination";
import { usePersistedWindowIds } from "@/src/features/playground/page/hooks/usePersistedWindowIds";
import useCommandEnter from "@/src/features/playground/page/hooks/useCommandEnter";
import {
MULTI_WINDOW_CONFIG,
type MultiWindowState,
} from "@/src/features/playground/page/types";
import Page from "@/src/components/layouts/page";
import { PlaygroundProvider } from "@/src/features/playground/page/context";
import Playground from "@/src/features/playground/page/playground";
import MultiWindowPlayground from "@/src/features/playground/page/components/MultiWindowPlayground";
/**
* PlaygroundPage Component
*
* Main playground page that provides the multi-window playground experience
* for prompt testing and comparison. Manages window state at the page level
* to enable header controls integration.
*
* Key Features:
* - Multi-window playground for side-by-side comparison
* - Integrated header controls for window management
* - Global execution controls (Run All, Stop All)
* - Window count display and management
* - Reset playground functionality for starting fresh
* - Persistent window IDs across page refreshes
*
* Architecture:
* - Page-level window state management
* - Header integration with multi-window controls
* - Global coordination through useWindowCoordination hook
* - Clean single-header design
*/
export default function PlaygroundPage() {
return (
<PlaygroundProvider>
const { windowIds, isLoaded, addWindowWithCopy, removeWindowId } =
usePersistedWindowIds();
// Global coordination hook for managing window actions
const {
executeAllWindows,
getExecutionStatus,
isExecutingAll: globalIsExecutingAll,
} = useWindowCoordination();
/**
* Add a new window to the playground
* @param sourceWindowId - Optional source window ID to copy state from. If not provided, copies from the most recent window.
*/
const addWindow = useCallback(
(sourceWindowId?: string) => {
const newWindowId = addWindowWithCopy(sourceWindowId);
if (newWindowId) {
console.log(`Added new window: ${newWindowId}`);
} else {
console.warn("Failed to add new window");
}
},
[addWindowWithCopy],
);
/**
* Remove a window from the playground
*/
const removeWindow = useCallback(
(windowId: string) => {
removeWindowId(windowId);
},
[removeWindowId],
);
/**
* Handle global execution of all windows
*/
const handleExecuteAll = useCallback(() => {
executeAllWindows();
}, [executeAllWindows]);
// Handle command+enter for "Run All" button
useCommandEnter(!globalIsExecutingAll, async () => {
executeAllWindows();
});
// Don't render until window IDs are loaded
if (!isLoaded) {
return (
<Page
withPadding
withPadding={false}
headerProps={{
title: "Playground",
help: {
description: "A sandbox to test and iterate your prompts",
description:
"A sandbox to test and iterate your prompts across multiple windows",
href: "https://langfuse.com/docs/playground",
},
actionButtonsRight: (
<>
<SaveToPromptButton />
<ResetPlaygroundButton />
</>
),
}}
>
<div className="flex-1 overflow-auto">
<Playground />
<div className="flex h-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
</Page>
</PlaygroundProvider>
);
}
// Execution status and control states
const executionStatus = globalIsExecutingAll
? getExecutionStatus() ||
`Executing ${windowIds.length} window${windowIds.length === 1 ? "" : "s"}`
: getExecutionStatus();
const isAddWindowDisabled =
windowIds.length >= MULTI_WINDOW_CONFIG.MAX_WINDOWS;
const isRunAllDisabled = globalIsExecutingAll;
const windowState: MultiWindowState = {
windowIds,
isExecutingAll: globalIsExecutingAll,
};
return (
<Page
scrollable={false}
withPadding={false}
headerProps={{
title: "Playground",
help: {
description:
"A sandbox to test and iterate your prompts across multiple windows",
href: "https://langfuse.com/docs/playground",
},
actionButtonsRight: (
<div className="flex flex-nowrap items-center gap-2">
{/* Window Count Display - Hidden on mobile */}
<div className="hidden items-center gap-2 text-sm text-muted-foreground md:flex">
<span className="whitespace-nowrap">
{windowIds.length} window
{windowIds.length === 1 ? "" : "s"}
</span>
{executionStatus && (
<>
<span className="hidden sm:inline"></span>
<div className="flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="hidden whitespace-nowrap sm:inline">
{executionStatus}
</span>
</div>
</>
)}
</div>
{/* Add Window Button - Hidden on mobile */}
<Button
variant="outline"
onClick={() => addWindow()}
disabled={isAddWindowDisabled}
className="hidden flex-shrink-0 gap-1 md:flex"
title="Add a new playground window"
>
<Plus className="h-3 w-3" />
<span className="hidden lg:inline">Add Window</span>
</Button>
{/* Multi-Window Controls - Hidden on mobile */}
<Button
variant="outline"
onClick={handleExecuteAll}
disabled={isRunAllDisabled}
className="hidden flex-shrink-0 gap-1 md:flex"
title="Execute all playground windows simultaneously"
>
{isRunAllDisabled ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Play className="h-3 w-3" />
)}
<span className="hidden lg:inline">Run All (Ctrl + Enter)</span>
</Button>
{/* Reset Playground Button */}
<ResetPlaygroundButton />
</div>
),
}}
>
<MultiWindowPlayground
windowState={windowState}
onRemoveWindow={removeWindow}
onAddWindow={addWindow}
/>
</Page>
);
}
@@ -1,40 +0,0 @@
import { ModelParameters } from "@/src/components/ModelParameters";
import { usePlaygroundContext } from "./context";
import { Variables } from "./components/Variables";
import { MessagePlaceholders } from "./components/MessagePlaceholders";
import { Messages } from "./components/Messages";
import { PlaygroundTools } from "./components/PlaygroundTools";
import { StructuredOutputSchemaSection } from "./components/StructuredOutputSchemaSection";
export default function Playground() {
const playgroundContext = usePlaygroundContext();
return (
<div className="flex h-full flex-row space-x-8">
<div className="h-full basis-3/4 overflow-auto">
<Messages {...playgroundContext} />
</div>
<div className="max-h-full min-h-0 basis-1/4 pr-2">
<div className="flex h-full flex-col gap-4">
<div className="mb-4 flex-shrink-0 overflow-y-auto">
<ModelParameters {...playgroundContext} />
</div>
<div className="mb-4 max-h-[25vh] flex-shrink-0 overflow-y-auto">
<PlaygroundTools />
</div>
<div className="mb-4 flex-shrink-0">
<StructuredOutputSchemaSection />
</div>
<div className="flex min-h-0 flex-1 flex-col gap-6">
<div className="min-h-0 flex-1">
<Variables />
</div>
<div className="min-h-0 flex-1">
<MessagePlaceholders />
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,20 @@
import { MULTI_WINDOW_CONFIG } from "../types";
export const WINDOW_IDS_KEY = "playgroundWindowIds";
const getPrefixedKey = (prefix: string, windowId: string) => {
const effectiveWindowId = windowId || MULTI_WINDOW_CONFIG.DEFAULT_WINDOW_ID;
if (effectiveWindowId === MULTI_WINDOW_CONFIG.DEFAULT_WINDOW_ID) {
return prefix.endsWith("_") ? prefix.slice(0, -1) : prefix;
}
return `${prefix}${effectiveWindowId}`;
};
export const getCacheKey = (windowId: string) =>
getPrefixedKey("langfuse-playgroundCache_", windowId);
export const getModelNameKey = (windowId: string) =>
getPrefixedKey("langfuse-llmModelName_", windowId);
export const getModelProviderKey = (windowId: string) =>
getPrefixedKey("langfuse-llmModelProvider_", windowId);
@@ -0,0 +1,129 @@
import { type PlaygroundCache } from "../types";
import {
getCacheKey,
getModelNameKey,
getModelProviderKey,
WINDOW_IDS_KEY,
} from "./keys";
/**
* Retrieves the list of window IDs from sessionStorage.
* @returns An array of window IDs.
*/
export const getWindowIds = (): string[] | null => {
const saved = sessionStorage.getItem(WINDOW_IDS_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed) && parsed.length > 0) {
return parsed;
}
} catch (e) {
console.error("Failed to parse saved window IDs, clearing.", e);
sessionStorage.removeItem(WINDOW_IDS_KEY);
}
}
return null;
};
/**
* Saves the list of window IDs to sessionStorage.
* @param ids - The array of window IDs to save.
*/
export const saveWindowIds = (ids: string[]): void => {
sessionStorage.setItem(WINDOW_IDS_KEY, JSON.stringify(ids));
};
/**
* Retrieves the cached state for a specific window.
* @param windowId - The ID of the window.
* @returns The cached state or null if not found.
*/
export const getWindowState = (windowId: string): PlaygroundCache | null => {
const key = getCacheKey(windowId);
const cachedState = sessionStorage.getItem(key);
if (!cachedState) return null;
try {
return JSON.parse(cachedState) as PlaygroundCache;
} catch (error) {
console.error(`Failed to parse cache for window ${windowId}:`, error);
sessionStorage.removeItem(key);
return null;
}
};
/**
* Clones the entire state (cache and model params) from a source window to a target window.
* @param sourceWindowId - The ID of the window to copy from.
* @param targetWindowId - The ID of the window to copy to.
*/
export const cloneWindowState = (
sourceWindowId: string,
targetWindowId: string,
): void => {
try {
// 1. Clone sessionStorage (cache)
const sourceState = getWindowState(sourceWindowId);
if (sourceState) {
const clonedState = JSON.parse(JSON.stringify(sourceState));
sessionStorage.setItem(
getCacheKey(targetWindowId),
JSON.stringify(clonedState),
);
}
const sourceModelName = sessionStorage.getItem(
getModelNameKey(sourceWindowId),
);
if (sourceModelName) {
sessionStorage.setItem(getModelNameKey(targetWindowId), sourceModelName);
}
const sourceModelProvider = sessionStorage.getItem(
getModelProviderKey(sourceWindowId),
);
if (sourceModelProvider) {
sessionStorage.setItem(
getModelProviderKey(targetWindowId),
sourceModelProvider,
);
}
} catch (error) {
console.error(
`Failed to clone window state from ${sourceWindowId} to ${targetWindowId}`,
error,
);
}
};
/**
* Removes all storage associated with a single window.
* @param windowId - The ID of the window to remove.
*/
export const removeWindowState = (windowId: string): void => {
sessionStorage.removeItem(getCacheKey(windowId));
sessionStorage.removeItem(getModelNameKey(windowId));
sessionStorage.removeItem(getModelProviderKey(windowId));
};
/**
* Clears all playground-related data from storage.
*/
export const clearAllPlaygroundData = (): void => {
const sessionKeysToRemove: string[] = [];
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
if (key?.startsWith("playground")) {
sessionKeysToRemove.push(key);
}
}
sessionKeysToRemove.forEach((key) => sessionStorage.removeItem(key));
const localKeysToRemove: string[] = [];
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
if (key?.startsWith("llmModel")) {
localKeysToRemove.push(key);
}
}
localKeysToRemove.forEach((key) => sessionStorage.removeItem(key));
};
+85 -1
View File
@@ -34,7 +34,91 @@ export type PlaygroundCache = {
Pick<UIModelParams, "provider" | "model">;
output?: string | null;
promptVariables?: PromptVariable[];
// TODO: also cache placeholders?
messagePlaceholders?: PlaceholderMessageFillIn[];
tools?: PlaygroundTool[];
structuredOutputSchema?: PlaygroundSchema | null;
} | null;
// Multi-window types and interfaces
/**
* Handle interface for coordinating actions with individual playground windows
* Used by the global coordination system to execute actions on specific windows
*/
export interface PlaygroundHandle {
/** Execute the playground with optional streaming */
handleSubmit: (streaming?: boolean) => Promise<void>;
/** Stop the current execution */
stopExecution: () => void;
/** Getter for current streaming state */
getIsStreaming: () => boolean;
}
/**
* Return type for the useWindowCoordination hook
* Provides functions for managing the global window coordination system
*/
export interface WindowCoordinationReturn {
/** Register a window with the global coordination system */
registerWindow: (windowId: string, handle: PlaygroundHandle) => void;
/** Unregister a window from the global coordination system */
unregisterWindow: (windowId: string) => void;
/** Execute all registered windows in parallel */
executeAllWindows: () => void;
/** Stop all currently executing windows */
stopAllWindows: () => void;
/** Get current execution status across all windows */
getExecutionStatus: () => string | null;
/** Whether any windows are currently executing */
isExecutingAll: boolean;
}
/**
* Props for PlaygroundProvider with multi-window support
* Extends the existing provider to support window-specific state isolation
*/
export interface PlaygroundProviderProps {
children: React.ReactNode;
/** Optional window ID for state isolation. Defaults to "default" for single-window mode */
windowId?: string;
}
/**
* State management for the multi-window playground container
* Tracks all active windows and global execution state
*/
export interface MultiWindowState {
/** Array of window IDs currently active */
windowIds: string[];
/** Whether a global "execute all" operation is in progress */
isExecutingAll: boolean;
}
/**
* Event types for the global coordination system
* Custom events dispatched through the EventTarget-based event bus
*/
export const PLAYGROUND_EVENTS = {
EXECUTE_ALL: "playground:execute-all",
STOP_ALL: "playground:stop-all",
WINDOW_REGISTERED: "playground:window-registered",
WINDOW_UNREGISTERED: "playground:window-unregistered",
WINDOW_EXECUTION_STATE_CHANGE: "playground:window-execution-state-change",
} as const;
/**
* Configuration for window limits and validation
* Defines constraints for multi-window functionality
*/
export const MULTI_WINDOW_CONFIG = {
/** Maximum number of windows allowed */
MAX_WINDOWS: 10,
/** Maximum number of windows allowed on mobile/small screens */
MAX_WINDOWS_MOBILE: 1,
/** Minimum window width in pixels */
MIN_WINDOW_WIDTH: 400,
/** Mobile breakpoint in pixels (below this, mobile behavior applies) */
MOBILE_BREAKPOINT: 768,
/** Default window ID for single-window mode */
DEFAULT_WINDOW_ID: "default",
} as const;
@@ -90,7 +90,7 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
name: initialPrompt?.name ?? (folderPath ? `${folderPath}/` : ""),
config: JSON.stringify(initialPrompt?.config?.valueOf(), null, 2) || "{}",
isActive: !Boolean(initialPrompt),
commitMessage: initialPrompt?.commitMessage ?? undefined,
commitMessage: undefined,
};
const form = useForm({
@@ -18,8 +18,8 @@ const NewPromptBaseSchema = z.object({
commitMessage: z
.string()
.trim()
.min(1)
.max(COMMIT_MESSAGE_MAX_LENGTH)
.transform((val) => (val === "" ? undefined : val))
.optional(),
});
@@ -16,7 +16,6 @@ import {
StringParam,
useQueryParams,
withDefault,
useQueryParam,
} from "use-query-params";
import { createColumnHelper } from "@tanstack/react-table";
import { joinTableCoreAndMetrics } from "@/src/components/table/utils/joinTableCoreAndMetrics";
@@ -33,6 +32,7 @@ import {
} from "@/src/components/ui/breadcrumb";
import { Slash, Folder, Home } from "lucide-react";
import { promptsTableColsWithOptions } from "@langfuse/shared";
import { useFullTextSearch } from "@/src/components/table/use-cases/useFullTextSearch";
type PromptTableRow = {
id: string;
@@ -101,10 +101,8 @@ export function PromptTable() {
folder: StringParam,
});
const [searchQuery, setSearchQuery] = useQueryParam(
"search",
withDefault(StringParam, null),
);
const { searchQuery, searchType, setSearchQuery, setSearchType } =
useFullTextSearch();
// Reset pagination when search query changes
useEffect(() => {
@@ -132,6 +130,7 @@ export function PromptTable() {
orderBy: orderByState,
pathPrefix: currentFolderPath,
searchQuery: searchQuery || undefined,
searchType: searchType,
},
{
enabled: Boolean(projectId),
@@ -460,12 +459,17 @@ export function PromptTable() {
setFilterState={useDebounce(setFilterState)}
columnsWithCustomSelect={["labels", "tags"]}
searchConfig={{
metadataSearchFields: ["Name", "Tags"],
metadataSearchFields: ["Name", "Tags", "Content"],
updateQuery: useDebounce(setSearchQuery, 300),
currentQuery: searchQuery ?? undefined,
tableAllowsFullTextSearch: false,
setSearchType: undefined,
searchType: undefined,
tableAllowsFullTextSearch: true,
setSearchType,
searchType,
customDropdownLabels: {
metadata: "Names, Tags",
fullText: "Full Text",
},
hidePerformanceWarning: true,
}}
/>
<DataTable
@@ -20,6 +20,7 @@ import {
promptsTableCols,
PromptType,
StringNoHTMLNonEmpty,
TracingSearchType,
} from "@langfuse/shared";
import { orderBy, singleFilter } from "@langfuse/shared";
import {
@@ -36,6 +37,34 @@ import { aggregateScores } from "@/src/features/scores/lib/aggregateScores";
import { TRPCError } from "@trpc/server";
import { promptChangeEventSourcing } from "@/src/features/prompts/server/promptChangeEventSourcing";
const buildPromptSearchFilter = (
searchQuery: string | undefined | null,
searchType?: TracingSearchType[],
): Prisma.Sql => {
if (searchQuery === undefined || searchQuery === null || searchQuery === "") {
return Prisma.empty;
}
const q = searchQuery;
const types = searchType ?? ["id"];
const searchConditions: Prisma.Sql[] = [];
if (types.includes("id")) {
searchConditions.push(Prisma.sql`p.name ILIKE ${`%${q}%`}`);
searchConditions.push(
Prisma.sql`EXISTS (SELECT 1 FROM UNNEST(p.tags) AS tag WHERE tag ILIKE ${`%${q}%`})`,
);
}
if (types.includes("content")) {
searchConditions.push(Prisma.sql`p.prompt::text ILIKE ${`%${q}%`}`);
}
return searchConditions.length > 0
? Prisma.sql` AND (${Prisma.join(searchConditions, " OR ")})`
: Prisma.empty;
};
const PromptFilterOptions = z.object({
projectId: z.string(), // Required for protectedProjectProcedure
filter: z.array(singleFilter),
@@ -43,6 +72,7 @@ const PromptFilterOptions = z.object({
...paginationZod,
pathPrefix: z.string().optional(),
searchQuery: z.string().optional(),
searchType: z.array(TracingSearchType).optional(),
});
export const promptRouter = createTRPCRouter({
@@ -97,15 +127,10 @@ export const promptRouter = createTRPCRouter({
})()
: Prisma.empty;
const searchFilter =
input.searchQuery !== undefined &&
input.searchQuery !== null &&
input.searchQuery !== ""
? (() => {
const q = input.searchQuery;
return Prisma.sql` AND (p.name ILIKE ${`%${q}%`} OR EXISTS (SELECT 1 FROM UNNEST(p.tags) AS tag WHERE tag ILIKE ${`%${q}%`}))`;
})()
: Prisma.empty;
const searchFilter = buildPromptSearchFilter(
input.searchQuery,
input.searchType,
);
const [prompts, promptCount] = await Promise.all([
// prompts
@@ -155,7 +180,15 @@ export const promptRouter = createTRPCRouter({
};
}),
count: protectedProjectProcedure
.input(z.object({ projectId: z.string() }))
.input(
z.object({
projectId: z.string(),
searchQuery: z.string().optional(),
searchType: z.array(TracingSearchType).optional(),
pathPrefix: z.string().optional(),
filter: z.array(singleFilter).optional(),
}),
)
.query(async ({ input, ctx }) => {
throwIfNoProjectAccess({
session: ctx.session,
@@ -163,14 +196,37 @@ export const promptRouter = createTRPCRouter({
scope: "prompts:read",
});
const filterCondition = input.filter
? tableColumnsToSqlFilterAndPrefix(
input.filter,
promptsTableCols,
"prompts",
)
: Prisma.empty;
const pathFilter = input.pathPrefix
? (() => {
const prefix = input.pathPrefix;
return Prisma.sql` AND (p.name LIKE ${`${prefix}/%`} OR p.name = ${prefix})`;
})()
: Prisma.empty;
const searchFilter = buildPromptSearchFilter(
input.searchQuery,
input.searchType,
);
const count = await ctx.prisma.$queryRaw<Array<{ totalCount: bigint }>>(
generatePromptQuery(
Prisma.sql` count(*) AS "totalCount"`,
input.projectId,
Prisma.empty,
filterCondition,
Prisma.empty,
1, // limit
0, // page
pathFilter,
searchFilter,
input.pathPrefix,
),
);
@@ -0,0 +1,68 @@
import { type jsonSchema } from "@langfuse/shared";
import { prisma } from "@langfuse/shared/src/db";
import { v4 } from "uuid";
import type z from "zod/v4";
type Json = z.infer<typeof jsonSchema>;
const isUniqueConstraintError = (error: any): boolean => {
return (
error.code === "P2002" || // Prisma unique constraint
error.message?.includes("duplicate key") ||
error.message?.includes("UNIQUE constraint") ||
error.message?.includes("violates unique constraint")
);
};
export const createOrFetchDatasetRun = async ({
projectId,
datasetId,
name,
description,
metadata,
}: {
projectId: string;
datasetId: string;
name: string;
description?: string;
metadata?: Json | null;
}) => {
try {
// Attempt optimistic creation
const datasetRun = await prisma.datasetRuns.create({
data: {
id: v4(),
datasetId,
projectId,
name,
description: description ?? null,
metadata: metadata ?? {},
createdAt: new Date(),
updatedAt: new Date(),
},
});
return datasetRun;
} catch (error) {
// Check if it's a unique constraint violation
if (isUniqueConstraintError(error)) {
// Fetch existing run
const existingRun = await prisma.datasetRuns.findUnique({
where: {
datasetId_projectId_name: {
datasetId,
projectId,
name: name,
},
},
});
if (existingRun) {
return existingRun;
}
} else {
throw error;
}
}
throw new Error("Failed to create or fetch dataset run");
};

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