Compare commits

...
27 Commits
Author SHA1 Message Date
Hassieb Pakzad dd632fea9e chore: release v3.167.0 2026-04-09 14:26:32 +02:00
Hassieb PakzadandGitHub 7527bb0d84 fix(web): require secret key for LLM test base URL changes (#13055) 2026-04-09 14:25:31 +02:00
NimarandGitHub 8cc4a5537f fix(cicd): re-add nextauth etc to docker (#12865)
* fix(cicd): re-add nextauth etc to docker

* clarify

* fix prisma version

* one more comment
2026-04-09 12:09:01 +00:00
marliessophieandGitHub 0a6d3f108a chore(experiments): Link dataset cell in Experiments table to dataset page and show display name (#13059)
fix(experiments): render dataset badge label without table-id component
2026-04-09 12:05:11 +00:00
Ben BachemandGitHub 1bf83313e3 fix(trace-table): Only disable URL persistence for ScoresTable in peek mode (#12963)
fix(web): Only disable URL persistence for `ScoresTable` in peek mode
2026-04-09 11:52:40 +00:00
marliessophieandGitHub 9c3a715d77 fix(annotation): Wait for session to load before rendering annotation queue items (#13058)
fix(web): simplify annotation queue loading state guard
2026-04-09 11:32:58 +00:00
marliessophieandGitHub 0cf2a33473 fix(v4-add-to-dataset): Allow non-string JSON prefill values for new dataset items (#13053)
* fix(datasets): normalize add-to-dataset prefill values

* fix(datasets): preserve parsed null prefill values
2026-04-09 11:11:09 +00:00
Valery MeleshkinandGitHub 51554eb066 chore(dx): add blob storage docs review checks to AGENTS.md (#13056) 2026-04-09 12:24:47 +02:00
marliessophieandGitHub cbc21bb9cc feat(annotation-queues): integrate session handling and beta feature flag in AnnotationQueueItemPage and update router for observation fetching (#13050) 2026-04-09 09:44:50 +00:00
Valery MeleshkinandGitHub 8e30694214 Revert "chore: optional docker setup in Codex setup/maintenance scripts" (#13049)
Revert "chore: optional docker setup in Codex setup/maintenance scripts (#13035)"

This reverts commit 0d20d9de2b.
2026-04-09 10:38:13 +02:00
Valery MeleshkinandGitHub 0d20d9de2b chore: optional docker setup in Codex setup/maintenance scripts (#13035)
* fix(codex): install golang-migrate in docker setup

* fix(codex): include local bin path in maintenance

* fix(codex): load local bin path in setup shell

* fix(codex): verify migrate checksum and safe extract

* fix(codex): improve migrate install error guidance
2026-04-08 17:03:11 +00:00
Tobias Wochinger eeeba25439 chore: release v3.166.0 2026-04-08 19:03:32 +02:00
a59630d656 chore(dx): add more steps for pre-commit (#12901)
* chore(dx): add more stop for pre-commit

* chore: add type checking as well

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: add auto-fixed files to diff

* chore: change to not modifying / remove typecheck

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:04:41 +00:00
Valery MeleshkinandGitHub 10e7dea9c9 fix: getTraceById metadata (#13042) 2026-04-08 17:35:07 +02:00
marliessophieandGitHub 9bf326db4f Revert "fix(dataset-items): update current dataset item versions in upsert function" (#13043)
Revert "fix(dataset-items): update current dataset item versions in upsert fu…"

This reverts commit c393c64a40.
2026-04-08 17:26:31 +02:00
13190c3ec4 perf(trace-ui): Exclude and tool columns from trace observations query when IO is not requested (#12948)
* put prompt_* and tool_* behind 'includeIO'

* ordering

* put prompt outside of io

* also omit for events

---------

Co-authored-by: Nimar <l.nimar.b@gmail.com>
2026-04-08 15:05:50 +00:00
Ben BachemandGitHub 7a4ce9ee5d fix(web): Inconsistent search results between editor and controller (#13038) 2026-04-08 15:05:37 +00:00
NimarandGitHub bc02989ccf fix(ui): remove right screen side handle on mobile (#13036) 2026-04-08 17:02:55 +02:00
NimarandGitHub f0dac0299c chore(deps): bump turbo to 2.9.5 (#13032) 2026-04-08 14:09:50 +00:00
Valery MeleshkinandGitHub 19997064c1 feat(api): add fields parameter to GetTraceById endpoint (#13015) 2026-04-08 13:58:34 +00:00
marliessophieandGitHub c393c64a40 fix(dataset-items): update current dataset item versions in upsert function (#13034) 2026-04-08 13:42:47 +00:00
d04e027107 fix(prompt-automations): prompt creations no longer trigger webhooks for unfiltered event actions (#13000)
* fix(prompt): webhook triggers honor eventAction filters

* fix(automations): added validation of event actions

* test(automations): add deleted event action test case to promptVersionProcessor

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

* Revert "fix(automations): added validation of event actions"

This reverts commit 210344f49b45fd80e79fecf6841dd37dbe822a28.

* fix(test): update setupTriggerAndAction to match all event actions

The helper used eventActions: ["updated"] which broke the prompt
creation test after eventActions filtering was enforced. Using []
matches all actions, covering both created and updated test cases.

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

* test(ci): retrigger stuck license cla check

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:21:03 +00:00
marliessophieandGitHub 53869c7200 fix(dataset-items): handle version conflict errors in dataset item upsert (#13031) 2026-04-08 12:49:20 +00:00
6d964894fb fix(api): return archived item when getting dataset item by ID (#13028)
fix(api): return archived dataset items from GET endpoint

Previously GET /api/public/dataset-items/{id} returned 404 for archived
items. Now it returns them with their status, matching user expectations
for direct ID lookups.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:24:44 +00:00
81653b2bd2 ci: add cla-assistant workflow to retrigger stuck CLA checks (#13021)
Workaround for a known cla-assistant bug where the CLA check gets stuck
after a contributor signs. Comment `/check-cla` on any PR to manually
retrigger. See: https://github.com/cla-assistant/cla-assistant/issues/528

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:02:08 +00:00
marliessophieandGitHub 02d6486f10 chore(dataset-items): patch API on version collision (#13029)
* chore(dataset-items): patch API to throw 4xx instead on version collision

* chore: push
2026-04-08 11:46:38 +00:00
07ee4ed961 fix(web): Improve search highlighting in CodeMirrorEditor (#12961)
* fix(web): Improve search highlighting in CodeMirrorEditor

* Add color variables

* Always call `syncEditorsToQuery` if the active changed

* make selector more spefific

---------

Co-authored-by: Nimar <l.nimar.b@gmail.com>
2026-04-08 11:24:22 +00:00
52 changed files with 1318 additions and 599 deletions
+3
View File
@@ -235,6 +235,9 @@ LANGFUSE_ENABLE_BLOB_STORAGE_FILE_LOG=true
# Comma-separated default field groups for GET /api/public/traces when no fields param is provided
# Valid values: core, io, scores, observations, metrics
# LANGFUSE_API_TRACES_DEFAULT_FIELDS=
# Comma-separated default field groups for GET /api/public/traces/{traceId} when no fields param is provided
# Valid values: core, io, scores, observations, metrics
# LANGFUSE_API_TRACEBYID_DEFAULT_FIELDS=
### START Enterprise Edition Configuration
+20
View File
@@ -0,0 +1,20 @@
# Workaround for a known cla-assistant bug where the CLA check gets stuck and
# never re-runs after a contributor signs the CLA.
# See: https://github.com/cla-assistant/cla-assistant/issues/528
#
# Usage: comment `/check-cla` on any PR to manually retrigger the CLA check.
name: CLA Assistant
on:
issue_comment:
types: [created]
jobs:
retrigger_cla:
# Only run on PR comments (not issue comments) with the /check-cla command
if: github.event.issue.pull_request && github.event.comment.body == '/check-cla'
runs-on: ubuntu-latest
steps:
- name: Retrigger CLA check
run: |
curl -s "https://cla-assistant.io/check/langfuse/langfuse?pullRequest=${{ github.event.issue.number }}"
+4 -2
View File
@@ -11,13 +11,15 @@ if [ "$current_branch" = "$protected_branch" ]; then
echo "🚨 You are about to commit to the $protected_branch branch. Are you sure? (y/n)"
read -r answer < /dev/tty
if [ "$answer" != "${answer#[Yy]}" ]; then
# Commit approved, check formatting. On files changed, block commit
# Commit approved, run checks
pnpm run format:check
pnpm run lint
else
echo "Commit to $protected_branch branch has been canceled."
exit 1 # Commit will be blocked
fi
fi
# If not the protected branch, check formatting (on changed files, block)
# If not the protected branch, run checks
pnpm run format:check
pnpm run lint
+44 -39
View File
@@ -418,45 +418,48 @@ The background color of the following component will be `hsl(var(--primary))` an
### Color Variables
| Variable | Description | Examples |
| ------------------------ | ------------------------------------------------------------------ | -------------------------------- |
| --background | Background color | Default background color of body |
| --foreground | Foreground color | Default text color of body |
| --muted | Muted background color | TabsList, Skeleton and Switch |
| --muted-foreground | Muted foreground color | |
| --popover | Popover background color | DropdownMenu, HoverCard, Popover |
| --popover-foreground | Popover foreground color | |
| --card | Card background color | Card |
| --card-foreground | Card foreground color | |
| --border | Border color | Default border color |
| --input | Input field border color | Input, Select, Textarea |
| --primary | Primary button background colors | Button variant="primary" |
| --primary-foreground | Primary button foreground color | |
| --secondary | Secondary button background color | Button variant="secondary" |
| --secondary-foreground | Secondary button foreground color | |
| --accent | Used for accents such as hover effects | DropdownMenuItem, SelectItem |
| --accent-foreground | Used for texts on hover effects | DropdownMenuItem, SelectItem |
| --destructive | Destructive action color for background | Button variant="destructive" |
| --destructive-foreground | Destructive action color for text | |
| --ring | Focus ring color | MultiSelect |
| --primary-accent | Primary accent color used for branding | Layout |
| --hover-primary-accent | Primary accent color used for hover effects for links | SignIn and AuthCloudRegionSwitch |
| --muted-green | Muted green for Event label | ObservationTree |
| --muted-magenta | Muted magenta for Generation label | ObservationTree |
| --muted-blue | Muted blue for Span label | ObservationTree |
| --muted-gray | Muted gray for disabled status badges | StatusBadge |
| --accent-light-green | Light green accent for background of output and assistant messages | IOPreview, Generations, Traces |
| --accent-dark-green | Dark green accent for border of output and assistant messages | CodeJsonViewer and IOPReview |
| --light-red | Light red for error background | level-color and StatusBadge |
| --dark-red | Dark red for error text and error badge dot color | level-color and ErrorPage |
| --light-yellow | Light yellow for warning background | LevelColor |
| --dark-yellow | Dark yellow for warning text | LevelColor |
| --light-green | Light green for success status badge background | StatusBadge |
| --dark-green | Dark green for success status badge text and dot | StatusBadge |
| --light-blue | Light blue for background of Staging label | LangfuseLogo |
| --dark-blue | Dark blue for text and border of Staging label | LangfuseLogo |
| --accent-light-blue | Light blue accent for table link hover effect | TableLink |
| --accent-dark-blue | Dark blue accent for table link text | TableLink |
| Variable | Description | Examples |
| -------------------------------- | ------------------------------------------------------------------ | -------------------------------- |
| --background | Background color | Default background color of body |
| --foreground | Foreground color | Default text color of body |
| --muted | Muted background color | TabsList, Skeleton and Switch |
| --muted-foreground | Muted foreground color | |
| --popover | Popover background color | DropdownMenu, HoverCard, Popover |
| --popover-foreground | Popover foreground color | |
| --card | Card background color | Card |
| --card-foreground | Card foreground color | |
| --border | Border color | Default border color |
| --input | Input field border color | Input, Select, Textarea |
| --primary | Primary button background colors | Button variant="primary" |
| --primary-foreground | Primary button foreground color | |
| --secondary | Secondary button background color | Button variant="secondary" |
| --secondary-foreground | Secondary button foreground color | |
| --accent | Used for accents such as hover effects | DropdownMenuItem, SelectItem |
| --accent-foreground | Used for texts on hover effects | DropdownMenuItem, SelectItem |
| --destructive | Destructive action color for background | Button variant="destructive" |
| --destructive-foreground | Destructive action color for text | |
| --ring | Focus ring color | MultiSelect |
| --primary-accent | Primary accent color used for branding | Layout |
| --hover-primary-accent | Primary accent color used for hover effects for links | SignIn and AuthCloudRegionSwitch |
| --muted-green | Muted green for Event label | ObservationTree |
| --muted-magenta | Muted magenta for Generation label | ObservationTree |
| --muted-blue | Muted blue for Span label | ObservationTree |
| --muted-gray | Muted gray for disabled status badges | StatusBadge |
| --accent-light-green | Light green accent for background of output and assistant messages | IOPreview, Generations, Traces |
| --accent-dark-green | Dark green accent for border of output and assistant messages | CodeJsonViewer and IOPReview |
| --light-red | Light red for error background | level-color and StatusBadge |
| --dark-red | Dark red for error text and error badge dot color | level-color and ErrorPage |
| --light-yellow | Light yellow for warning background | LevelColor |
| --dark-yellow | Dark yellow for warning text | LevelColor |
| --light-green | Light green for success status badge background | StatusBadge |
| --dark-green | Dark green for success status badge text and dot | StatusBadge |
| --light-blue | Light blue for background of Staging label | LangfuseLogo |
| --dark-blue | Dark blue for text and border of Staging label | LangfuseLogo |
| --accent-light-blue | Light blue accent for table link hover effect | TableLink |
| --accent-dark-blue | Dark blue accent for table link text | TableLink |
| --find-match-selected-background | Background color for selected search matches | CodeMirrorEditor |
| --find-match-selected-foreground | Foreground color for selected search matches | CodeMirrorEditor |
| --find-match-background | Background color for search matches | CodeMirrorEditor |
### Adding New Colors
@@ -510,3 +513,5 @@ npx fern-api generate --api organizations # for the organizations API
Langfuse is MIT licensed, except for `ee/` folder. See [LICENSE](LICENSE) and [docs](https://langfuse.com/docs/open-source) for more details.
When contributing to the Langfuse codebase, you need to agree to the [Contributor License Agreement](https://cla-assistant.io/langfuse/langfuse). You only need to do this once and the CLA bot will remind you if you haven't signed it yet.
If the CLA check gets stuck after signing (a [known cla-assistant bug](https://github.com/cla-assistant/cla-assistant/issues/520)), comment `/check-cla` on your PR to retrigger it.
+6
View File
@@ -10,6 +10,12 @@ service:
docs: Get a specific trace
method: GET
path: /traces/{traceId}
request:
name: GetTraceRequest
query-parameters:
fields:
type: optional<string>
docs: "Comma-separated list of fields to include in the response. Available field groups: 'core' (always included), 'io' (input, output, metadata), 'scores', 'observations', 'metrics'. If not specified, all fields are returned. Example: 'core,scores,metrics'. Note: Excluded 'observations' or 'scores' fields return empty arrays; excluded 'metrics' returns -1 for 'totalCost' and 'latency'."
path-parameters:
traceId:
type: string
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.165.0",
"version": "3.167.0",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
@@ -48,7 +48,7 @@
"husky": "^9.1.7",
"prettier": "^3.8.1",
"release-it": "^19.2.4",
"turbo": "2.8.20"
"turbo": "2.9.5"
},
"release-it": {
"git": {
+1 -1
View File
@@ -18,7 +18,7 @@
"@eslint/js": "^9.39.2",
"eslint-config-next": "16.2.1",
"eslint-config-prettier": "^10.1.8",
"eslint-config-turbo": "2.8.20",
"eslint-config-turbo": "2.9.5",
"eslint-plugin-only-warn": "^1.1.0",
"eslint-plugin-prettier": "^5.5.4",
"globals": "^16.0.0",
+14
View File
@@ -97,6 +97,15 @@ the same PR.
2. Update ClickHouse query/mapping logic in `src/server/clickhouse/*` and
related repositories.
3. Validate ingestion/read path impact in both `web` and `worker`.
4. If the change affects columns, types, or nullability of tables read by blob
storage export queries (`getTracesForBlobStorageExport`,
`getObservationsForBlobStorageExport`, `getScoresForBlobStorageExport`,
`getEventsForBlobStorageExport`, or the EventsQueryBuilder `export` field
set), fetch the latest published docs and check for discrepancies:
- https://langfuse.com/docs/api-and-data-platform/features/export-to-blob-storage
- https://langfuse.com/docs/api-and-data-platform/features/blob-storage-export-fields
Surface any mismatches in field names, types, nullability, or filter
descriptions so they can be addressed in the docs repo.
### Queue payload contract change
@@ -130,3 +139,8 @@ the same PR.
- Do not hand-edit generated artifacts under `prisma/generated/*` or `dist/*`.
- Avoid exposing server-only modules through `src/index.ts` if they must remain
frontend-safe.
- Changes to domain constants consumed by blob storage exports (e.g.
`LISTABLE_SCORE_TYPES` in `src/domain/scores.ts`, score data type enums)
should be reviewed against the blob storage export field reference docs for
consistency — fetch the latest page and surface any discrepancies:
https://langfuse.com/docs/api-and-data-platform/features/blob-storage-export-fields
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.165.0";
export const VERSION = "v3.167.0";
@@ -185,6 +185,39 @@ const FIELD_SETS = {
"toolCalls",
"toolCallNames",
],
baseWithoutTools: [
"id",
"type",
"projectId",
"name",
"modelParameters",
"startTime",
"endTime",
"traceId",
"completionStartTime",
"providedUsageDetails",
"usageDetails",
"providedCostDetails",
"costDetails",
"level",
"environment",
"bookmarked",
"public",
"statusMessage",
"version",
"parentObservationId",
"createdAt",
"updatedAt",
"providedModelName",
"totalCost",
"promptId",
"promptName",
"promptVersion",
"internalModelId",
"userId",
"sessionId",
"traceName",
],
calculated: ["latency", "timeToFirstToken"],
io: ["input", "output"],
metadata: ["metadata"],
@@ -375,16 +375,35 @@ export async function upsertDatasetItem(
[Implementation.VERSIONED]: async () => {
// VERSIONED: Invalidate old row by setting valid_to, then create new row
await prisma.$transaction(async (tx) => {
const newValidFrom = new Date();
// 0. Re-read if there is an existing item to get the validFrom timestamp
const current = await tx.datasetItem.findFirst({
where: {
id: itemId,
projectId: props.projectId,
validTo: null,
},
orderBy: {
validFrom: "desc",
},
});
if (current && current.datasetId !== dataset.id) {
throw new LangfuseNotFoundError(
`Dataset item with id ${itemId} not found for project ${props.projectId}`,
);
}
const baseTs = current?.validFrom.getTime() ?? 0;
const newValidFrom = new Date(Math.max(Date.now(), baseTs + 1));
// 1. If updating existing item, invalidate the current version
if (existingItem) {
if (current) {
await tx.datasetItem.update({
where: {
id_projectId_validFrom: {
id: existingItem.id,
id: current.id,
projectId: props.projectId,
validFrom: existingItem.validFrom,
validFrom: current.validFrom,
},
},
data: {
@@ -337,6 +337,7 @@ export const getObservationsForTraceFromEventsTable = async (params: {
limit: MAX_OBSERVATIONS_PER_TRACE + 1,
offset: 0,
select: "rows",
selectToolData: false,
tags: { kind: "byTraceId" },
},
);
@@ -394,6 +395,7 @@ export const getObservationsWithModelDataFromEventsTable = async (
async function getObservationsFromEventsTableInternal<T>(
opts: ObservationTableQuery & {
select: "count" | "rows";
selectToolData?: boolean;
tags: Record<string, string>;
},
): Promise<Array<T>> {
@@ -401,6 +403,7 @@ async function getObservationsFromEventsTableInternal<T>(
projectId,
filter,
selectIOAndMetadata,
selectToolData = true,
renderingProps = DEFAULT_RENDERING_PROPS,
limit,
offset,
@@ -461,7 +464,10 @@ async function getObservationsFromEventsTableInternal<T>(
if (opts.select === "count") {
queryBuilder.selectFieldSet("count");
} else {
queryBuilder.selectFieldSet("base", "calculated");
queryBuilder.selectFieldSet(
selectToolData ? "base" : "baseWithoutTools",
"calculated",
);
if (selectIOAndMetadata) {
queryBuilder
.selectIO(
@@ -175,9 +175,7 @@ export const getObservationsForTrace = async <IncludeIO extends boolean>(
prompt_id,
prompt_name,
prompt_version,
tool_definitions,
tool_calls,
tool_call_names,
${includeIO === true ? "tool_definitions, tool_calls, tool_call_names," : ""}
created_at,
updated_at,
event_ts
@@ -506,6 +506,7 @@ export const getTraceById = async ({
clickhouseFeatureTag = "tracing",
preferredClickhouseService,
excludeInputOutput = false,
excludeMetadata = false,
}: {
traceId: string;
projectId: string;
@@ -516,6 +517,8 @@ export const getTraceById = async ({
preferredClickhouseService?: PreferredClickhouseService;
/** When true, sets input/output columns to empty in the query to reduce database load */
excludeInputOutput?: boolean;
/** When true, sets metadata column to empty in the query to reduce database load */
excludeMetadata?: boolean;
}) => {
const records = await measureAndReturn({
operationName: "getTraceById",
@@ -550,13 +553,14 @@ export const getTraceById = async ({
: renderingProps.truncated
? `leftUTF8(output, ${env.LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT})`
: "output";
const metadataColumn = excludeMetadata ? "'{}'" : "metadata";
const query = `
SELECT
id,
name as name,
user_id as user_id,
metadata as metadata,
${metadataColumn} as metadata,
release as release,
version as version,
project_id,
+40 -40
View File
@@ -43,8 +43,8 @@ importers:
specifier: ^19.2.4
version: 19.2.4(@types/node@24.10.4)(magicast@0.5.2)
turbo:
specifier: 2.8.20
version: 2.8.20
specifier: 2.9.5
version: 2.9.5
ee:
dependencies:
@@ -110,8 +110,8 @@ importers:
specifier: ^10.1.8
version: 10.1.8(eslint@9.39.4(jiti@2.6.1))
eslint-config-turbo:
specifier: 2.8.20
version: 2.8.20(eslint@9.39.4(jiti@2.6.1))(turbo@2.8.20)
specifier: 2.9.5
version: 2.9.5(eslint@9.39.4(jiti@2.6.1))(turbo@2.9.5)
eslint-plugin-only-warn:
specifier: ^1.1.0
version: 1.2.1
@@ -5391,33 +5391,33 @@ packages:
'@tsconfig/node16@1.0.4':
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
'@turbo/darwin-64@2.8.20':
resolution: {integrity: sha512-FQ9EX1xMU5nbwjxXxM3yU88AQQ6Sqc6S44exPRroMcx9XZHqqppl5ymJF0Ig/z3nvQNwDmz1Gsnvxubo+nXWjQ==}
'@turbo/darwin-64@2.9.5':
resolution: {integrity: sha512-qPxhKsLMQP+9+dsmPgAGidi5uNifD4AoAOnEnljab3Qgn0QZRR31Hp+/CgW3Ia5AanWj6JuLLTBYvuQj4mqTWg==}
cpu: [x64]
os: [darwin]
'@turbo/darwin-arm64@2.8.20':
resolution: {integrity: sha512-Gpyh9ATFGThD6/s9L95YWY54cizg/VRWl2B67h0yofG8BpHf67DFAh9nuJVKG7bY0+SBJDAo5cMur+wOl9YOYw==}
'@turbo/darwin-arm64@2.9.5':
resolution: {integrity: sha512-vkF/9F/l3aWd4bHxTui5Hh0F5xrTZ4e3rbBsc57zA6O8gNbmHN3B6eZ5psAIP2CnJRZ8ZxRjV3WZHeNXMXkPBw==}
cpu: [arm64]
os: [darwin]
'@turbo/linux-64@2.8.20':
resolution: {integrity: sha512-p2QxWUYyYUgUFG0b0kR+pPi8t7c9uaVlRtjTTI1AbCvVqkpjUfCcReBn6DgG/Hu8xrWdKLuyQFaLYFzQskZbcA==}
'@turbo/linux-64@2.9.5':
resolution: {integrity: sha512-z/Get5NUaUxm5HSGFqVMICDRjFNsCUhSc4wnFa/PP1QD0NXCjr7bu9a2EM6md/KMCBW0Qe393Ac+UM7/ryDDTw==}
cpu: [x64]
os: [linux]
'@turbo/linux-arm64@2.8.20':
resolution: {integrity: sha512-Gn5yjlZGLRZWarLWqdQzv0wMqyBNIdq1QLi48F1oY5Lo9kiohuf7BPQWtWxeNVS2NgJ1+nb/DzK1JduYC4AWOA==}
'@turbo/linux-arm64@2.9.5':
resolution: {integrity: sha512-jyBifaNoI5/NheyswomiZXJvjdAdvT7hDRYzQ4meP0DKGvpXUjnqsD+4/J2YSDQ34OHxFkL30FnSCUIVOh2PHw==}
cpu: [arm64]
os: [linux]
'@turbo/windows-64@2.8.20':
resolution: {integrity: sha512-vyaDpYk/8T6Qz5V/X+ihKvKFEZFUoC0oxYpC1sZanK6gaESJlmV3cMRT3Qhcg4D2VxvtC2Jjs9IRkrZGL+exLw==}
'@turbo/windows-64@2.9.5':
resolution: {integrity: sha512-ph24K5uPtvo7UfuyDXnBiB/8XvrO+RQWbbw5zkA/bVNoy9HDiNoIJJj3s62MxT9tjEb6DnPje5PXSz1UR7QAyg==}
cpu: [x64]
os: [win32]
'@turbo/windows-arm64@2.8.20':
resolution: {integrity: sha512-voicVULvUV5yaGXo0Iue13BcHGYW3u0VgqSbfQwBaHbpj1zLjYV4KIe+7fYIo6DO8FVUJzxFps3ODCQG/Wy2Qw==}
'@turbo/windows-arm64@2.9.5':
resolution: {integrity: sha512-6c5RccT/+iR39SdT1G5HyZaD2n57W77o+l0TTfxG/cVlhV94Acyg2gTQW7zUOhW1BeQpBjHzu9x8yVBZwrHh7g==}
cpu: [arm64]
os: [win32]
@@ -7151,8 +7151,8 @@ packages:
peerDependencies:
eslint: '>=7.0.0'
eslint-config-turbo@2.8.20:
resolution: {integrity: sha512-zT17KSqM4gTrChIocN+WfSdjg21QZTCDrcssylFab/wxu6S1EHDbt0JOna9b8rH5OKafZxDuFxKMV8BkulWShg==}
eslint-config-turbo@2.9.5:
resolution: {integrity: sha512-MwAmBEge6WOONTll4BAI1ZbbtD4PrhEaumcu6MNIdrwygMXw2Ne2eV8wxVpj6cnPgyUNE6iS6WdOoi/vPI4YFg==}
peerDependencies:
eslint: '>6.6.0'
turbo: '>2.0.0'
@@ -7239,8 +7239,8 @@ packages:
peerDependencies:
eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7
eslint-plugin-turbo@2.8.20:
resolution: {integrity: sha512-sMDremg73XbX+Imh7iDRS3TAro5jIf7CdzSqrT50wCGhClbiUvmWcLomnX+ldUbfTy+OvtqOeePvTivJMR87eQ==}
eslint-plugin-turbo@2.9.5:
resolution: {integrity: sha512-mwsR2dVy35crMS39TKSKG1xMxfn9M9t/sHXg70GAVqJxoFhsmM1XOcVGQ5w2z7GFUuMP70xUcpSendFzyuuSlQ==}
peerDependencies:
eslint: '>6.6.0'
turbo: '>2.0.0'
@@ -10805,8 +10805,8 @@ packages:
ttl-set@1.0.0:
resolution: {integrity: sha512-2fuHn/UR+8Z9HK49r97+p2Ru1b5Eewg2QqPrU14BVCQ9QoyU3+vLLZk2WEiyZ9sgJh6W8G1cZr9I2NBLywAHrA==}
turbo@2.8.20:
resolution: {integrity: sha512-Rb4qk5YT8RUwwdXtkLpkVhNEe/lor6+WV7S5tTlLpxSz6MjV5Qi8jGNn4gS6NAvrYGA/rNrE6YUQM85sCZUDbQ==}
turbo@2.9.5:
resolution: {integrity: sha512-JXNkRe6H6MjSlk5UQRTjyoKX5YN2zlc2632xcSlSFBao5yvbMWTpv9SNolOZlZmUlcDOHuszPLItbKrvcXnnZA==}
hasBin: true
type-check@0.4.0:
@@ -16572,22 +16572,22 @@ snapshots:
'@tsconfig/node16@1.0.4': {}
'@turbo/darwin-64@2.8.20':
'@turbo/darwin-64@2.9.5':
optional: true
'@turbo/darwin-arm64@2.8.20':
'@turbo/darwin-arm64@2.9.5':
optional: true
'@turbo/linux-64@2.8.20':
'@turbo/linux-64@2.9.5':
optional: true
'@turbo/linux-arm64@2.8.20':
'@turbo/linux-arm64@2.9.5':
optional: true
'@turbo/windows-64@2.8.20':
'@turbo/windows-64@2.9.5':
optional: true
'@turbo/windows-arm64@2.8.20':
'@turbo/windows-arm64@2.9.5':
optional: true
'@tybys/wasm-util@0.10.1':
@@ -18563,11 +18563,11 @@ snapshots:
dependencies:
eslint: 9.39.4(jiti@2.6.1)
eslint-config-turbo@2.8.20(eslint@9.39.4(jiti@2.6.1))(turbo@2.8.20):
eslint-config-turbo@2.9.5(eslint@9.39.4(jiti@2.6.1))(turbo@2.9.5):
dependencies:
eslint: 9.39.4(jiti@2.6.1)
eslint-plugin-turbo: 2.8.20(eslint@9.39.4(jiti@2.6.1))(turbo@2.8.20)
turbo: 2.8.20
eslint-plugin-turbo: 2.9.5(eslint@9.39.4(jiti@2.6.1))(turbo@2.9.5)
turbo: 2.9.5
eslint-import-resolver-node@0.3.9:
dependencies:
@@ -18723,11 +18723,11 @@ snapshots:
string.prototype.matchall: 4.0.12
string.prototype.repeat: 1.0.0
eslint-plugin-turbo@2.8.20(eslint@9.39.4(jiti@2.6.1))(turbo@2.8.20):
eslint-plugin-turbo@2.9.5(eslint@9.39.4(jiti@2.6.1))(turbo@2.9.5):
dependencies:
dotenv: 16.0.3
eslint: 9.39.4(jiti@2.6.1)
turbo: 2.8.20
turbo: 2.9.5
eslint-scope@5.1.1:
dependencies:
@@ -23029,14 +23029,14 @@ snapshots:
dependencies:
fast-fifo: 1.3.2
turbo@2.8.20:
turbo@2.9.5:
optionalDependencies:
'@turbo/darwin-64': 2.8.20
'@turbo/darwin-arm64': 2.8.20
'@turbo/linux-64': 2.8.20
'@turbo/linux-arm64': 2.8.20
'@turbo/windows-64': 2.8.20
'@turbo/windows-arm64': 2.8.20
'@turbo/darwin-64': 2.9.5
'@turbo/darwin-arm64': 2.9.5
'@turbo/linux-64': 2.9.5
'@turbo/linux-arm64': 2.9.5
'@turbo/windows-64': 2.9.5
'@turbo/windows-arm64': 2.9.5
type-check@0.4.0:
dependencies:
+9
View File
@@ -70,6 +70,15 @@ minimumReleaseAgeExclude:
- "@prisma/debug@6.19.3"
- "@prisma/fetch-engine@6.19.3"
- "@prisma/get-platform@6.19.3"
- "eslint-config-turbo@2.9.5"
- "eslint-plugin-turbo@2.9.5"
- "turbo@2.9.5"
- "@turbo/darwin-64@2.9.5"
- "@turbo/darwin-arm64@2.9.5"
- "@turbo/windows-64@2.9.5"
- "@turbo/windows-arm64@2.9.5"
- "@turbo/linux-64@2.9.5"
- "@turbo/linux-arm64@2.9.5"
allowBuilds:
"@prisma/client": true
"@prisma/engines": true
+4 -4
View File
@@ -7,7 +7,7 @@ RUN apk update && apk upgrade --no-cache libcrypto3 libssl3 libc6-compat busybox
FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine AS build-base
# Pin turbo to avoid nondeterministic prune output from future patch releases.
RUN npm install turbo@2.8.20 --global
RUN npm install turbo@2.9.5 --global
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
@@ -17,7 +17,7 @@ FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine AS runtime-base
# Remove build-only package managers. npm stays here because the runner stage
# uses it to install prisma and optional dd-trace before removing it.
RUN rm -rf /usr/local/lib/node_modules/corepack && \
rm -f /usr/local/bin/corepack /usr/local/bin/pnpm /usr/local/bin/pnpx /usr/local/bin/yarn /usr/local/bin/yarnpkg
rm -f /usr/local/bin/corepack /usr/local/bin/yarn /usr/local/bin/yarnpkg
FROM --platform=${TARGETPLATFORM:-linux/amd64} build-base AS pruner
@@ -122,7 +122,7 @@ ARG GID=1001
RUN addgroup --system --gid ${GID} nodejs
RUN adduser --system --uid ${UID} nextjs
RUN npm install -g --no-package-lock --no-save prisma@6.17.1
RUN npm install -g --no-package-lock --no-save prisma@6.19.3
# Install dd-trace only if NEXT_PUBLIC_LANGFUSE_CLOUD_REGION is configured
ARG NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
@@ -130,7 +130,7 @@ RUN if [ -n "$NEXT_PUBLIC_LANGFUSE_CLOUD_REGION" ]; then \
npm install --no-package-lock --no-save dd-trace@5.65.0; \
fi
# Runtime images do not need npm once explicit runtime tools are installed.
# npm is only used for the installs above; remove it from the final runtime image.
RUN rm -rf /usr/local/lib/node_modules/npm && \
rm -f /usr/local/bin/npm /usr/local/bin/npx
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.165.0",
"version": "3.167.0",
"private": true,
"license": "MIT",
"engines": {
+13
View File
@@ -5639,6 +5639,19 @@ paths:
required: true
schema:
type: string
- name: fields
in: query
description: >-
Comma-separated list of fields to include in the response. Available
field groups: 'core' (always included), 'io' (input, output,
metadata), 'scores', 'observations', 'metrics'. If not specified,
all fields are returned. Example: 'core,scores,metrics'. Note:
Excluded 'observations' or 'scores' fields return empty arrays;
excluded 'metrics' returns -1 for 'totalCost' and 'latency'.
required: false
schema:
type: string
nullable: true
responses:
'200':
description: ''
@@ -320,21 +320,17 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
).toBe(true);
});
it("should return 404 when getting an ARCHIVED dataset item by id", async () => {
it("should return archived dataset item when getting by id", async () => {
const datasetName = `dataset-archived-by-id-${v4()}`;
// Create dataset
await makeZodVerifiedAPICall(
PostDatasetsV1Response,
"POST",
"/api/public/datasets",
{
name: datasetName,
},
{ name: datasetName },
auth,
);
// Create an archived dataset item
const archivedItem = await makeZodVerifiedAPICall(
PostDatasetItemsV1Response,
"POST",
@@ -350,16 +346,29 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
expect(archivedItem.status).toBe(200);
expect(archivedItem.body.status).toBe("ARCHIVED");
// Try to get the archived item by id - should return 404
const getArchivedItem = await makeAPICall(
const getArchivedItem = await makeZodVerifiedAPICall(
GetDatasetItemV1Response,
"GET",
`/api/public/dataset-items/archived-item-by-id`,
undefined,
auth,
);
expect(getArchivedItem.status).toBe(404);
expect(getArchivedItem.status).toBe(200);
expect(getArchivedItem.body.id).toBe("archived-item-by-id");
expect(getArchivedItem.body.status).toBe("ARCHIVED");
});
it("should return active dataset item when getting by id", async () => {
const datasetName = `dataset-active-by-id-${v4()}`;
await makeZodVerifiedAPICall(
PostDatasetsV1Response,
"POST",
"/api/public/datasets",
{ name: datasetName },
auth,
);
// Create an active item to verify GET still works for active items
const activeItem = await makeZodVerifiedAPICall(
PostDatasetItemsV1Response,
"POST",
@@ -374,7 +383,6 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
);
expect(activeItem.status).toBe(200);
// Get the active item by id - should succeed
const getActiveItem = await makeZodVerifiedAPICall(
GetDatasetItemV1Response,
"GET",
@@ -1,6 +1,7 @@
import {
CTEQueryBuilder,
EventsAggregationQueryBuilder,
EventsQueryBuilder,
} from "@langfuse/shared/src/server";
describe("CTEQueryBuilder", () => {
@@ -121,3 +122,27 @@ describe("CTEQueryBuilder", () => {
expect(params.param3).toBe("value3");
});
});
describe("EventsQueryBuilder", () => {
it("should allow list queries to omit tool payload columns", () => {
const slimQuery = new EventsQueryBuilder({
projectId: "test-project",
})
.selectFieldSet("baseWithoutTools", "calculated")
.buildWithParams().query;
const defaultQuery = new EventsQueryBuilder({
projectId: "test-project",
})
.selectFieldSet("base", "calculated")
.buildWithParams().query;
expect(slimQuery).not.toContain('e.tool_definitions as "tool_definitions"');
expect(slimQuery).not.toContain('e.tool_calls as "tool_calls"');
expect(slimQuery).not.toContain('e.tool_call_names as "tool_call_names"');
expect(defaultQuery).toContain('e.tool_definitions as "tool_definitions"');
expect(defaultQuery).toContain('e.tool_calls as "tool_calls"');
expect(defaultQuery).toContain('e.tool_call_names as "tool_call_names"');
});
});
@@ -1,12 +1,25 @@
/** @jest-environment node */
jest.mock("@langfuse/shared/src/server", () => {
const actual = jest.requireActual("@langfuse/shared/src/server");
return {
...actual,
fetchLLMCompletion: jest.fn(),
};
});
import type { Session } from "next-auth";
import { LLMAdapter } from "@langfuse/shared";
import { prisma } from "@langfuse/shared/src/db";
import { appRouter } from "@/src/server/api/root";
import { createInnerTRPCContext } from "@/src/server/api/trpc";
import { decrypt } from "@langfuse/shared/encryption";
import { createOrgProjectAndApiKey } from "@langfuse/shared/src/server";
import {
createOrgProjectAndApiKey,
fetchLLMCompletion,
} from "@langfuse/shared/src/server";
const mockFetchLLMCompletion = jest.mocked(fetchLLMCompletion);
describe("llmApiKey.all RPC", () => {
let projectId: string;
@@ -49,6 +62,7 @@ describe("llmApiKey.all RPC", () => {
const setup = await createOrgProjectAndApiKey();
projectId = setup.projectId;
orgId = setup.orgId;
mockFetchLLMCompletion.mockReset().mockResolvedValue({});
session = {
expires: "1",
@@ -187,7 +201,7 @@ describe("llmApiKey.all RPC", () => {
).rejects.toThrow("User does not have access to this resource or action");
});
it("should require llmApiKeys:update access for testing an existing llm api key", async () => {
it("should require llmApiKeys:create access for testing an existing llm api key", async () => {
await caller.llmApiKey.create({
projectId,
provider: "openai",
@@ -216,6 +230,116 @@ describe("llmApiKey.all RPC", () => {
).rejects.toThrow("User does not have access to this resource or action");
});
it("should block testUpdate when the base URL changes without a new secret key", async () => {
await caller.llmApiKey.create({
projectId,
provider: "openai",
adapter: LLMAdapter.OpenAI,
secretKey: "sk-original",
baseURL: "https://api.openai.com/v1",
});
const existingKey = await prisma.llmApiKeys.findFirstOrThrow({
where: {
projectId,
provider: "openai",
},
});
const result = await caller.llmApiKey.testUpdate({
id: existingKey.id,
projectId,
provider: "openai",
adapter: LLMAdapter.OpenAI,
baseURL: "https://attacker.example.com/v1",
});
expect(result).toEqual({
success: false,
error: "Secret key is required when changing the base URL",
});
expect(mockFetchLLMCompletion).not.toHaveBeenCalled();
});
it("should allow testUpdate without a new secret key when the base URL is unchanged", async () => {
const existingExtraHeaders = {
Authorization: "Bearer stored-token",
"X-Custom-Header": "stored-value",
};
await caller.llmApiKey.create({
projectId,
provider: "openai",
adapter: LLMAdapter.OpenAI,
secretKey: "sk-original",
baseURL: "https://api.openai.com/v1",
extraHeaders: existingExtraHeaders,
});
const existingKey = await prisma.llmApiKeys.findFirstOrThrow({
where: {
projectId,
provider: "openai",
},
});
const result = await caller.llmApiKey.testUpdate({
id: existingKey.id,
projectId,
provider: "openai",
adapter: LLMAdapter.OpenAI,
baseURL: "https://api.openai.com/v1",
});
expect(result).toEqual({ success: true });
expect(mockFetchLLMCompletion).toHaveBeenCalledTimes(1);
const llmConnection = mockFetchLLMCompletion.mock.calls[0][0].llmConnection;
expect(llmConnection.baseURL).toBe("https://api.openai.com/v1");
expect(decrypt(llmConnection.secretKey)).toBe("sk-original");
expect(JSON.parse(decrypt(llmConnection.extraHeaders))).toEqual(
existingExtraHeaders,
);
});
it("should allow testUpdate when the base URL changes and a new secret key is provided", async () => {
const existingExtraHeaders = {
Authorization: "Bearer stored-token",
"X-Custom-Header": "stored-value",
};
await caller.llmApiKey.create({
projectId,
provider: "openai",
adapter: LLMAdapter.OpenAI,
secretKey: "sk-original",
baseURL: "https://api.openai.com/v1",
extraHeaders: existingExtraHeaders,
});
const existingKey = await prisma.llmApiKeys.findFirstOrThrow({
where: {
projectId,
provider: "openai",
},
});
const result = await caller.llmApiKey.testUpdate({
id: existingKey.id,
projectId,
provider: "openai",
adapter: LLMAdapter.OpenAI,
secretKey: "sk-rotated",
baseURL: "https://new-endpoint.example.com/v1",
});
expect(result).toEqual({ success: true });
expect(mockFetchLLMCompletion).toHaveBeenCalledTimes(1);
const llmConnection = mockFetchLLMCompletion.mock.calls[0][0].llmConnection;
expect(llmConnection.baseURL).toBe("https://new-endpoint.example.com/v1");
expect(decrypt(llmConnection.secretKey)).toBe("sk-rotated");
expect(llmConnection.extraHeaders).toBeUndefined();
});
it("should create and update an llm api key", async () => {
const secret = "test-secret";
const provider = "openai";
@@ -69,7 +69,7 @@ const setupTriggerAndAction = async (projectId: string) => {
id: v4(),
projectId: projectId,
eventSource: "prompt",
eventActions: ["updated"],
eventActions: [],
filter: [],
status: "ACTIVE",
},
@@ -220,6 +220,139 @@ describe("/api/public/traces API Endpoint", () => {
);
});
it("should fetch a trace with core-only fields when fields=core", async () => {
const traceId = randomUUID();
const createdTrace = createTrace({
id: traceId,
name: "trace-core-only",
user_id: "user-1",
project_id: projectId,
metadata: { key: "value" },
input: JSON.stringify({ prompt: "test" }),
output: JSON.stringify({ response: "test response" }),
});
const observation = createObservation({
trace_id: traceId,
project_id: projectId,
name: "test-observation",
end_time: new Date().getTime(),
start_time: new Date().getTime() - 1000,
cost_details: { input: 0.02, output: 0.03, total: 0.05 },
});
const score = createTraceScore({
trace_id: traceId,
project_id: projectId,
name: "test-score",
value: 0.8,
});
await createTracesCh([createdTrace]);
await createObservationsCh([observation]);
await createScoresCh([score]);
const trace = await makeZodVerifiedAPICall(
GetTraceV1Response,
"GET",
`/api/public/traces/${traceId}?fields=core`,
);
expect(trace.body.id).toBe(traceId);
expect(trace.body.input).toBeNull();
expect(trace.body.output).toBeNull();
expect(trace.body.metadata).toEqual({});
expect(trace.body.observations).toEqual([]);
expect(trace.body.scores).toEqual([]);
expect(trace.body.totalCost).toBe(-1);
expect(trace.body.latency).toBe(-1);
});
it("should fetch a trace with core,scores,metrics fields", async () => {
const traceId = randomUUID();
const createdTrace = createTrace({
id: traceId,
name: "trace-with-scores-metrics",
project_id: projectId,
input: JSON.stringify({ prompt: "test" }),
output: JSON.stringify({ response: "test response" }),
});
const observation = createObservation({
trace_id: traceId,
project_id: projectId,
name: "test-observation",
end_time: new Date().getTime(),
start_time: new Date().getTime() - 1000,
cost_details: { input: 0.02, output: 0.03, total: 0.05 },
input: "observation input",
output: "observation output",
});
const score = createTraceScore({
trace_id: traceId,
project_id: projectId,
name: "test-score",
value: 0.8,
});
await createTracesCh([createdTrace]);
await createObservationsCh([observation]);
await createScoresCh([score]);
const trace = await makeZodVerifiedAPICall(
GetTraceV1Response,
"GET",
`/api/public/traces/${traceId}?fields=core,scores,metrics`,
);
expect(trace.body.id).toBe(traceId);
expect(trace.body.input).toBeNull();
expect(trace.body.output).toBeNull();
expect(trace.body.observations).toEqual([]);
expect(trace.body.scores).toHaveLength(1);
expect(trace.body.totalCost).toBe(0.05);
expect(trace.body.latency).toBeCloseTo(1);
});
it("should return all fields when fields param contains only invalid groups", async () => {
const traceId = randomUUID();
const createdTrace = createTrace({
id: traceId,
name: "trace-invalid-fields",
project_id: projectId,
input: JSON.stringify({ prompt: "test" }),
output: JSON.stringify({ response: "test response" }),
metadata: { key: "value" },
});
const observation = createObservation({
trace_id: traceId,
project_id: projectId,
name: "test-observation",
end_time: new Date().getTime(),
start_time: new Date().getTime() - 1000,
cost_details: { input: 0.01, output: 0.02, total: 0.03 },
});
await createTracesCh([createdTrace]);
await createObservationsCh([observation]);
const trace = await makeZodVerifiedAPICall(
GetTraceV1Response,
"GET",
`/api/public/traces/${traceId}?fields=invalid_group,also_invalid`,
);
// All invalid fields should fall back to returning all field groups
expect(trace.body.id).toBe(traceId);
expect(trace.body.input).not.toBeNull();
expect(trace.body.output).not.toBeNull();
expect(trace.body.observations).toHaveLength(1);
expect(trace.body.totalCost).toBeGreaterThanOrEqual(0);
expect(trace.body.latency).toBeGreaterThanOrEqual(0);
});
it("should fetch all traces", async () => {
const timestamp = new Date();
const createdTrace = createTrace({
@@ -0,0 +1,126 @@
import { ChatMessageRole, ChatMessageType } from "@langfuse/shared";
import { createMessageSearchController } from "./controller";
describe("message search controller", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
const commitQuery = (
controller: ReturnType<typeof createMessageSearchController>,
query: string,
) => {
controller.setQueryInput(query);
jest.runAllTimers();
return controller.getSnapshot().matches;
};
it("finds all occurrences of a query", () => {
const controller = createMessageSearchController(["page-1"]);
controller.registerPageMessages("page-1", [
{
id: "message-1",
type: ChatMessageType.System,
role: ChatMessageRole.System,
content:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ",
},
{
id: "message-2",
type: ChatMessageType.User,
role: ChatMessageRole.User,
content:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ",
},
]);
expect(commitQuery(controller, "Lorem")).toEqual([
expect.objectContaining({
messageId: "message-1",
from: 0,
to: 5,
}),
expect.objectContaining({
messageId: "message-2",
from: 0,
to: 5,
}),
]);
expect(commitQuery(controller, "dolor")).toEqual([
expect.objectContaining({
messageId: "message-1",
from: 12,
to: 17,
}),
expect.objectContaining({
messageId: "message-1",
from: 103,
to: 108,
}),
expect.objectContaining({
messageId: "message-2",
from: 12,
to: 17,
}),
expect.objectContaining({
messageId: "message-2",
from: 103,
to: 108,
}),
]);
});
// Regression test for https://github.com/langfuse/langfuse/issues/13002
it("matches fullwidth and halfwidth variants consistently", () => {
const controller = createMessageSearchController(["page-1"]);
controller.registerPageMessages("page-1", [
{
id: "message-1",
type: ChatMessageType.System,
role: ChatMessageRole.System,
content:
"Langfuse is an LLM observability platform. Langfuse is also great.",
},
]);
expect(commitQuery(controller, "Langfuse")).toEqual([
expect.objectContaining({ from: 0, to: 8 }),
expect.objectContaining({ from: 43, to: 51 }),
]);
expect(commitQuery(controller, "Langfuse")).toEqual([
expect.objectContaining({ from: 0, to: 8 }),
expect.objectContaining({ from: 43, to: 51 }),
]);
expect(commitQuery(controller, "langfuse")).toEqual([
expect.objectContaining({ from: 0, to: 8 }),
expect.objectContaining({ from: 43, to: 51 }),
]);
});
// Regression test for https://github.com/langfuse/langfuse/issues/13002
it("returns original document offsets for compatibility character matches", () => {
const controller = createMessageSearchController(["page-1"]);
controller.registerPageMessages("page-1", [
{
id: "message-1",
type: ChatMessageType.System,
role: ChatMessageRole.System,
content: "高さ180㌢の棚",
},
]);
expect(commitQuery(controller, "センチ")).toEqual([
expect.objectContaining({ from: 5, to: 6 }),
]);
});
});
@@ -3,11 +3,14 @@
import capitalize from "lodash/capitalize";
import { type ReactCodeMirrorRef } from "@uiw/react-codemirror";
import { ChatMessageType, type ChatMessageWithId } from "@langfuse/shared";
import { EditorState } from "@codemirror/state";
import { SearchQuery } from "@codemirror/search";
import { type RefObject } from "react";
import {
applyCodeMirrorSearchQuery,
selectCodeMirrorRange,
unsetActiveSearchMarkCodeMirrorRange,
setActiveSearchMarkCodeMirrorRange,
} from "@/src/components/editor";
export type MessageSearchMatch = {
@@ -136,7 +139,11 @@ function buildMatches(state: MessageSearchState) {
return [];
}
const lowerQuery = searchQuery.toLocaleLowerCase();
const codeMirrorSearchQuery = new SearchQuery({
search: searchQuery,
caseSensitive: false,
literal: true,
});
const allMatches: MessageSearchMatch[] = [];
for (const [pageIndex, pageId] of state.pageIds.entries()) {
@@ -151,10 +158,13 @@ function buildMatches(state: MessageSearchState) {
continue;
}
const lowerText = text.toLocaleLowerCase();
let from = lowerText.indexOf(lowerQuery);
const cursor = codeMirrorSearchQuery.getCursor(
EditorState.create({ doc: text }),
);
let match = cursor.next();
while (from !== -1) {
while (!match.done) {
const { from, to } = match.value;
const label = getMessageSearchLabel(message, messageIndex);
const pageLabel = state.getPageLabel?.(pageId, pageIndex);
const matchWithoutKey = {
@@ -163,7 +173,7 @@ function buildMatches(state: MessageSearchState) {
label,
locationLabel: pageLabel ? `${pageLabel} · ${label}` : label,
from,
to: from + searchQuery.length,
to,
text,
};
@@ -172,10 +182,7 @@ function buildMatches(state: MessageSearchState) {
...matchWithoutKey,
});
from = lowerText.indexOf(
lowerQuery,
from + Math.max(1, lowerQuery.length),
);
match = cursor.next();
}
}
}
@@ -269,17 +276,33 @@ export function createMessageSearchController(
inline: "center",
});
const messageTarget = messageTargets.get(
getMessageTargetKey(activeMatch.pageId, activeMatch.messageId),
let activeMessageTarget: MessageSearchMessageTarget | null = null;
const inactiveMessageTargets: MessageSearchMessageTarget[] = [];
const activeMessageTargetKey = getMessageTargetKey(
activeMatch.pageId,
activeMatch.messageId,
);
messageTarget?.rowRef.current?.scrollIntoView({
for (const [key, target] of messageTargets.entries()) {
if (key === activeMessageTargetKey) {
activeMessageTarget = target;
} else {
inactiveMessageTargets.push(target);
}
}
for (const target of inactiveMessageTargets) {
unsetActiveSearchMarkCodeMirrorRange(target?.editorRef);
}
activeMessageTarget?.rowRef.current?.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "nearest",
});
selectCodeMirrorRange(messageTarget?.editorRef, {
setActiveSearchMarkCodeMirrorRange(activeMessageTarget?.editorRef, {
from: activeMatch.from,
to: activeMatch.to,
});
@@ -318,7 +341,7 @@ export function createMessageSearchController(
const refreshSearchResults = (shouldSyncEditors: boolean) => {
const activeMatchChanged = recomputeMatches();
if (shouldSyncEditors) {
if (shouldSyncEditors || activeMatchChanged) {
syncEditorsToQuery();
}
@@ -21,7 +21,6 @@ export function MessageSearchToolbar({ className }: { className?: string }) {
openRequestCount,
queryInput,
matches,
activeMatch,
activeMatchIndex,
openSearch,
closeSearch,
@@ -109,9 +108,6 @@ export function MessageSearchToolbar({ className }: { className?: string }) {
onClick={nextMatch}
disabled={matches.length === 0}
/>
<div className="text-muted-foreground hidden max-w-48 truncate px-1 text-xs lg:block">
{activeMatch?.locationLabel ?? "No matches"}
</div>
<IconButton icon={X} label="Close search" onClick={closeSearch} />
</div>
);
+185 -14
View File
@@ -6,7 +6,7 @@ import CodeMirror, {
ViewPlugin,
type ViewUpdate,
} from "@uiw/react-codemirror";
import { RangeSetBuilder } from "@codemirror/state";
import { RangeSetBuilder, StateEffect, StateField } from "@codemirror/state";
import { SearchQuery, search, setSearchQuery } from "@codemirror/search";
import { json, jsonParseLinter } from "@codemirror/lang-json";
import { linter, type Diagnostic } from "@codemirror/lint";
@@ -165,6 +165,142 @@ const bidiSupport = [
),
];
// Add custom search highlight decoration using the same class names as the default search match decorations
// See: https://github.com/codemirror/search/blob/36e8f21e070d471fcbe2e2f338ef4e647b492ba8/src/search.ts#L396
const searchMatchMark = Decoration.mark({
class: "cm-searchMatch",
});
const selectedSearchMatchMark = Decoration.mark({
class: "cm-searchMatch cm-searchMatch-selected",
});
const setSearchHighlightMarks = StateEffect.define<
{
from: number;
to: number;
}[]
>({
map: (ranges, change) =>
ranges.map(({ from, to }) => ({
from: change.mapPos(from),
to: change.mapPos(to),
})),
});
const setSelectedSearchHighlightMark = StateEffect.define<{
from: number;
to: number;
}>({
map: ({ from, to }, change) => ({
from: change.mapPos(from),
to: change.mapPos(to),
}),
});
const unsetSelectedSearchHighlightMark = StateEffect.define({});
const searchHighlightingSupport = StateField.define<DecorationSet>({
create() {
return Decoration.none;
},
update(decos, tr) {
decos = decos.map(tr.changes);
for (const effect of tr.effects) {
if (effect.is(setSearchHighlightMarks)) {
// Remove all existing search highlights
decos = decos.update({
filter: (from, to, decoration) => {
return !decoration.spec.class?.includes("cm-searchMatch");
},
});
decos = decos.update({
add: effect.value.map(({ from, to }) =>
searchMatchMark.range(from, to),
),
});
}
if (effect.is(unsetSelectedSearchHighlightMark)) {
let selectedRange: { from: number; to: number } | null = null;
decos = decos.update({
filter: (from, to, decoration) => {
if (decoration.spec.class?.includes("cm-searchMatch-selected")) {
selectedRange = { from, to };
return false;
}
return true;
},
});
// Reassign the value and cast it because typescript infers it to be always null,
// not recognizing it to be assigned in the filter above.
selectedRange = selectedRange as {
from: number;
to: number;
} | null;
if (selectedRange) {
decos = decos.update({
add: [searchMatchMark.range(selectedRange.from, selectedRange.to)],
});
}
}
if (effect.is(setSelectedSearchHighlightMark)) {
// Remove normal search match mark from the selected range,
// otherwise there will _both_ a normal and selected highlight on the active match.
decos = decos.update({
filter: (from, to, decoration) => {
if (from === effect.value.from && to === effect.value.to) {
return !decoration.spec.class?.includes("cm-searchMatch");
}
return true;
},
});
let previousSelectedRange: { from: number; to: number } | null = null;
// Make the existing selected search highlight a normal search highlight
decos = decos.update({
filter: (from, to, decoration) => {
if (decoration.spec.class?.includes("cm-searchMatch-selected")) {
previousSelectedRange = { from, to };
return false;
}
return true;
},
});
// Reassign the value and cast it because typescript infers it to be always null,
// not recognizing it to be assigned in the filter above.
previousSelectedRange = previousSelectedRange as {
from: number;
to: number;
} | null;
decos = decos.update({
add: [
...(previousSelectedRange
? [
searchMatchMark.range(
previousSelectedRange.from,
previousSelectedRange.to,
),
]
: []),
selectedSearchMatchMark.range(effect.value.from, effect.value.to),
].toSorted((a, b) => a.from - b.from),
});
}
}
return decos;
},
provide: (f) => EditorView.decorations.from(f),
});
export function applyCodeMirrorSearchQuery(
editorRef: RefObject<ReactCodeMirrorRef | null> | undefined,
searchValue: string,
@@ -174,18 +310,30 @@ export function applyCodeMirrorSearchQuery(
return;
}
const searchQuery = new SearchQuery({
search: searchValue,
caseSensitive: false,
literal: true,
});
view.dispatch({
effects: setSearchQuery.of(
new SearchQuery({
search: searchValue,
caseSensitive: false,
literal: true,
}),
),
effects: setSearchQuery.of(searchQuery),
});
const cursor = searchQuery.getCursor(view.state);
const matchRanges: { from: number; to: number }[] = [];
let current = cursor.next();
while (!current.done) {
matchRanges.push(current.value);
current = cursor.next();
}
view.dispatch({
effects: setSearchHighlightMarks.of(matchRanges),
});
}
export function selectCodeMirrorRange(
export function setActiveSearchMarkCodeMirrorRange(
editorRef: RefObject<ReactCodeMirrorRef | null> | undefined,
range: { from: number; to: number } | null,
) {
@@ -195,11 +343,23 @@ export function selectCodeMirrorRange(
}
view.dispatch({
selection: {
anchor: range.from,
head: range.to,
},
scrollIntoView: true,
effects: [
setSelectedSearchHighlightMark.of(range),
EditorView.scrollIntoView(range.from),
],
});
}
export function unsetActiveSearchMarkCodeMirrorRange(
editorRef: RefObject<ReactCodeMirrorRef | null> | undefined,
) {
const view = editorRef?.current?.view;
if (!view) {
return;
}
view.dispatch({
effects: unsetSelectedSearchHighlightMark.of(null),
});
}
@@ -268,6 +428,7 @@ export function CodeMirrorEditor({
}}
lang={mode === "json" ? "json" : undefined}
extensions={[
searchHighlightingSupport,
search(),
// RTL/bidi support - must be early for proper line decoration
...bidiSupport,
@@ -277,6 +438,16 @@ export function CodeMirrorEditor({
outline: "none",
},
}),
// Update search match highlight styles
EditorView.theme({
".cm-searchMatch.cm-searchMatch": {
backgroundColor: "hsl(var(--find-match-background))",
},
".cm-searchMatch.cm-searchMatch-selected": {
backgroundColor: "hsl(var(--find-match-selected-background))",
color: "hsl(var(--find-match-selected-foreground))",
},
}),
// Hide gutter when lineNumbers is false
// Fix missing gutter border
...(!lineNumbers
@@ -11,6 +11,7 @@ interface ResizableDesktopLayoutProps {
mainContent: ReactNode;
sidebarContent: ReactNode;
open: boolean;
showHandle?: boolean;
defaultMainSize?: number;
defaultSidebarSize?: number;
minMainSize?: number;
@@ -36,6 +37,7 @@ export function ResizableDesktopLayout({
mainContent,
sidebarContent,
open,
showHandle = true,
defaultMainSize = 70,
defaultSidebarSize = 30,
minMainSize = 30,
@@ -108,7 +110,9 @@ export function ResizableDesktopLayout({
{sidebarContent}
</ResizablePanel>
)}
{sidebarPosition === "left" && open && <ResizableHandle withHandle />}
{sidebarPosition === "left" && open && showHandle && (
<ResizableHandle withHandle />
)}
<ResizablePanel
id={MAIN_PANEL_ID}
defaultSize={`${defaultMainSize}%`}
@@ -121,7 +125,9 @@ export function ResizableDesktopLayout({
{mainContent}
</div>
</ResizablePanel>
{sidebarPosition === "right" && open && <ResizableHandle withHandle />}
{sidebarPosition === "right" && open && showHandle && (
<ResizableHandle withHandle />
)}
{sidebarPosition === "right" && (
<ResizablePanel
id={SIDEBAR_PANEL_ID}
@@ -59,6 +59,7 @@ export function ResizableContent({ children }: PropsWithChildren) {
mainContent={children}
sidebarContent={<SupportDrawer />}
open={open}
showHandle={false}
defaultMainSize={70}
defaultSidebarSize={30}
minMainSize={30}
@@ -129,6 +129,7 @@ export function ObservationDetailView({
setJsonViewPreference,
jsonBetaEnabled,
setJsonBetaEnabled,
isPeekMode,
} = useViewPreferences();
// Map jsonViewPreference to currentView format expected by child components
@@ -480,7 +481,7 @@ export function ObservationDetailView({
"userId",
]}
localStorageSuffix="ObservationPreview"
disableUrlPersistence
disableUrlPersistence={isPeekMode}
/>
</div>
</TabsBarContent>
@@ -93,6 +93,7 @@ export function TraceDetailView({
setJsonViewPreference,
jsonBetaEnabled,
setJsonBetaEnabled,
isPeekMode,
} = useViewPreferences();
// Map jsonViewPreference to currentView format expected by child components
@@ -421,7 +422,7 @@ export function TraceDetailView({
traceId={trace.id}
hiddenColumns={["traceName", "jobConfigurationId", "userId"]}
localStorageSuffix="TracePreview"
disableUrlPersistence
disableUrlPersistence={isPeekMode}
/>
</div>
</TabsBarContent>
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.165.0";
export const VERSION = "v3.167.0";
+3
View File
@@ -399,6 +399,7 @@ export const env = createEnv({
.enum(["true", "false"])
.default("false"),
LANGFUSE_API_TRACES_DEFAULT_FIELDS: z.string().optional(),
LANGFUSE_API_TRACEBYID_DEFAULT_FIELDS: z.string().optional(),
// Events table migration
LANGFUSE_ENABLE_EVENTS_TABLE_OBSERVATIONS: z
@@ -781,6 +782,8 @@ export const env = createEnv({
process.env.LANGFUSE_API_TRACES_REJECT_NO_DATE_RANGE,
LANGFUSE_API_TRACES_DEFAULT_FIELDS:
process.env.LANGFUSE_API_TRACES_DEFAULT_FIELDS,
LANGFUSE_API_TRACEBYID_DEFAULT_FIELDS:
process.env.LANGFUSE_API_TRACEBYID_DEFAULT_FIELDS,
// Events table migration
LANGFUSE_ENABLE_EVENTS_TABLE_OBSERVATIONS:
process.env.LANGFUSE_ENABLE_EVENTS_TABLE_OBSERVATIONS,
@@ -17,6 +17,7 @@ import { TraceAnnotationProcessor } from "./processors/TraceAnnotationProcessor"
import { SessionAnnotationProcessor } from "./processors/SessionAnnotationProcessor";
import { ObjectNotFoundCard } from "@/src/components/ui/object-not-found-card";
import { useV4Beta } from "@/src/features/events/hooks/useV4Beta";
import { useSession } from "next-auth/react";
export const AnnotationQueueItemPage: React.FC<{
annotationQueueId: string;
@@ -25,6 +26,8 @@ export const AnnotationQueueItemPage: React.FC<{
queryItemId?: string;
}> = ({ annotationQueueId, projectId, view, queryItemId }) => {
const router = useRouter();
const { status: sessionStatus } = useSession();
const sessionLoaded = sessionStatus !== "loading";
const { isBetaEnabled } = useV4Beta();
const isSingleItem = router.query.singleItem === "true";
const [nextItemData, setNextItemData] = useState<
@@ -42,7 +45,7 @@ export const AnnotationQueueItemPage: React.FC<{
const seenItemData = api.annotationQueueItems.byId.useQuery(
{ projectId, itemId: itemId as string, isBetaEnabled },
{ enabled: !!itemId, refetchOnMount: false },
{ enabled: !!itemId && sessionLoaded, refetchOnMount: false },
);
const fetchAndLockNextMutation =
@@ -51,18 +54,19 @@ export const AnnotationQueueItemPage: React.FC<{
// Effects
useEffect(() => {
async function fetchNextItem() {
if (!itemId && !isSingleItem) {
if (!itemId && !isSingleItem && sessionLoaded) {
const nextItem = await fetchAndLockNextMutation.mutateAsync({
queueId: annotationQueueId,
projectId,
seenItemIds,
isBetaEnabled,
});
setNextItemData(nextItem);
}
}
fetchNextItem();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [sessionLoaded]);
const { configs } = useAnnotationQueueData({ annotationQueueId, projectId });
const unseenPendingItemCount =
@@ -88,6 +92,7 @@ export const AnnotationQueueItemPage: React.FC<{
queueId: annotationQueueId,
projectId,
seenItemIds,
isBetaEnabled,
});
setNextItemData(nextItem);
}
@@ -144,7 +149,8 @@ export const AnnotationQueueItemPage: React.FC<{
(seenItemData.isPending && itemId) ||
(fetchAndLockNextMutation.isPending && !itemId) ||
unseenPendingItemCount.isPending ||
objectData.isLoading
objectData.isLoading ||
(!sessionLoaded && !isSingleItem)
) {
return <Skeleton className="h-full w-full" />;
}
@@ -165,6 +171,7 @@ export const AnnotationQueueItemPage: React.FC<{
queueId: annotationQueueId,
projectId,
seenItemIds,
isBetaEnabled,
});
setNextItemData(nextItem);
}
@@ -13,7 +13,11 @@ import {
optionalPaginationZod,
Prisma,
} from "@langfuse/shared";
import { getObservationById, logger } from "@langfuse/shared/src/server";
import {
getObservationById,
getObservationByIdFromEventsTable,
logger,
} from "@langfuse/shared/src/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
@@ -464,78 +468,73 @@ export const queueRouter = createTRPCRouter({
queueId: z.string(),
projectId: z.string(),
seenItemIds: z.array(z.string()),
isBetaEnabled: z.boolean().optional().default(false),
}),
)
.mutation(async ({ input, ctx }) => {
try {
throwIfNoProjectAccess({
session: ctx.session,
throwIfNoProjectAccess({
session: ctx.session,
projectId: input.projectId,
scope: "annotationQueues:CUD",
});
const now = new Date();
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
const item = await ctx.prisma.annotationQueueItem.findFirst({
where: {
queueId: input.queueId,
projectId: input.projectId,
scope: "annotationQueues:CUD",
});
const now = new Date();
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
const item = await ctx.prisma.annotationQueueItem.findFirst({
where: {
queueId: input.queueId,
projectId: input.projectId,
status: AnnotationQueueStatus.PENDING,
OR: [
{ lockedAt: null },
{ lockedAt: { lt: fiveMinutesAgo } },
{ lockedByUserId: ctx.session.user.id },
],
NOT: {
id: { in: input.seenItemIds },
},
status: AnnotationQueueStatus.PENDING,
OR: [
{ lockedAt: null },
{ lockedAt: { lt: fiveMinutesAgo } },
{ lockedByUserId: ctx.session.user.id },
],
NOT: {
id: { in: input.seenItemIds },
},
orderBy: {
createdAt: "asc",
},
});
},
orderBy: {
createdAt: "asc",
},
});
// Expected behavior, non-error case: all items have been seen AND/OR completed, no more unseen pending items
if (!item) return null;
// Expected behavior, non-error case: all items have been seen AND/OR completed, no more unseen pending items
if (!item) return null;
const updatedItem = await ctx.prisma.annotationQueueItem.update({
where: {
id: item.id,
projectId: input.projectId,
},
data: {
lockedAt: now,
lockedByUserId: ctx.session.user.id,
},
});
const updatedItem = await ctx.prisma.annotationQueueItem.update({
where: {
id: item.id,
projectId: input.projectId,
},
data: {
lockedAt: now,
lockedByUserId: ctx.session.user.id,
},
});
const inflatedUpdatedItem = {
...updatedItem,
lockedByUser: { name: ctx.session.user.name },
const inflatedUpdatedItem = {
...updatedItem,
lockedByUser: { name: ctx.session.user.name },
};
if (item.objectType === AnnotationQueueObjectType.OBSERVATION) {
const clickhouseObservation = input.isBetaEnabled
? await getObservationByIdFromEventsTable({
id: item.objectId,
projectId: input.projectId,
})
: await getObservationById({
id: item.objectId,
projectId: input.projectId,
});
return {
...inflatedUpdatedItem,
parentTraceId: clickhouseObservation?.traceId,
};
if (item.objectType === AnnotationQueueObjectType.OBSERVATION) {
const clickhouseObservation = await getObservationById({
id: item.objectId,
projectId: input.projectId,
});
return {
...inflatedUpdatedItem,
parentTraceId: clickhouseObservation?.traceId,
};
}
return inflatedUpdatedItem;
} catch (error) {
logger.error(error);
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Fetching and locking next annotation queue item failed.",
});
}
return inflatedUpdatedItem;
}),
});
@@ -24,6 +24,7 @@ import { useIsAuthenticatedAndProjectMember } from "@/src/features/auth/hooks";
import { parseJsonPrioritised } from "@langfuse/shared";
import { ActionButton } from "@/src/components/ActionButton";
import { type MetadataDomainClient } from "@/src/utils/clientSideDomainTypes";
import { type Prisma } from "@langfuse/shared";
/**
* Component for creating a new dataset item from an existing object.
@@ -39,22 +40,30 @@ export const NewDatasetItemFromExistingObject = (props: {
traceId?: string;
observationId?: string;
fromDatasetId?: string;
input: string | null;
output: string | null;
input: Prisma.JsonValue | null;
output: Prisma.JsonValue | null;
metadata: MetadataDomainClient;
isCopyItem?: boolean;
buttonVariant?: ButtonProps["variant"];
size?: ButtonProps["size"];
}) => {
const parsedInput =
props.input && typeof props.input === "string"
? (parseJsonPrioritised(props.input) ?? null)
: null;
const normalizePrefillValue = (
value: Prisma.JsonValue | null,
): Prisma.JsonValue | null => {
if (value === null || value === undefined) {
return null;
}
const parsedOutput =
props.output && typeof props.output === "string"
? (parseJsonPrioritised(props.output) ?? null)
: null;
if (typeof value === "string") {
const parsed = parseJsonPrioritised(value);
return parsed !== undefined ? parsed : value;
}
return value;
};
const parsedInput = normalizePrefillValue(props.input);
const parsedOutput = normalizePrefillValue(props.output);
const [isFormOpen, setIsFormOpen] = useState(false);
const isAuthenticatedAndProjectMember = useIsAuthenticatedAndProjectMember(
@@ -301,11 +301,29 @@ export default function ExperimentsTable({
header: getExperimentsColumnName("experimentDatasetId"),
size: 150,
cell: ({ row }) => {
const key: string | undefined = row.getValue("datasetId");
const value = filterOptions.experimentDatasetId?.find(
(d) => d.value === key,
const datasetId: string | undefined = row.getValue("datasetId");
const datasetName = filterOptions.experimentDatasetId?.find(
(d) => d.value === datasetId,
)?.displayValue;
return value ? <TableIdOrName value={value} /> : undefined;
if (!datasetId || !datasetName) {
return undefined;
}
return (
<Link
href={`/project/${projectId}/datasets/${encodeURIComponent(datasetId)}`}
target="_blank"
rel="noopener noreferrer"
>
<Badge
variant="secondary"
className="hover:bg-secondary/80 max-w-full cursor-pointer"
>
{datasetName}
</Badge>
</Link>
);
},
},
{
+24 -12
View File
@@ -442,25 +442,37 @@ export const llmApiKeyRouter = createTRPCRouter({
});
}
const decryptedSecretKey =
input.secretKey !== undefined &&
input.secretKey !== "" &&
input.secretKey !== null
? input.secretKey
: decrypt(existingKey.secretKey);
const hasNewSecretKey =
typeof input.secretKey === "string" && input.secretKey.length > 0;
const baseURL = input.baseURL ?? existingKey.baseURL;
const isBaseURLChanged = baseURL !== existingKey.baseURL;
if (isBaseURLChanged && !hasNewSecretKey) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Secret key is required when changing the base URL",
});
}
const secretKey = hasNewSecretKey
? (input.secretKey as string)
: decrypt(existingKey.secretKey);
// Merge existing key with provided input, giving priority to input
const secretKey = decryptedSecretKey;
const adapter = input.adapter ?? (existingKey.adapter as LLMAdapter);
const provider = input.provider ?? existingKey.provider;
const baseURL = input.baseURL ?? existingKey.baseURL;
const customModels = input.customModels ?? existingKey.customModels;
const config = input.config ?? existingKey.config;
// Never reuse stored headers across a destination change.
const extraHeaders =
input.extraHeaders ??
(existingKey.extraHeaders
? decryptAndParseExtraHeaders(existingKey.extraHeaders)
: undefined);
input.extraHeaders !== undefined
? input.extraHeaders
: isBaseURLChanged
? undefined
: existingKey.extraHeaders
? decryptAndParseExtraHeaders(existingKey.extraHeaders)
: undefined;
return testLLMConnection({
adapter,
+14 -1
View File
@@ -89,10 +89,11 @@ export const GetTracesV1Query = z.object({
.nullish()
.transform((v) => {
if (!v) return null;
return v
const parsed = v
.split(",")
.map((f) => f.trim())
.filter((f) => TRACE_FIELD_GROUPS.includes(f as TraceFieldGroup));
return parsed.length > 0 ? parsed : null;
})
.pipe(z.array(z.enum(TRACE_FIELD_GROUPS)).nullable()),
useEventsTable: useEventsTableSchema,
@@ -125,6 +126,18 @@ export const PostTracesV1Response = z.object({ id: z.string() });
// GET /api/public/traces/{traceId}
export const GetTraceV1Query = z.object({
traceId: z.string(),
fields: z
.string()
.nullish()
.transform((v) => {
if (!v) return null;
const parsed = v
.split(",")
.map((f) => f.trim())
.filter((f) => TRACE_FIELD_GROUPS.includes(f as TraceFieldGroup));
return parsed.length > 0 ? parsed : null;
})
.pipe(z.array(z.enum(TRACE_FIELD_GROUPS)).nullable()),
});
export const GetTraceV1Response = APIExtendedTrace.extend({
scores: z.array(APIScoreSchemaV1),
@@ -27,10 +27,9 @@ export default withMiddlewares({
const datasetItem = await getDatasetItemById({
projectId: auth.scope.projectId,
datasetItemId: datasetItemId,
status: "ACTIVE",
});
if (!datasetItem) {
throw new LangfuseNotFoundError("Dataset item not found or archived");
throw new LangfuseNotFoundError("Dataset item not found");
}
const dataset = await prisma.dataset.findUnique({
@@ -8,7 +8,11 @@ import {
PostDatasetItemsV1Response,
transformDbDatasetItemDomainToAPIDatasetItem,
} from "@/src/features/public-api/types/datasets";
import { LangfuseNotFoundError, Prisma } from "@langfuse/shared";
import {
LangfuseConflictError,
LangfuseNotFoundError,
Prisma,
} from "@langfuse/shared";
import {
createDatasetItemFilterState,
getDatasetItems,
@@ -82,12 +86,23 @@ export default withMiddlewares({
// When this constraint is violated, the database will upsert based on (id, projectId, datasetId).
// If this record does not exist, the database will throw an error.
logger.warn(
`Failed to upsert dataset item. Dataset item ${id} in project ${auth.scope.projectId} already exists for a different dataset than ${datasetName}`,
`Failed to upsert dataset item. Dataset item ${id} already exists for a different dataset than ${datasetName}`,
);
throw new LangfuseNotFoundError(
`The dataset item with id ${id} already exists in a dataset other than ${datasetName}`,
);
}
if (e.code === "P2002") {
// Unique constraint violation on (id, projectId, validFrom).
// This can happen when concurrent requests try to update the same dataset item
// and create versions with the same timestamp.
logger.warn(
`Failed to upsert dataset item due to version conflict. Dataset item ${id} was modified concurrently.`,
);
throw new LangfuseConflictError(
`Dataset item ${id ?? "new"} was modified concurrently. Please retry the request.`,
);
}
}
throw e;
}
+58 -22
View File
@@ -6,7 +6,10 @@ import {
GetTraceV1Response,
DeleteTraceV1Query,
DeleteTraceV1Response,
TRACE_FIELD_GROUPS,
type TraceFieldGroup,
} from "@/src/features/public-api/types/traces";
import { env } from "@/src/env.mjs";
import {
filterAndValidateDbTraceScoreList,
LangfuseNotFoundError,
@@ -29,11 +32,32 @@ export default withMiddlewares({
responseSchema: GetTraceV1Response,
fn: async ({ query, auth }) => {
const { traceId } = query;
let effectiveFields: readonly TraceFieldGroup[] =
query.fields ?? TRACE_FIELD_GROUPS;
if (!query.fields && env.LANGFUSE_API_TRACEBYID_DEFAULT_FIELDS) {
const parsed = env.LANGFUSE_API_TRACEBYID_DEFAULT_FIELDS.split(",")
.map((f) => f.trim())
.filter((f): f is TraceFieldGroup =>
TRACE_FIELD_GROUPS.includes(f as TraceFieldGroup),
);
if (parsed.length > 0) {
effectiveFields = parsed;
}
}
const requestedFields = effectiveFields;
const includeIO = requestedFields.includes("io");
const includeObservations = requestedFields.includes("observations");
const includeScores = requestedFields.includes("scores");
const includeMetrics = requestedFields.includes("metrics");
const trace = await getTraceById({
traceId,
projectId: auth.scope.projectId,
clickhouseFeatureTag: "tracing-public-api",
preferredClickhouseService: "ReadOnly",
excludeInputOutput: !includeIO,
excludeMetadata: !includeIO,
});
if (!trace) {
@@ -43,19 +67,23 @@ export default withMiddlewares({
}
const [observations, scores] = await Promise.all([
getObservationsForTrace({
traceId,
projectId: auth.scope.projectId,
timestamp: trace?.timestamp,
includeIO: true,
preferredClickhouseService: "ReadOnly",
}),
getScoresForTraces({
projectId: auth.scope.projectId,
traceIds: [traceId],
timestamp: trace?.timestamp,
preferredClickhouseService: "ReadOnly",
}),
includeObservations || includeMetrics
? getObservationsForTrace({
traceId,
projectId: auth.scope.projectId,
timestamp: trace?.timestamp,
includeIO: includeObservations,
preferredClickhouseService: "ReadOnly",
})
: Promise.resolve([]),
includeScores
? getScoresForTraces({
projectId: auth.scope.projectId,
traceIds: [traceId],
timestamp: trace?.timestamp,
preferredClickhouseService: "ReadOnly",
})
: Promise.resolve([]),
]);
const uniqueModels: string[] = Array.from(
@@ -129,16 +157,24 @@ export default withMiddlewares({
return {
...trace,
externalId: null,
scores: validatedScores,
latency: latencyMs !== undefined ? latencyMs / 1000 : 0,
observations: outObservations,
metadata: includeIO ? trace.metadata : {},
scores: includeScores ? validatedScores : [],
latency: includeMetrics
? latencyMs !== undefined
? latencyMs / 1000
: 0
: -1,
observations: includeObservations ? outObservations : [],
htmlPath: `/project/${auth.scope.projectId}/traces/${traceId}`,
totalCost: outObservations
.reduce(
(acc, obs) => acc.add(obs.calculatedTotalCost ?? new Decimal(0)),
new Decimal(0),
)
.toNumber(),
totalCost: includeMetrics
? outObservations
.reduce(
(acc, obs) =>
acc.add(obs.calculatedTotalCost ?? new Decimal(0)),
new Decimal(0),
)
.toNumber()
: -1,
};
},
}),
+16
View File
@@ -62,6 +62,14 @@
--color-sidebar-border: hsl(var(--sidebar-border));
--color-sidebar-ring: hsl(var(--sidebar-ring));
--color-find-match-background: hsl(var(--find-match-background));
--color-find-match-selected-background: hsl(
var(--find-match-selected-background)
);
--color-find-match-selected-foreground: hsl(
var(--find-match-selected-foreground)
);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
@@ -210,6 +218,10 @@
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
--find-match-background: 60 60% 85%;
--find-match-selected-background: 60 100% 50%;
--find-match-selected-foreground: 0 0% 0%;
--chart-1: 239 84% 58%;
--chart-2: 188 94% 43%;
--chart-3: 240 4% 46%;
@@ -306,6 +318,10 @@
--sidebar-border: 217.2 32.6% 26.5%;
--sidebar-ring: 224.3 76.3% 46%;
--find-match-background: 60 100% 20%;
--find-match-selected-background: 60 100% 50%;
--find-match-selected-foreground: 0 0% 0%;
--chart-1: 239 84% 58%;
--chart-2: 188 94% 43%;
--chart-3: 240 4% 46%;
+6
View File
@@ -93,3 +93,9 @@ Use root [AGENTS.md](../AGENTS.md) for monorepo-level rules.
- Keep tests independent; no ordering assumptions.
- Avoid editing `dist/*` directly.
- Coordinate shared changes with `../packages/shared`.
- Changes to `src/features/blobstorage/` (export pipeline, enrichment logic,
field additions, latency unit handling) should be reviewed against the
published blob storage docs for consistency — fetch the latest pages and
surface any discrepancies:
- https://langfuse.com/docs/api-and-data-platform/features/export-to-blob-storage
- https://langfuse.com/docs/api-and-data-platform/features/blob-storage-export-fields
+11 -11
View File
@@ -7,16 +7,16 @@ RUN apk update && apk upgrade --no-cache libcrypto3 libssl3 libc6-compat busybox
FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine AS build-base
# Pin turbo to avoid nondeterministic prune output from future patch releases.
RUN npm install turbo@2.8.20 --global
RUN npm install turbo@2.9.5 --global
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@10.33.0 --activate
FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine AS runtime-base
# Remove package managers from the runtime image; only build stages need them.
# package managers and build-only CLIs only increase exposure to CVEs -> remove them
RUN rm -rf /usr/local/lib/node_modules/corepack /usr/local/lib/node_modules/npm && \
rm -f /usr/local/bin/corepack /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/pnpm /usr/local/bin/pnpx /usr/local/bin/yarn /usr/local/bin/yarnpkg
rm -f /usr/local/bin/corepack /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/yarn /usr/local/bin/yarnpkg
FROM --platform=${TARGETPLATFORM:-linux/amd64} build-base AS pruner
@@ -50,20 +50,19 @@ RUN turbo run build --filter=worker...
FROM --platform=${TARGETPLATFORM:-linux/amd64} builder AS prod-deps
RUN rm -rf /prod && \
pnpm --filter worker deploy --legacy --prod /prod/worker && \
# previously we copied the --from=builder /app . (includes full node_modules etc)
# we only need the prod + generated prisma client plus .prisma artifacts
# @langfuse/shared still pulls in next-auth transitively, so keep the deploy output
# intact here instead of pruning node_modules in Docker.
# also, pnpm v10 needs the pnpm legacy deploy implementation, we didn't upgrade that yet
RUN pnpm --filter worker deploy --legacy --prod /prod/worker && \
builder_prisma_client_dir="$(find /app/node_modules/.pnpm -path '*/node_modules/@prisma/client' -type d | head -n 1)" && \
deployed_prisma_client_dir="$(find /prod/worker/node_modules/.pnpm -path '*/node_modules/@prisma/client' -type d | head -n 1)" && \
builder_prisma_runtime_dir="$(dirname "$(dirname "$builder_prisma_client_dir")")/.prisma" && \
deployed_prisma_runtime_dir="$(dirname "$(dirname "$deployed_prisma_client_dir")")/.prisma" && \
rm -rf "$deployed_prisma_client_dir" "$deployed_prisma_runtime_dir" && \
mkdir -p "$(dirname "$deployed_prisma_client_dir")" && \
cp -R "$builder_prisma_client_dir" "$deployed_prisma_client_dir" && \
cp -R "$builder_prisma_runtime_dir" "$deployed_prisma_runtime_dir" && \
# @langfuse/shared currently brings auth-only Next.js packages that the worker never executes.
find /prod/worker/node_modules \
\( -path '*/node_modules/next' -o -path '*/node_modules/next-auth' -o -path '*/.bin/next' \) \
-exec rm -rf {} +
cp -R "$builder_prisma_runtime_dir" "$deployed_prisma_runtime_dir"
FROM --platform=${TARGETPLATFORM:-linux/amd64} runtime-base AS runner
@@ -86,6 +85,7 @@ ARG GID=1001
RUN addgroup --system --gid ${GID} expressjs
RUN adduser --system --uid ${UID} expressjs
# Copy only production worker payload instead of full builder workspace (just /prod/worker not entire /app)
COPY --from=prod-deps --chown=expressjs:expressjs /prod/worker ./worker
RUN chmod +x ./worker/entrypoint.sh
USER expressjs
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "worker",
"version": "3.165.0",
"version": "3.167.0",
"description": "",
"license": "MIT",
"private": true,
@@ -657,4 +657,48 @@ describe.concurrent("test eval filtering", () => {
expect(jobs[0].jobInputTraceId).toBe(traceId1);
expect(jobs[0].status.toString()).toBe("PENDING");
}, 10_000);
test("cached trace preserves metadata for in-memory filter evaluation", async ({
expect,
projectId,
traceId1,
upsertTrace,
configureJob,
getJobs,
}) => {
// Create a trace with metadata
await upsertTrace({
id: traceId1,
metadata: { tier: "premium" },
});
// Create TWO job configs so configs.length > 1, triggering the cached trace path
// (getTraceById is called with excludeInputOutput: true)
await configureJob({
scoreName: "score-with-metadata-filter",
filter: [
{
type: "stringObject",
key: "tier",
value: "premium",
column: "metadata",
operator: "=",
},
],
});
await configureJob({
scoreName: "score-no-filter",
filter: [],
});
// Single createEvalJobs call => configs.length=2 => cached trace path
await createEvalJobs({
event: { projectId, traceId: traceId1 },
jobTimestamp: new Date(),
});
const jobs = await getJobs();
// Both configs should produce a job — the metadata filter should match
expect(jobs.length).toBe(2);
}, 10_000);
});
@@ -1,39 +1,94 @@
import { describe, it, expect, beforeEach, beforeAll, afterAll } from "vitest";
import { describe, it, expect, beforeEach } from "vitest";
import { v4 } from "uuid";
import {
ActionExecutionStatus,
JobConfigState,
TriggerEventSource,
TriggerEventAction,
PromptType,
} from "@langfuse/shared";
import {
createOrgProjectAndApiKey,
redis,
EntityChangeEventType,
} from "@langfuse/shared/src/server";
import { ActionType, prisma } from "@langfuse/shared/src/db";
import { promptVersionProcessor } from "../features/entityChange/promptVersionProcessor";
describe("promptVersionChangeWorker", () => {
let orgId: string;
let projectId: string;
let auth: string;
beforeEach(async () => {
// Create test organization and project
const result = await createOrgProjectAndApiKey();
orgId = result.orgId;
projectId = result.projectId;
auth = result.auth;
});
describe("successful trigger execution", () => {
it("should execute webhook for matching prompt created event", async () => {
// Create a prompt
it.each([
// eventActions only
{
description: "should execute webhook for matching prompt created event",
trigger: { eventActions: ["created"], nameFilter: null },
event: { promptName: "test-prompt", action: "created" as const },
expected: 1,
},
{
description: "should execute webhook for matching prompt updated event",
trigger: { eventActions: ["updated"], nameFilter: null },
event: { promptName: "test-prompt", action: "updated" as const },
expected: 1,
},
{
description: "should execute webhook for matching prompt deleted event",
trigger: { eventActions: ["deleted"], nameFilter: null },
event: { promptName: "test-prompt", action: "deleted" as const },
expected: 1,
},
{
description: "should not execute when action filter doesn't match",
trigger: { eventActions: ["deleted"], nameFilter: null },
event: { promptName: "test-prompt", action: "created" as const },
expected: 0,
},
{
description:
"should execute webhook when eventActions and filters are empty",
trigger: { eventActions: [] as string[], nameFilter: null },
event: { promptName: "test-prompt", action: "updated" as const },
expected: 1,
},
// eventActions + name filter combinations
{
description:
"should execute webhook when eventActions is empty and name filter matches",
trigger: { eventActions: [] as string[], nameFilter: "target-prompt" },
event: { promptName: "target-prompt", action: "created" as const },
expected: 1,
},
{
description:
"should execute webhook when eventActions and name filter both match",
trigger: { eventActions: ["created"], nameFilter: "target-prompt" },
event: { promptName: "target-prompt", action: "created" as const },
expected: 1,
},
{
description:
"should not execute when eventActions matches but name filter does not",
trigger: { eventActions: ["created"], nameFilter: "target-prompt" },
event: { promptName: "different-prompt", action: "created" as const },
expected: 0,
},
{
description:
"should not execute when eventActions does not match but name filter does",
trigger: { eventActions: ["deleted"], nameFilter: "target-prompt" },
event: { promptName: "target-prompt", action: "created" as const },
expected: 0,
},
])(
"$description",
async ({ trigger, event: { promptName, action }, expected }) => {
const { eventActions, nameFilter } = trigger;
const promptId = v4();
// Create a webhook action
const actionId = v4();
await prisma.action.create({
data: {
@@ -49,47 +104,47 @@ describe("promptVersionChangeWorker", () => {
},
});
// Create a trigger that matches prompt created events
const triggerId = v4();
await prisma.trigger.create({
data: {
id: triggerId,
projectId,
eventSource: TriggerEventSource.Prompt,
eventActions,
status: JobConfigState.ACTIVE,
filter: [
{
column: "action",
operator: "=",
value: "created",
type: "string",
},
],
filter: nameFilter
? [
{
column: "Name",
operator: "=",
value: nameFilter,
type: "string",
},
]
: [],
},
});
// Create automation linking trigger and action
const automationId = v4();
await prisma.automation.create({
data: {
id: automationId,
name: "prompt-created-automation",
name: `automation-${v4()}`,
projectId,
triggerId,
actionId,
},
});
// Create the event to process
const event: EntityChangeEventType = {
entityType: "prompt-version",
projectId,
promptId,
action: "created",
action,
prompt: {
id: promptId,
projectId,
name: "test-prompt",
name: promptName,
version: 1,
prompt: { messages: [{ role: "user", content: "Hello" }] },
config: null,
@@ -104,296 +159,17 @@ describe("promptVersionChangeWorker", () => {
},
};
// Execute the worker
await promptVersionProcessor(event);
// Verify automation execution was created
const executions = await prisma.automationExecution.findMany({
where: {
projectId,
automationId,
triggerId,
actionId,
},
where: { projectId, automationId },
});
expect(executions).toHaveLength(1);
expect(executions[0].status).toBe(ActionExecutionStatus.PENDING);
expect(executions[0].sourceId).toBe(promptId);
});
it("should execute webhook for matching prompt updated event", async () => {
// Create a prompt
const promptId = v4();
// Create a webhook action
const actionId = v4();
await prisma.action.create({
data: {
id: actionId,
projectId,
type: ActionType.WEBHOOK,
config: {
type: "WEBHOOK",
url: "https://webhook.example.com/test",
headers: {},
method: "POST",
},
},
});
// Create a trigger that matches prompt updated events
const triggerId = v4();
await prisma.trigger.create({
data: {
id: triggerId,
projectId,
eventSource: TriggerEventSource.Prompt,
status: JobConfigState.ACTIVE,
filter: [
{
column: "action",
operator: "=",
value: "updated",
type: "string",
},
],
},
});
// Create automation linking trigger and action
const automationId = v4();
await prisma.automation.create({
data: {
id: automationId,
name: "prompt-updated-automation",
projectId,
triggerId,
actionId,
},
});
// Create the event to process
const event: EntityChangeEventType = {
entityType: "prompt-version",
projectId,
promptId,
action: "updated",
prompt: {
id: promptId,
projectId,
name: "test-prompt",
version: 2,
prompt: { messages: [{ role: "user", content: "Hello updated" }] },
config: null,
tags: [],
labels: [],
type: PromptType.Chat,
isActive: true,
createdBy: "test-user",
createdAt: new Date(),
updatedAt: new Date(),
commitMessage: null,
},
};
// Execute the worker
await promptVersionProcessor(event);
// Verify automation execution was created
const executions = await prisma.automationExecution.findMany({
where: {
projectId,
automationId,
triggerId,
actionId,
},
});
expect(executions).toHaveLength(1);
expect(executions[0].status).toBe(ActionExecutionStatus.PENDING);
});
});
it("should not execute when action filter doesn't match", async () => {
// Create a prompt
const promptId = v4();
// Create a webhook action
const actionId = v4();
await prisma.action.create({
data: {
id: actionId,
projectId,
type: ActionType.WEBHOOK,
config: {
url: "https://webhook.example.com/test",
headers: {},
method: "POST",
},
},
});
// Create a trigger that only matches DELETED events
const triggerId = v4();
await prisma.trigger.create({
data: {
id: triggerId,
projectId,
eventSource: TriggerEventSource.Prompt,
status: JobConfigState.ACTIVE,
filter: [
{
column: "action",
operator: "=",
value: "deleted",
type: "string",
},
],
},
});
// Create automation linking trigger and action
const automationId = v4();
await prisma.automation.create({
data: {
id: automationId,
name: "prompt-deleted-automation",
projectId,
triggerId,
actionId,
},
});
// Create a CREATED event (which shouldn't match the DELETED filter)
const event: EntityChangeEventType = {
entityType: "prompt-version",
projectId,
promptId,
action: "created",
prompt: {
id: promptId,
projectId,
name: "test-prompt",
version: 1,
prompt: { messages: [{ role: "user", content: "Hello" }] },
config: null,
tags: [],
labels: [],
type: PromptType.Chat,
isActive: true,
createdBy: "test-user",
createdAt: new Date(),
updatedAt: new Date(),
commitMessage: null,
},
};
// Execute the worker
await promptVersionProcessor(event);
// Verify no automation execution was created
const executions = await prisma.automationExecution.findMany({
where: {
projectId,
automationId,
},
});
expect(executions).toHaveLength(0);
});
it("should not execute when prompt name filter doesn't match", async () => {
// Create a prompt
const promptId = v4();
// Create a webhook action
const actionId = v4();
await prisma.action.create({
data: {
id: actionId,
projectId,
type: ActionType.WEBHOOK,
config: {
url: "https://webhook.example.com/test",
headers: {},
method: "POST",
},
},
});
// Create a trigger that matches CREATED events but only for specific prompt name
const triggerId = v4();
await prisma.trigger.create({
data: {
id: triggerId,
projectId,
eventSource: TriggerEventSource.Prompt,
status: JobConfigState.ACTIVE,
filter: [
{
column: "action",
operator: "=",
value: "created",
type: "string",
},
{
column: "name",
operator: "=",
value: "target-prompt",
type: "string",
},
],
},
});
// Create automation linking trigger and action
const automationId = v4();
await prisma.automation.create({
data: {
id: automationId,
name: "specific-prompt-automation",
projectId,
triggerId,
actionId,
},
});
// Create event with different prompt name
const event: EntityChangeEventType = {
entityType: "prompt-version",
projectId,
promptId,
action: "created",
prompt: {
id: promptId,
projectId,
name: "different-prompt",
version: 1,
prompt: { messages: [{ role: "user", content: "Hello" }] },
config: null,
tags: [],
labels: [],
type: PromptType.Chat,
isActive: true,
createdBy: "test-user",
createdAt: new Date(),
updatedAt: new Date(),
commitMessage: null,
},
};
// Execute the worker
await promptVersionProcessor(event);
// Verify no automation execution was created
const executions = await prisma.automationExecution.findMany({
where: {
projectId,
automationId,
},
});
expect(executions).toHaveLength(0);
});
expect(executions).toHaveLength(expected);
if (expected > 0) {
expect(executions[0].status).toBe(ActionExecutionStatus.PENDING);
expect(executions[0].sourceId).toBe(promptId);
}
},
);
});
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.165.0";
export const VERSION = "v3.167.0";
@@ -1,5 +1,6 @@
import {
type TriggerEventAction,
type FilterState,
jsonSchemaNullable,
InternalServerError,
} from "@langfuse/shared";
@@ -68,10 +69,27 @@ export const promptVersionProcessor = async (
}
};
// Merge eventActions into the filter so InMemoryFilterService handles
// everything in one place. Done here rather than in convertTriggerToDomain
// because that function also serves the UI — injecting a synthetic condition
// there would corrupt the edit form and write it back to the DB on save.
const mergedFilter: FilterState =
trigger.eventActions.length > 0
? [
...trigger.filter,
{
column: "action",
operator: "any of",
type: "stringOptions",
value: trigger.eventActions,
},
]
: trigger.filter;
// Use InMemoryFilterService for all filtering including actions
const eventMatches = InMemoryFilterService.evaluateFilter(
eventData,
trigger.filter,
mergedFilter,
fieldMapper,
);
@@ -262,6 +262,7 @@ export const createEvalJobs = async ({
: new Date(jobTimestamp),
clickhouseFeatureTag: "eval-create",
excludeInputOutput: true,
excludeMetadata: false, // Metadata needed for in-memory filter evaluation
});
recordIncrement("langfuse.evaluation-execution.trace_cache_fetch", 1, {