Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd632fea9e | ||
|
|
7527bb0d84 | ||
|
|
8cc4a5537f | ||
|
|
0a6d3f108a | ||
|
|
1bf83313e3 | ||
|
|
9c3a715d77 | ||
|
|
0cf2a33473 | ||
|
|
51554eb066 | ||
|
|
cbc21bb9cc | ||
|
|
8e30694214 | ||
|
|
0d20d9de2b | ||
|
|
eeeba25439 | ||
|
|
a59630d656 | ||
|
|
10e7dea9c9 | ||
|
|
9bf326db4f | ||
|
|
13190c3ec4 | ||
|
|
7a4ce9ee5d | ||
|
|
bc02989ccf | ||
|
|
f0dac0299c | ||
|
|
19997064c1 | ||
|
|
c393c64a40 | ||
|
|
d04e027107 | ||
|
|
53869c7200 | ||
|
|
6d964894fb | ||
|
|
81653b2bd2 | ||
|
|
02d6486f10 | ||
|
|
07ee4ed961 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 @@
|
||||
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,
|
||||
|
||||
Generated
+40
-40
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "3.165.0",
|
||||
"version": "3.167.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
+2
-1
@@ -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 @@
|
||||
export const VERSION = "v3.165.0";
|
||||
export const VERSION = "v3.167.0";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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
@@ -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
@@ -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 @@
|
||||
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, {
|
||||
|
||||
Reference in New Issue
Block a user