Compare commits

...
45 Commits
Author SHA1 Message Date
Marlies Mayerhofer 0e1498a094 chore: release v3.78.2 2025-07-03 13:37:47 +02:00
marliessophieandGitHub 38f020e482 fix(scores): update score validation logic to accept inclusive ranges for score config validation (#7650)
* fix(scores): update score validation logic to accept inclusive ranges for score config validation

* chore: typo
2025-07-03 09:22:33 +00:00
Hassieb PakzadandGitHub 07e1d4e960 fix(otel): trace update events on present metadata (#7648) 2025-07-03 08:36:40 +00:00
Hassieb PakzadandGitHub 602d00a5bb chore: upgrade langfuse-langchain (#7647) 2025-07-03 08:16:41 +00:00
7395bd8d83 fix: loading state of csv import button (#7640)
* Disable import button during processing and show loading state

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

* prettier

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-07-03 00:12:27 +00:00
Marc KlingenandGitHub 63d164ad52 chore: improve cursor background agent configuration (#7641)
* fix

* push

* test
2025-07-03 01:41:14 +02:00
Thorsten SpiekerandGitHub f1b915c8b7 fix: remove invalid metrics and dimensions from pivot table widget on view change (#7636)
* fix: remove invalid metrics and dimensions from pivot table widget on view change

* linting
2025-07-02 19:01:26 +02:00
Steffen SchmitzandGitHub e3886b94c1 chore: set static backoff for query clickhouse retries (#7635) 2025-07-02 17:35:43 +02:00
Steffen SchmitzandGitHub 44376c2a9f chore: ignore all pending linting errors and enforce zero warnings (#7634) 2025-07-02 14:32:35 +00:00
marliessophieandGitHub 2e85c9ac15 chore(evals): add QUEUE_ERROR_MESSAGES constants for improved error handling (#7630)
* chore(evals): add QUEUE_ERROR_MESSAGES constants for improved error handling

* chore: push
2025-07-02 14:01:15 +00:00
Steffen SchmitzandGitHub 7cc81deb65 chore: retry socket hang ups for CH queries (#7628) 2025-07-02 13:52:26 +00:00
Steffen SchmitzandGitHub a13ddd41ae exp: write observation and score info to traces_mt (#7590)
* exp: write observation and score info to traces_mt

* chore: update declarations for experimental traces table

* chore: change anyLast pattern to working condition

* chore: revert anyLast setup

* chore: revert

* chore: cleanup queries
2025-07-02 11:53:38 +00:00
Nimar ebe85f2a67 chore: release v3.78.1 2025-07-02 12:15:50 +02:00
NimarandGitHub c7b62adbab feat(experiments) support for placeholders as message histories (#7615)
* run experiments with placeholders as well

* add missing file

* fix experiements

* run outside of loop

* add tests

* fix tests

* make tests clean again

* also test that variables resovle first
2025-07-02 10:04:51 +00:00
66d4926fd0 feat: add tags to search on prompts table (#7619)
* Enhance prompt search to include tags with case-insensitive matching

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

* drop md

* prettier

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-07-01 20:37:57 +00:00
b26f5c512f refactor: pivot table dropdowns (#7611)
---------

Co-authored-by: Max Deichmann <m.deichmann@tum.de>
2025-07-01 21:21:46 +02:00
marliessophieandGitHub 9227eaab9c feat(deleteButton): add custom delete message for running evaluator (#7617) 2025-07-01 18:33:41 +00:00
Nimar 3debac082a chore: release v3.78.0 2025-07-01 18:34:30 +02:00
fb43e03171 feat(prompts): Add search bar to prompts table (#7603)
* Add search functionality to prompts table with query param support

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

* prettier

* fix

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-07-01 14:16:45 +00:00
marliessophieandGitHub dd04da64ba fix(prisma): update JobExecution and DefaultLlmModel relations to include onUpdate and onDelete behaviors to match DB constraints (#7602)
* fix(prisma): update JobExecution and DefaultLlmModel relations to include onUpdate and onDelete behaviors to match DB constraints

* chore(prisma): update foreign key onUpdate behavior for job_template_id in JobExecution model

* Revert "chore(prisma): update foreign key onUpdate behavior for job_template_id in JobExecution model"

This reverts commit e65c12c9c36a1f8732560e665b1d8d7422c530f0.
2025-07-01 14:07:42 +00:00
b94cd9880a chore: fix typos (#7604)
* Fix typos: GitHub brand name and "check out" verb phrase

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

* Checkpoint before follow-up message

* Fix GitHub brand name and typos across documentation and UI

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

* Changes from background composer bc-bdbd909e-6932-4489-b125-e66b1d0f5ebe

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-07-01 13:54:09 +00:00
NimarandGitHub 0f2d2b8850 fix(prompts): don't allow placeholders and variable with same name on same prompt version (#7598)
* no naming collisions between prompt and placeholder

* tests for variable placeholder name collision

* fix test spelling

* also show conflict in playhground
2025-07-01 13:39:35 +00:00
steffen911 69db85cfe4 chore: release v3.77.0 2025-07-01 13:37:53 +02:00
Thorsten SpiekerandGitHub b445471f98 feat(dashboard): add pivot table widget type (#7546) 2025-07-01 13:36:54 +02:00
2ce014980b feat(posthog-export): Add environment attribute to PostHog exports (#7581)
* Add environment to PostHog event tracking for traces and observations

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

* use object envs instead of trace env

* fix

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-06-30 22:47:14 +00:00
Nimar 40ad6761cc chore: release v3.76.0 2025-06-30 19:13:49 +02:00
bd36669c6a feat(prompts): prompt chat message placeholders in backend and api (#7388)
* add first unit test

* first test passes

* share types

* fix tests

* update docs for placeholder

* improve tests

* add message compile

* update types

* add backend remaining

* fix playground

* review suggest

* add playground

* code review

* fix spelling

* fix validation in FE

* fix validation

* add seed data

* allow placeholdered prompt to playgrounf

* add tooltip

* fix build

* code feedback

* use zod to validate

* Update CLAUDE.md

* chore: remove prefix from user_id column (#7477)

* chore: add .env values for sdk integration tests (#7479)

* fix(data-tables): table peeks and full links should respect custom basePaths (#7483)

* strip url

* fix for dataset peek

* add to cursor rules

* chore: release v3.75.1

* feat: line breaks on evaluation run tooltip hints (#7486)

* Fix comment display to preserve whitespace and line breaks

* include in seeder

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

* chore: reduce number of listed S3 files in event processing (#7487)

* chore(cloud): apply "cloud:demo" tier to demo org tenant in plain sup… (#7489)

chore(cloud): apply "cloud:demo" tier to demo org tenant in plain support

* make the api beautiful again

* update api

* reorg import

* fix improt

* prefill message placeholders

* remove todos

* fix prompt mapping

---------

Co-authored-by: Steffen Schmitz <steffen@langfuse.com>
Co-authored-by: Marc Klingen <git@marcklingen.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-06-30 16:54:07 +00:00
Steffen SchmitzandGitHub 98cc1bb7a1 exp: add aggregating merge trees experiment for traces tables (#7574)
* exp: add aggregating merge trees experiment for traces tables

* chore: lint
2025-06-30 16:46:50 +00:00
marliessophieandGitHub 3dcdbd99b3 fix(evals): show loading state of detail buttons only if preview is enabled (#7570)
fix(evals): show loading state of detail buttons only if show preview is enabled
2025-06-30 14:40:17 +00:00
Hassieb PakzadandGitHub 1a5c5f6383 fix(otel): skip multiproject check for non-python (#7568) 2025-06-30 13:38:14 +00:00
Steffen SchmitzandGitHub 867f6c7484 feat(dashboard): allow filtering by score value in custom dashboards (#7567)
* feat(dashboard): allow filtering by score value in custom dashboards

* chore: remove console log
2025-06-30 13:16:14 +00:00
NimarandGitHub 1097f1b8d5 chore(dx): show different favicon for dev mode - easier to find tab (#7550)
add dev favicon
2025-06-30 12:00:25 +00:00
Marc KlingenandGitHub a73156bbe5 fix: warn if missing encryption_key when updating posthog integration (#7560)
* fix: warn if missing encryption_key when updating posthog integration

* push
2025-06-30 11:20:22 +00:00
marliessophieandGitHub 7a9c62b106 refactor(evals): optimize variable extraction logic in EvaluationPromptPreview and useExtractVariables hooks (#7409)
* refactor(evals): optimize variable extraction logic in EvaluationPromptPreview and useExtractVariables hooks

* style: improve preview header

* chore: eslint
2025-06-30 10:57:37 +00:00
marliessophieandGitHub 70cc910ee9 fix(datasets): allow users to update metadata and description while keeping name constant (#7562)
* feat: add whitelisted name support for unique name validation in dataset form

* fix: pass metadata to update dataset form
2025-06-30 09:55:00 +00:00
Steffen SchmitzandGitHub 64a71f059f chore: add trace update compatibility properties (#7561) 2025-06-30 09:28:23 +00:00
Max DeichmannandGitHub f5a0c7cfed chore: move prompt types into shared package (#7555)
* push

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes
2025-06-29 10:38:14 +00:00
Marc KlingenandGitHub 2472d2f6da chore(cloud): remove posthog groups to reduce costs (#7552) 2025-06-27 20:01:38 +00:00
Max DeichmannandGitHub dd0446f4dd chore: add python sdk v3 notification (#7543)
* fixes

* push
2025-06-27 14:08:47 +00:00
Max DeichmannandGitHub 7263a1554c chore: fix admin api (#7530)
* fixes

* fixes
2025-06-27 11:10:00 +00:00
Max DeichmannandGitHub 2c25c27b51 chore: skip s3 LIST for projects configured in env (#7541)
push
2025-06-27 11:09:50 +00:00
Steffen SchmitzandGitHub f2b92e1f23 chore: only consider getWaitingCount for queue length (#7542) 2025-06-27 09:44:37 +00:00
Steffen SchmitzandGitHub 593c0a567c perf: execute eval variable parsing sequentially (#7540)
* perf: execute eval variable parsing sequentially

* chore: skip caching for dataset items
2025-06-27 09:24:02 +00:00
Steffen SchmitzandGitHub ac1f465634 perf: cache eval entities during variable extraction (#7538) 2025-06-27 07:56:01 +00:00
NimarandGitHub 083b1357a1 fix: long trace names shouldn't break at icons (#7382)
* fix breaks

* fix

* undo
2025-06-26 18:27:36 +00:00
170 changed files with 7195 additions and 1096 deletions
+12 -5
View File
@@ -12,8 +12,15 @@ RUN apt-get update && \
postgresql-client \
redis-tools \
less nano \
sudo \
&& rm -rf /var/lib/apt/lists/*
# ---------- Docker -----------------------------------------------------------
# Install Docker for background agents that need container capabilities
RUN curl -fsSL https://get.docker.com -o get-docker.sh && \
sh get-docker.sh && \
rm get-docker.sh
# ---------- pnpm -------------------------------------------------------------
# Langfuse monorepo relies on pnpm 9.5.0 (see CONTRIBUTING.md)
ENV PNPM_HOME="/pnpm"
@@ -29,11 +36,11 @@ RUN wget -qO- "https://github.com/golang-migrate/migrate/releases/download/v${MI
chmod +x /usr/local/bin/migrate
# ---------- Non-root user -----------------------------------------------------
# Use root for convenience in development containers
WORKDIR /workspace
# Create non-root user
RUN useradd -ms /bin/bash ubuntu
# Create non-root user with sudo privileges and docker group access
RUN useradd -ms /bin/bash ubuntu && \
usermod -aG sudo ubuntu && \
usermod -aG docker ubuntu && \
echo "ubuntu ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
# Pre-create pnpm store and set correct ownership to avoid first-run cost & permission issues
RUN pnpm store path > /dev/null && \
+7 -1
View File
@@ -4,5 +4,11 @@
"context": ".",
"dockerfile": "Dockerfile"
},
"start": "pnpm dx-f"
"start": "sudo service docker start",
"terminals": [
{
"name": "dev server",
"command": "pnpm run dx-f"
}
]
}
+1 -1
View File
@@ -11,7 +11,7 @@ alwaysApply: true
## File Structure
- We generally put all code related to a net-new feature into a folder within web/src/features.
- Checkout other features to learn about the common structure.
- Check out other features to learn about the common structure.
## API for frontend features
+2 -1
View File
@@ -5,4 +5,5 @@ alwaysApply: true
---
# General rules
- Linting in this repo only works if the development server is running
- Linting in this repo only works if the development server is running
- Always run the full mono-repo via `pnpm run dx` (use `pnpm dx-f` when you run this in a background agent). Thereby the database will also be seeded.
+4 -1
View File
@@ -182,4 +182,7 @@ To get a project, use the `get_project` capability with the full project name as
## Front-end Tips
### Window Location Handling
- Whenever you want to use or do use window.location..., ensure that you also add proper handling for a custom basePath
- Whenever you want to use or do use window.location..., ensure that you also add proper handling for a custom basePath
## TypeScript Best Practices
- In TypeScript, if possible, don't use the `any` type
+1 -1
View File
@@ -26,7 +26,7 @@
<a href="https://langfuse.com/roadmap"><strong>路线图</strong></a> ·
</div>
<br/>
<span>Langfuse 使用 <a href="https://github.com/orgs/langfuse/discussions"><strong>Github Discussions</strong></a> 作为支持和功能请求的平台。</span>
<span>Langfuse 使用 <a href="https://github.com/orgs/langfuse/discussions"><strong>GitHub Discussions</strong></a> 作为支持和功能请求的平台。</span>
<br/>
<span><b>我们正在招聘。</b> <a href="https://langfuse.com/careers"><strong>加入我们</strong></a>,从事产品工程和技术市场职位。</span>
<br/>
+1 -1
View File
@@ -26,7 +26,7 @@
<a href="https://langfuse.com/roadmap"><strong>ロードマップ</strong></a> ·
</div>
<br/>
<span>Langfuseは、サポートと機能リクエストのために <a href="https://github.com/orgs/langfuse/discussions"><strong>Github Discussions</strong></a> を利用しています。</span>
<span>Langfuseは、サポートと機能リクエストのために <a href="https://github.com/orgs/langfuse/discussions"><strong>GitHub Discussions</strong></a> を利用しています。</span>
<br/>
<span><b>We're hiring.</b> <a href="https://langfuse.com/careers"><strong>チームに加わる</strong></a> (製品エンジニアリングおよびテクニカルGTMのポジション)への応募をお待ちしています。</span>
<br/>
+1 -1
View File
@@ -26,7 +26,7 @@
<a href="https://langfuse.com/roadmap"><strong>Roadmap</strong></a> ·
</div>
<br/>
<span>Langfuse uses <a href="https://github.com/orgs/langfuse/discussions"><strong>Github Discussions</strong></a> for Support and Feature Requests.</span>
<span>Langfuse uses <a href="https://github.com/orgs/langfuse/discussions"><strong>GitHub Discussions</strong></a> for Support and Feature Requests.</span>
<br/>
<span><b>We're hiring.</b> <a href="https://langfuse.com/careers"><strong>Join us</strong></a> in product engineering and technical go-to-market roles.</span>
<br/>
+12 -2
View File
@@ -82,7 +82,7 @@ types:
CreateChatPromptRequest:
properties:
name: string
prompt: list<ChatMessage>
prompt: list<ChatMessageWithPlaceholders>
config: optional<unknown>
labels:
type: optional<list<string>>
@@ -132,6 +132,11 @@ types:
type: optional<map<string, unknown>>
docs: The dependency resolution graph for the current prompt. Null if prompt has no dependencies.
ChatMessageWithPlaceholders:
union:
chatmessage: ChatMessage
placeholder: PlaceholderMessage
ChatMessage:
properties:
role:
@@ -139,6 +144,11 @@ types:
content:
type: string
PlaceholderMessage:
properties:
name:
type: string
TextPrompt:
extends: BasePrompt
properties:
@@ -147,4 +157,4 @@ types:
ChatPrompt:
extends: BasePrompt
properties:
prompt: list<ChatMessage>
prompt: list<ChatMessageWithPlaceholders>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.75.4",
"version": "3.78.2",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
+2 -2
View File
@@ -33,7 +33,7 @@
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 72",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 0",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
"db:migrate": "DISABLE_ERD=false dotenv -e ../../.env -- npx prisma migrate dev",
"db:push": "DISABLE_ERD=false dotenv -e ../../.env -- npx prisma db push",
@@ -84,7 +84,7 @@
"jsonpath-plus": "10.3.0",
"kysely": "^0.27.4",
"langchain": "^0.3.28",
"langfuse-langchain": "3.37.4",
"langfuse-langchain": "3.38.1",
"lodash": "^4.17.21",
"lossless-json": "^4.1.1",
"next-auth": "^4.24.11",
+2 -1
View File
@@ -111,7 +111,8 @@ export const DashboardWidgetChartType = {
VERTICAL_BAR: "VERTICAL_BAR",
PIE: "PIE",
NUMBER: "NUMBER",
HISTOGRAM: "HISTOGRAM"
HISTOGRAM: "HISTOGRAM",
PIVOT_TABLE: "PIVOT_TABLE"
} as const;
export type DashboardWidgetChartType = (typeof DashboardWidgetChartType)[keyof typeof DashboardWidgetChartType];
export type Account = {
@@ -0,0 +1,9 @@
-- Database migration to add PIVOT_TABLE chart type to DashboardWidgetChartType enum
-- This enables the creation of pivot table widgets in the dashboard system
--
-- This migration adds support for tabular data visualization with configurable
-- row dimensions and metrics, extending the existing widget types (line charts,
-- bar charts, pie charts, etc.) to include pivot table functionality.
-- AlterEnum
ALTER TYPE "DashboardWidgetChartType" ADD VALUE 'PIVOT_TABLE';
+3 -2
View File
@@ -871,7 +871,7 @@ model JobExecution {
jobConfiguration JobConfiguration @relation(fields: [jobConfigurationId], references: [id], onDelete: Cascade)
jobTemplateId String? @map("job_template_id")
jobTemplate EvalTemplate? @relation(fields: [jobTemplateId], references: [id], onDelete: SetNull)
jobTemplate EvalTemplate? @relation(fields: [jobTemplateId], references: [id], onDelete: SetNull, onUpdate: NoAction)
status JobExecutionStatus
startTime DateTime? @map("start_time")
@@ -902,7 +902,7 @@ model DefaultLlmModel {
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
projectId String @map("project_id")
Project Project @relation(fields: [projectId], references: [id])
Project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
llmApiKeyId String @map("llm_api_key_id")
LlmApiKey LlmApiKeys @relation("LlmApiKeyId", fields: [llmApiKeyId], references: [id], onDelete: Cascade)
@@ -1157,6 +1157,7 @@ enum DashboardWidgetChartType {
PIE
NUMBER
HISTOGRAM
PIVOT_TABLE
}
model DashboardWidget {
+14 -15
View File
@@ -1,18 +1,18 @@
import {
PrismaClient,
type Project,
ScoreDataType,
JobConfiguration,
JobExecutionStatus,
} from "../../src/index";
import { hash } from "bcryptjs";
import { randomUUID } from "node:crypto";
import { parseArgs } from "node:util";
import { hash } from "bcryptjs";
import { v4 } from "uuid";
import { getDisplaySecretKey, hashSecretKey, logger } from "../../src/server";
import { encrypt } from "../../src/encryption";
import { redis } from "../../src/server/redis/redis";
import { randomUUID } from "crypto";
import {
type JobConfiguration,
JobExecutionStatus,
PrismaClient,
type Project,
ScoreDataType,
} from "../../src/index";
import { getDisplaySecretKey, hashSecretKey, logger } from "../../src/server";
import { redis } from "../../src/server/redis/redis";
import {EVAL_TRACE_COUNT,
FAILED_EVAL_TRACE_INTERVAL,
SEED_CHAT_ML_PROMPTS,
SEED_DATASETS,
@@ -27,7 +27,6 @@ import {
generateEvalScoreId,
generateEvalTraceId,
} from "./utils/seed-helpers";
import { EVAL_TRACE_COUNT } from "./utils/postgres-seed-constants";
type ConfigCategory = {
label: string;
@@ -179,7 +178,7 @@ async function main() {
const seedApiKey = {
id: "seed-api-key",
secret: process.env.SEED_SECRET_KEY ?? "sk-lf-1234567890",
secret: process.env.SEED_SECRET_KEY ?? "sk-lf-1234567890", // eslint-disable-line turbo/no-undeclared-env-vars
public: "pk-lf-1234567890",
note: "seeded key",
};
@@ -242,7 +241,7 @@ async function main() {
const secondKey = {
id: "seed-api-key-2",
secret: process.env.SEED_SECRET_KEY ?? "sk-lf-asdfghjkl",
secret: process.env.SEED_SECRET_KEY ?? "sk-lf-asdfghjkl", // eslint-disable-line turbo/no-undeclared-env-vars
public: "pk-lf-asdfghjkl",
note: "seeded key 2",
};
@@ -275,7 +274,7 @@ async function main() {
await createTraceSessions(project1, project2);
// If openai key is in environment, add it to the projects LLM API keys
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const OPENAI_API_KEY = process.env.OPENAI_API_KEY; // eslint-disable-line turbo/no-undeclared-env-vars
if (OPENAI_API_KEY) {
await prisma.llmApiKeys.create({
@@ -307,6 +307,34 @@ export const SEED_CHAT_ML_PROMPTS = [
labels: ["production", "latest"],
tags: ["tag1", "tag2"],
},
{
id: `prompt-chat-placeholder`,
createdBy: "user-1",
prompt: [
{
role: "system",
content:
'You are a very enthusiastic Langfuse representative who loves to help people! Langfuse is an open-source observability tool for developers of applications that use Large Language Models (LLMs). Given the following sections from the Langfuse documentation, answer the question using only that information, outputted in markdown format.\n\nPlease follow these guidelines:\n- Refer to the respective links of the documentation and select quality examples\n- Be kind.\n- Include emojis where it makes sense.\n- If the users have problems using Langfuse, tell them to reach out to the founders directly via the chat widget or GitHub at the end of your answer.\n- Answer as markdown, use highlights and paragraphs to structure the text.\n- Do not mention that you are "enthusiastic", the user does not need to know, will feel it from the style of your answers.\n- Only use information that is available in the context, do not make up any code that is not in the context.\n- Always put an empji at the end of the message.',
},
{
type: "placeholder",
name: "message_history",
},
{
role: "assistant",
content:
"Answering in next message based on your instructions only. What is the question?",
},
{
role: "user",
content: "{{question}}",
},
],
name: "prompt-chat-ml-with-placeholder",
version: 1,
labels: ["production", "latest"],
tags: ["tag1", "tag2"],
},
];
export const SEED_PROMPT_VERSIONS = [
+1 -1
View File
@@ -88,7 +88,7 @@ declare const globalThis: {
kyselyPrismaGlobal: { $kysely: Kysely<DB> } | undefined;
} & typeof global;
if (process.env.NODE_ENV === "development") {
if (process.env.NODE_ENV === "development") { // eslint-disable-line turbo/no-undeclared-env-vars
globalThis.prismaGlobal ??= createPrismaInstance(); // regular instantiation
globalThis.kyselyPrismaGlobal ??= globalThis.prismaGlobal.$extends(
kyselyExtension({
@@ -3,11 +3,11 @@ import { orderBy } from "../interfaces/orderBy";
import z from "zod/v4";
export enum TableViewPresetTableName {
Traces = "traces",
Observations = "observations",
Scores = "scores",
Sessions = "sessions",
Datasets = "datasets",
Traces = "traces", // eslint-disable-line no-unused-vars
Observations = "observations", // eslint-disable-line no-unused-vars
Scores = "scores", // eslint-disable-line no-unused-vars
Sessions = "sessions", // eslint-disable-line no-unused-vars
Datasets = "datasets", // eslint-disable-line no-unused-vars
}
const TableViewPresetDomainSchema = z.object({
+3 -1
View File
@@ -105,9 +105,11 @@ const EnvSchema = z.object({
.number()
.default(80e6), // 80MB
LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS: z.coerce.number().default(240_000), // 4 minutes
LANGFUSE_CLICKHOUSE_QUERY_MAX_ATTEMPTS: z.coerce.number().default(3), // Maximum attempts for socket hang up errors
LANGFUSE_SKIP_S3_LIST_FOR_OBSERVATIONS_PROJECT_IDS: z.string().optional(),
});
export const env: z.infer<typeof EnvSchema> =
process.env.DOCKER_BUILD === "1"
process.env.DOCKER_BUILD === "1" // eslint-disable-line turbo/no-undeclared-env-vars
? (process.env as any)
: EnvSchema.parse(removeEmptyEnvVariables(process.env));
+1
View File
@@ -7,3 +7,4 @@ export { MethodNotAllowedError } from "./MethodNotAllowedError";
export { ApiError } from "./ApiError";
export { InternalServerError } from "./InternalServerError";
export { LangfuseConflictError } from "./ConflictError";
export { QUEUE_ERROR_MESSAGES } from "./utils/constants";
@@ -0,0 +1,10 @@
export const QUEUE_ERROR_MESSAGES = {
API_KEY_ERROR: "API key for provider",
NO_DEFAULT_MODEL_ERROR: "No default model or custom model found for project",
MAPPED_DATA_ERROR:
"Please ensure the mapped data exists and consider extending the job delay.",
INVALID_JSON_ERROR: "is not valid JSON",
TOO_LOW_MAX_TOKENS_ERROR: "Error: Unterminated string in JSON at position",
OUTPUT_TOKENS_TOO_LONG_ERROR:
"Could not parse response content as the length limit was reached",
};
@@ -8,16 +8,16 @@ import { BatchTableNames } from "../../interfaces/tableNames";
import { TracingSearchType } from "../../interfaces/search";
export enum BatchExportStatus {
QUEUED = "QUEUED",
PROCESSING = "PROCESSING",
COMPLETED = "COMPLETED",
FAILED = "FAILED",
QUEUED = "QUEUED", // eslint-disable-line no-unused-vars
PROCESSING = "PROCESSING", // eslint-disable-line no-unused-vars
COMPLETED = "COMPLETED", // eslint-disable-line no-unused-vars
FAILED = "FAILED", // eslint-disable-line no-unused-vars
}
export enum BatchExportFileFormat {
JSON = "JSON",
CSV = "CSV",
JSONL = "JSONL",
JSON = "JSON", // eslint-disable-line no-unused-vars
CSV = "CSV", // eslint-disable-line no-unused-vars
JSONL = "JSONL", // eslint-disable-line no-unused-vars
}
// Use shared BatchTableNames enum for consistency across batch operations
@@ -42,7 +42,7 @@ function parseJsonDefault(selectedColumn: unknown, jsonSelector: string) {
export function extractValueFromObject(
obj: Record<string, unknown>,
mapping: z.infer<typeof variableMapping>,
parseJson?: (selectedColumn: unknown, jsonSelector: string) => unknown,
parseJson?: (selectedColumn: unknown, jsonSelector: string) => unknown, // eslint-disable-line no-unused-vars
): { value: string; error: Error | null } {
const selectedColumn = obj[mapping.selectedColumnId];
const jsonParser = parseJson || parseJsonDefault;
@@ -1,15 +1,17 @@
import { z } from "zod/v4";
import { jsonSchema, PromptNameSchema } from "@langfuse/shared";
import type { Prompt } from "@langfuse/shared";
import { COMMIT_MESSAGE_MAX_LENGTH } from "@/src/features/prompts/constants";
import type { Prompt } from "../../../prisma/generated/types";
import { jsonSchema } from "../../utils/zod";
import { COMMIT_MESSAGE_MAX_LENGTH } from "./constants";
import { PromptChatMessageSchema } from "../../server/llm/types";
import { PromptNameSchema } from "./validation";
export const ChatMessageSchema = z.object({
role: z.string(),
content: z.string(),
});
export const SingleChatMessageSchema = PromptChatMessageSchema;
export type SingleChatMessage = z.infer<typeof SingleChatMessageSchema>;
export enum PromptType {
// eslint-disable-next-line no-unused-vars
Chat = "chat",
// eslint-disable-next-line no-unused-vars
Text = "text",
}
@@ -41,7 +43,7 @@ const BaseCreateChatPromptSchema = z.object({
name: PromptNameSchema,
labels: z.array(PromptLabelSchema).default([]),
type: z.literal(PromptType.Chat),
prompt: z.array(ChatMessageSchema),
prompt: z.array(PromptChatMessageSchema),
config: jsonSchema.nullable().default({}),
tags: z.array(z.string()).nullish(),
});
@@ -134,7 +136,7 @@ export const BaseChatPromptSchema = z.object({
tags: z.array(z.string()),
labels: z.array(PromptLabelSchema),
type: z.literal(PromptType.Chat),
prompt: z.array(ChatMessageSchema),
prompt: z.array(PromptChatMessageSchema),
config: jsonSchema,
});
@@ -37,13 +37,13 @@ const ScorePropsAgainstConfigNumeric = z
dataType: z.literal("NUMERIC"),
})
.superRefine((data, ctx) => {
if (isPresent(data.maxValue) && data.value >= data.maxValue) {
if (isPresent(data.maxValue) && data.value > data.maxValue) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Value exceeds maximum value of ${data.maxValue} defined in config`,
});
}
if (isPresent(data.minValue) && data.value <= data.minValue) {
if (isPresent(data.minValue) && data.value < data.minValue) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Value is below minimum value of ${data.minValue} defined in config`,
@@ -150,7 +150,7 @@ const ValidatedScoreConfigSchema = z
*/
export const filterAndValidateDbScoreConfigList = (
scoreConfigs: ScoreConfigDbType[],
onParseError?: (error: z.ZodError) => void,
onParseError?: (error: z.ZodError) => void, // eslint-disable-line no-unused-vars
): ValidatedScoreConfig[] =>
scoreConfigs.reduce((acc, ts) => {
const result = ValidatedScoreConfigSchema.safeParse(ts);
+4
View File
@@ -13,6 +13,7 @@ export * from "./utils/json";
export * from "./utils/stringChecks";
export * from "./utils/objects";
export * from "./utils/typeChecks";
export * from "./utils/prompts";
export * from "./features/entitlements/plans";
export * from "./interfaces/rate-limits";
export * from "./tableDefinitions/typeHelpers";
@@ -43,6 +44,9 @@ export * from "./features/experiments/utils";
// prompts
export * from "./features/prompts/parsePromptDependencyTags";
export * from "./features/prompts/validation";
export * from "./features/prompts/types";
export * from "./features/prompts/constants";
export { compileChatMessages, compileChatMessagesWithIds, isPlaceholder, type MessagePlaceholderValues, type PromptMessage as ServerPromptMessage } from "./server/llm/compileChatMessages";
// export db types only
export * from "@prisma/client";
+7 -7
View File
@@ -4,11 +4,11 @@
* to avoid coupling between different batch operation types.
*/
export enum BatchTableNames {
Scores = "scores",
Sessions = "sessions",
Traces = "traces",
Observations = "observations",
DatasetRunItems = "dataset_run_items",
DatasetItems = "dataset_items",
AuditLogs = "audit_logs",
Scores = "scores", // eslint-disable-line no-unused-vars
Sessions = "sessions", // eslint-disable-line no-unused-vars
Traces = "traces", // eslint-disable-line no-unused-vars
Observations = "observations", // eslint-disable-line no-unused-vars
DatasetRunItems = "dataset_run_items", // eslint-disable-line no-unused-vars
DatasetItems = "dataset_items", // eslint-disable-line no-unused-vars
AuditLogs = "audit_logs", // eslint-disable-line no-unused-vars
}
+1
View File
@@ -11,6 +11,7 @@ export * from "./auth/gitHubEnterpriseProvider";
export * from "./llm/fetchLLMCompletion";
export * from "./llm/utils";
export * from "./llm/types";
export * from "./llm/compileChatMessages";
export * from "./utils/DatabaseReadStream";
export * from "./utils/transforms";
export * from "./clickhouse/client";
@@ -45,7 +45,7 @@ const getS3StorageServiceClient = (bucketName: string): StorageService => {
return s3StorageServiceClient;
};
export type TokenCountDelegate = (p: {
export type TokenCountDelegate = (p: { // eslint-disable-line no-unused-vars
model: Model;
text: unknown;
}) => number | undefined;
@@ -229,12 +229,21 @@ export const processEventBatch = async (
throw new Error("Redis not initialized, aborting event processing");
}
const projectIdsToSkipS3List =
env.LANGFUSE_SKIP_S3_LIST_FOR_OBSERVATIONS_PROJECT_IDS?.split(",") ?? [];
await Promise.all(
Object.keys(sortedBatchByEventBodyId).map(async (id) => {
const eventData = sortedBatchByEventBodyId[id];
const shardingKey = `${authCheck.scope.projectId}-${eventData.eventBodyId}`;
const queue = IngestionQueue.getInstance({ shardingKey });
const shouldSkipS3List =
getClickhouseEntityType(eventData.type) === "observation" &&
authCheck.scope.projectId !== null &&
(projectIdsToSkipS3List.includes(authCheck.scope.projectId) ||
source === "otel");
return queue
? queue.add(
QueueJobs.IngestionJob,
@@ -247,9 +256,7 @@ export const processEventBatch = async (
type: eventData.type,
eventBodyId: eventData.eventBodyId,
fileKey: eventData.key,
skipS3List:
source === "otel" &&
getClickhouseEntityType(eventData.type) === "observation",
skipS3List: shouldSkipS3List,
},
authCheck: authCheck as {
validKey: true;
@@ -22,7 +22,7 @@ export type SpanCtx = {
traceContext?: TCarrier;
};
type AsyncCallbackFn<T> = (span: opentelemetry.Span) => Promise<T>;
type AsyncCallbackFn<T> = (span: opentelemetry.Span) => Promise<T>; // eslint-disable-line no-unused-vars
export async function instrumentAsync<T>(
ctx: SpanCtx,
@@ -64,7 +64,7 @@ export async function instrumentAsync<T>(
);
}
type SyncCallbackFn<T> = (span: opentelemetry.Span) => T;
type SyncCallbackFn<T> = (span: opentelemetry.Span) => T; // eslint-disable-line no-unused-vars
export function instrumentSync<T>(
ctx: SpanCtx,
@@ -0,0 +1,116 @@
import { z } from "zod/v4";
import { v4 as uuidv4 } from "uuid";
import { type ChatMessage, type PlaceholderMessage, ChatMessageType, type PromptChatMessageSchema, type ChatMessageWithId, type ChatMessageWithIdNoPlaceholders, ChatMessageSchema } from "./types";
export type MessagePlaceholderValues = Record<string, ChatMessage[]>;
export type PromptMessage = z.infer<typeof PromptChatMessageSchema>;
export function isPlaceholder(message: PromptMessage): message is PlaceholderMessage {
return "type" in message && message.type === ChatMessageType.Placeholder;
}
function validateMessage(message: unknown): message is ChatMessage {
return ChatMessageSchema.safeParse(message).success;
}
function replaceTextVariables(
content: string,
textVariables: Record<string, string>
): string {
let result = content;
for (const [varName, varValue] of Object.entries(textVariables)) {
// Create regex that handles optional whitespace around variable name
const variablePattern = new RegExp(`{{\\s*${varName}\\s*}}`, "g");
result = result.replace(variablePattern, varValue);
}
return result;
}
function expandPlaceholder(
placeholder: PlaceholderMessage,
placeholderValues: MessagePlaceholderValues
): ChatMessage[] {
const replacementMessages = placeholderValues[placeholder.name];
if (!replacementMessages) {
throw new Error(`Missing value for message placeholder: ${placeholder.name}`);
}
if (!Array.isArray(replacementMessages)) {
throw new Error(`Placeholder value for '${placeholder.name}' must be an array of messages`);
}
for (const replacementMsg of replacementMessages) {
if (!validateMessage(replacementMsg)) {
throw new Error(`Invalid message format in placeholder '${placeholder.name}': messages must have 'role' and 'content' properties`);
}
}
return replacementMessages;
}
export function compileChatMessages(
messages: PromptMessage[],
placeholderValues: MessagePlaceholderValues,
textVariables?: Record<string, string>
): ChatMessage[] {
const expandedMessages = messages.flatMap((message) =>
isPlaceholder(message)
? expandPlaceholder(message, placeholderValues)
: [message as ChatMessage]
);
// substitute text variables
if (!textVariables || Object.keys(textVariables).length === 0) {
return expandedMessages;
}
return expandedMessages.map((message) => {
if (!message.content) {
return message;
}
return {
...message,
content: replaceTextVariables(message.content, textVariables)
};
});
}
export function compileChatMessagesWithIds(
messages: ChatMessageWithId[],
placeholderValues: Record<string, ChatMessage[]>,
textVariables?: Record<string, string>
): ChatMessageWithIdNoPlaceholders[] {
// TODO: check, is it even important to retain the IDs?
const expandedMessages = messages.flatMap((message) => {
if (isPlaceholder(message)) {
const expandedMsgs = expandPlaceholder(message, placeholderValues);
return expandedMsgs.map(msg => ({ ...msg, id: uuidv4() }));
} else {
// Preserve message IDs for already non-placeholder messages
return [message as ChatMessageWithIdNoPlaceholders];
}
});
// substitute text variables
if (!textVariables || Object.keys(textVariables).length === 0) {
return expandedMessages;
}
return expandedMessages.map((message) => {
if (!message.content) {
return message;
}
return {
...message,
content: replaceTextVariables(message.content, textVariables)
};
});
}
export function extractPlaceholderNames(messages: PromptMessage[]): string[] {
return messages
.filter((msg): msg is PlaceholderMessage => "type" in msg && msg.type === ChatMessageType.Placeholder)
.map(msg => msg.name);
}
@@ -63,7 +63,7 @@ type FetchLLMCompletionParams = LLMCompletionParams & {
};
export async function fetchLLMCompletion(
params: LLMCompletionParams & {
params: LLMCompletionParams & { // eslint-disable-line no-unused-vars
streaming: true;
},
): Promise<{
@@ -72,7 +72,7 @@ export async function fetchLLMCompletion(
}>;
export async function fetchLLMCompletion(
params: LLMCompletionParams & {
params: LLMCompletionParams & { // eslint-disable-line no-unused-vars
streaming: false;
},
): Promise<{
@@ -81,7 +81,7 @@ export async function fetchLLMCompletion(
}>;
export async function fetchLLMCompletion(
params: LLMCompletionParams & {
params: LLMCompletionParams & { // eslint-disable-line no-unused-vars
streaming: false;
structuredOutputSchema: ZodSchema;
},
@@ -91,7 +91,7 @@ export async function fetchLLMCompletion(
}>;
export async function fetchLLMCompletion(
params: LLMCompletionParams & {
params: LLMCompletionParams & { // eslint-disable-line no-unused-vars
tools: LLMToolDefinition[];
streaming: false;
},
+20 -7
View File
@@ -115,6 +115,8 @@ export enum ChatMessageRole {
Tool = "tool",
}
// Thought: should placeholder not semantically be part of this, because it can be
// PublicAPICreated of type? Works for now though.
export enum ChatMessageType {
System = "system",
Developer = "developer",
@@ -123,6 +125,7 @@ export enum ChatMessageType {
AssistantToolCall = "assistant-tool-call",
ToolResult = "tool-result",
PublicAPICreated = "public-api-created",
Placeholder = "placeholder",
}
export const SystemMessageSchema = z.object({
@@ -171,6 +174,12 @@ export const ToolResultMessageSchema = z.object({
});
export type ToolResultMessage = z.infer<typeof ToolResultMessageSchema>;
export const PlaceholderMessageSchema = z.object({
type: z.literal(ChatMessageType.Placeholder),
name: z.string().regex(/^[a-zA-Z][a-zA-Z0-9_]*$/, "Placeholder name must start with a letter and contain only alphanumeric characters and underscores"),
});
export type PlaceholderMessage = z.infer<typeof PlaceholderMessageSchema>;
export const ChatMessageDefaultRoleSchema = z.enum(ChatMessageRole);
export const ChatMessageSchema = z.union([
SystemMessageSchema,
@@ -193,12 +202,16 @@ export const ChatMessageSchema = z.union([
]);
export type ChatMessage = z.infer<typeof ChatMessageSchema>;
export type ChatMessageWithId = ChatMessage & { id: string };
export type ChatMessageWithId = (ChatMessage & { id: string }) | (PlaceholderMessage & { id: string });
export type ChatMessageWithIdNoPlaceholders = (ChatMessage & { id: string });
export const PromptChatMessageSchema = z.object({
role: z.string(),
content: z.string(),
});
export const PromptChatMessageSchema = z.union([
z.object({
role: z.string(),
content: z.string(),
}),
PlaceholderMessageSchema,
]);
export const PromptChatMessageListSchema = z.array(PromptChatMessageSchema);
export type PromptVariable = { name: string; value: string; isUsed: boolean };
@@ -218,11 +231,11 @@ export const SYSTEM_ROLES: string[] = [
ChatMessageRole.Developer,
];
export const TextPromptSchema = z.string().min(1, "Enter a prompt");
export const TextPromptContentSchema = z.string().min(1, "Enter a prompt");
export const PromptContentSchema = z.union([
PromptChatMessageListSchema,
TextPromptSchema,
TextPromptContentSchema,
]);
export type PromptContent = z.infer<typeof PromptContentSchema>;
@@ -432,19 +432,19 @@ export class FilterList {
this.filters.push(...filter);
}
find(predicate: (filter: Filter) => boolean) {
find(predicate: (filter: Filter) => boolean) { // eslint-disable-line no-unused-vars
return this.filters.find(predicate);
}
filter(predicate: (filter: Filter) => boolean) {
filter(predicate: (filter: Filter) => boolean) { // eslint-disable-line no-unused-vars
return new FilterList(this.filters.filter(predicate));
}
some(predicate: (filter: Filter) => boolean) {
some(predicate: (filter: Filter) => boolean) { // eslint-disable-line no-unused-vars
return this.filters.some(predicate);
}
forEach(callback: (filter: Filter) => void) {
forEach(callback: (filter: Filter) => void) { // eslint-disable-line no-unused-vars
this.filters.forEach(callback);
}
+3 -2
View File
@@ -70,8 +70,9 @@ export function getQueue(
return ScoreDeleteQueue.getInstance();
case QueueName.DeadLetterRetryQueue:
return DeadLetterRetryQueue.getInstance();
default:
const exhaustiveCheckDefault: never = queueName;
default: {
const exhaustiveCheckDefault: never = queueName; // eslint-disable-line no-case-declarations, no-unused-vars
throw new Error(`Queue ${queueName} not found`);
}
}
}
+3 -3
View File
@@ -167,9 +167,9 @@ const createRedisClient = () => {
declare global {
// eslint-disable-next-line no-var
var redis: undefined | ReturnType<typeof createRedisClient>;
var redis: undefined | ReturnType<typeof createRedisClient>; // eslint-disable-line no-unused-vars
}
export const redis = globalThis.redis ?? createRedisClient();
export const redis = globalThis.redis ?? createRedisClient(); // eslint-disable-line no-undef
if (env.NODE_ENV !== "production") globalThis.redis = redis;
if (env.NODE_ENV !== "production") globalThis.redis = redis; // eslint-disable-line no-undef
@@ -9,6 +9,7 @@ import { randomUUID } from "crypto";
import { getClickhouseEntityType } from "../clickhouse/schemaUtils";
import { NodeClickHouseClientConfigOptions } from "@clickhouse/client/dist/config";
import { context, SpanKind, trace } from "@opentelemetry/api";
import { backOff } from "exponential-backoff";
import {
StorageService,
StorageServiceFactory,
@@ -203,6 +204,18 @@ export async function* queryClickhouseStream<T>(opts: {
}
}
/**
* Determines if an error is retryable (socket hang up, connection reset, etc.)
*/
function isRetryableError(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const errorMessage = (error as Error).message?.toLowerCase() || "";
// Check for socket hang up and other network-related errors
return errorMessage.includes("socket hang up");
}
export async function queryClickhouse<T>(opts: {
query: string;
params?: Record<string, unknown> | undefined;
@@ -218,40 +231,78 @@ export async function queryClickhouse<T>(opts: {
span.setAttribute("db.query.text", opts.query);
span.setAttribute("db.operation.name", "SELECT");
const res = await clickhouseClient(opts.clickhouseConfigs).query({
query: opts.query,
format: "JSONEachRow",
query_params: opts.params,
clickhouse_settings: {
log_comment: JSON.stringify(opts.tags ?? {}),
},
});
// same logic as for prisma. we want to see queries in development
if (env.NODE_ENV === "development") {
logger.info(`clickhouse:query ${res.query_id} ${opts.query}`);
}
// Retry logic for socket hang up and other network errors
return await backOff(
async () => {
const res = await clickhouseClient(opts.clickhouseConfigs).query({
query: opts.query,
format: "JSONEachRow",
query_params: opts.params,
clickhouse_settings: {
log_comment: JSON.stringify(opts.tags ?? {}),
},
});
span.setAttribute("ch.queryId", res.query_id);
// add summary headers to the span. Helps to tune performance
const summaryHeader = res.response_headers["x-clickhouse-summary"];
if (summaryHeader) {
try {
const summary = Array.isArray(summaryHeader)
? JSON.parse(summaryHeader[0])
: JSON.parse(summaryHeader);
for (const key in summary) {
span.setAttribute(`ch.${key}`, summary[key]);
// same logic as for prisma. we want to see queries in development
if (env.NODE_ENV === "development") {
logger.info(`clickhouse:query ${res.query_id} ${opts.query}`);
}
} catch (error) {
logger.debug(
`Failed to parse clickhouse summary header ${summaryHeader}`,
error,
);
}
}
return await res.json<T>();
span.setAttribute("ch.queryId", res.query_id);
// add summary headers to the span. Helps to tune performance
const summaryHeader = res.response_headers["x-clickhouse-summary"];
if (summaryHeader) {
try {
const summary = Array.isArray(summaryHeader)
? JSON.parse(summaryHeader[0])
: JSON.parse(summaryHeader);
for (const key in summary) {
span.setAttribute(`ch.${key}`, summary[key]);
}
} catch (error) {
logger.debug(
`Failed to parse clickhouse summary header ${summaryHeader}`,
error,
);
}
}
return await res.json<T>();
},
{
numOfAttempts: env.LANGFUSE_CLICKHOUSE_QUERY_MAX_ATTEMPTS,
retry: (error: Error, attemptNumber: number) => {
const shouldRetry = isRetryableError(error);
if (shouldRetry) {
logger.warn(
`ClickHouse query failed with retryable error (attempt ${attemptNumber}/${env.LANGFUSE_CLICKHOUSE_QUERY_MAX_ATTEMPTS}): ${error.message}`,
{
error: error.message,
attemptNumber,
tags: opts.tags,
},
);
span.addEvent("clickhouse-query-retry", {
"retry.attempt": attemptNumber,
"retry.error": error.message,
});
} else {
logger.error(
`ClickHouse query failed with non-retryable error: ${error.message}`,
{
error: error.message,
tags: opts.tags,
},
);
}
return shouldRetry;
},
startingDelay: 100,
timeMultiple: 1,
maxDelay: 100,
},
);
},
);
}
@@ -122,6 +122,43 @@ export const traceRecordInsertSchema = traceRecordBaseSchema.extend({
});
export type TraceRecordInsertType = z.infer<typeof traceRecordInsertSchema>;
export const traceMtRecordInsertSchema = z.object({
// Identifiers
project_id: z.string(),
id: z.string(),
start_time: z.number(),
end_time: z.number().nullish(),
name: z.string(),
// Metadata properties
metadata: z.record(z.string(), z.string()),
user_id: z.string(),
session_id: z.string(),
environment: z.string(),
tags: z.array(z.string()),
version: z.string().nullish(),
release: z.string().nullish(),
// UI properties - nullable to prevent absent values being interpreted as overwrites
bookmarked: z.boolean().nullish(),
public: z.boolean().nullish(),
// Aggregations
observation_ids: z.array(z.string()),
score_ids: z.array(z.string()),
cost_details: z.record(z.string(), z.number()),
usage_details: z.record(z.string(), z.number()),
// Input/Output
input: z.string(),
output: z.string(),
created_at: z.number(),
updated_at: z.number(),
event_ts: z.number(),
});
export type TraceMtRecordInsertType = z.infer<typeof traceMtRecordInsertSchema>;
export const scoreRecordBaseSchema = z.object({
id: z.string(),
project_id: z.string(),
@@ -687,7 +687,11 @@ const getObservationsTableInternal = async <T>(
const appliedScoresFilter = scoresFilter.apply();
const appliedObservationsFilter = observationsFilter.apply();
const search = clickhouseSearchCondition(opts.searchQuery, opts.searchType, "o");
const search = clickhouseSearchCondition(
opts.searchQuery,
opts.searchType,
"o",
);
const scoresCte = `WITH scores_agg AS (
SELECT
@@ -1494,6 +1498,7 @@ export const getGenerationsForPostHog = async function* (
o.provided_model_name as model,
o.level as level,
o.version as version,
o.environment as environment,
t.id as trace_id,
t.name as trace_name,
t.session_id as trace_session_id,
@@ -1556,6 +1561,7 @@ export const getGenerationsForPostHog = async function* (
langfuse_model: record.model,
langfuse_level: record.level,
langfuse_tags: record.trace_tags,
langfuse_environment: record.environment,
langfuse_event_version: "1.0.0",
$session_id: record.posthog_session_id ?? null,
$set: {
@@ -1336,6 +1336,7 @@ export const getScoresForPostHog = async function* (
s.name as name,
s.value as value,
s.comment as comment,
s.environment as environment,
t.name as trace_name,
t.session_id as trace_session_id,
t.user_id as trace_user_id,
@@ -1388,6 +1389,7 @@ export const getScoresForPostHog = async function* (
langfuse_user_id: record.trace_user_id || "langfuse_unknown_user",
langfuse_release: record.trace_release,
langfuse_tags: record.trace_tags,
langfuse_environment: record.environment,
langfuse_event_version: "1.0.0",
$session_id: record.posthog_session_id ?? null,
$set: {
@@ -957,6 +957,7 @@ export const getTracesForPostHog = async function* (
t.release as release,
t.version as version,
t.tags as tags,
t.environment as environment,
t.metadata['$posthog_session_id'] as posthog_session_id,
o.total_cost as total_cost,
o.latency_milliseconds / 1000 as latency,
@@ -1006,6 +1007,7 @@ export const getTracesForPostHog = async function* (
langfuse_release: record.release,
langfuse_version: record.version,
langfuse_tags: record.tags,
langfuse_environment: record.environment,
langfuse_event_version: "1.0.0",
$session_id: record.posthog_session_id ?? null,
$set: {
@@ -33,6 +33,10 @@ export const HistogramChartConfig = BaseTotalValueChartConfig.extend({
bins: z.number().int().min(1).max(100).optional().default(10),
});
export const PivotTableChartConfig = BaseTotalValueChartConfig.extend({
type: z.literal("PIVOT_TABLE"),
});
// Define dimension schema
export const DimensionSchema = z.object({
field: z.string(),
@@ -53,6 +57,7 @@ export const ChartConfigSchema = z.discriminatedUnion("type", [
PieChartConfig,
BigNumberChartConfig,
HistogramChartConfig,
PivotTableChartConfig,
]);
export const DashboardDefinitionWidgetWidgetSchema = z.object({
@@ -1,6 +1,6 @@
import z from "zod/v4";
import { prisma } from "../../../db";
import { LangfuseNotFoundError } from "../../../errors";
import { LangfuseNotFoundError, QUEUE_ERROR_MESSAGES } from "../../../errors";
import { LLMApiKeySchema, ZodModelConfig } from "../../llm/types";
type ValidConfig = {
@@ -156,7 +156,7 @@ export class DefaultEvalModelService {
if (!selectedModel) {
return {
valid: false,
error: `No default model or custom model found for project ${projectId}.`,
error: `${QUEUE_ERROR_MESSAGES.NO_DEFAULT_MODEL_ERROR} ${projectId}.`,
};
}
@@ -173,7 +173,7 @@ export class DefaultEvalModelService {
if (!parsedKey.success) {
return {
valid: false,
error: `API key for provider "${selectedModel.provider}" not found in project ${projectId}.`,
error: `${QUEUE_ERROR_MESSAGES.API_KEY_ERROR} "${selectedModel.provider}" not found in project ${projectId}.`,
};
}
@@ -13,7 +13,7 @@ export class InMemoryFilterService {
static evaluateFilter<T>(
data: T,
filter: FilterState,
fieldMapper: (data: T, column: string) => unknown,
fieldMapper: (data: T, column: string) => unknown, // eslint-disable-line no-unused-vars
): boolean {
try {
// If no filters, data matches
@@ -45,7 +45,7 @@ export class InMemoryFilterService {
private static evaluateFilterCondition<T>(
data: T,
condition: FilterCondition,
fieldMapper: (data: T, column: string) => unknown,
fieldMapper: (data: T, column: string) => unknown, // eslint-disable-line no-unused-vars
): boolean {
const { column, type, operator } = condition;
@@ -21,9 +21,12 @@ export class PromptService {
private ttlSeconds: number;
constructor(
// eslint-disable-next-line no-unused-vars
private prisma: PrismaClient,
private redis: Redis | Cluster | null,
// eslint-disable-next-line no-unused-vars
private metricIncrementer?: // used for otel metrics
// eslint-disable-next-line no-unused-vars
(name: string, value?: number) => void,
cacheEnabled?: boolean, // used for testing
) {
@@ -13,8 +13,8 @@ export type PromptParams = {
);
export enum PromptServiceMetrics {
PromptCacheHit = "prompt_cache_hit",
PromptCacheMiss = "prompt_cache_miss",
PromptCacheHit = "prompt_cache_hit", // eslint-disable-line no-unused-vars
PromptCacheMiss = "prompt_cache_miss", // eslint-disable-line no-unused-vars
}
export type PartialPrompt = Pick<
@@ -28,21 +28,21 @@ type UploadFile = {
};
export interface StorageService {
uploadFile(params: UploadFile): Promise<{ signedUrl: string }>;
uploadFile(params: UploadFile): Promise<{ signedUrl: string }>; // eslint-disable-line no-unused-vars
uploadJson(path: string, body: Record<string, unknown>[]): Promise<void>;
uploadJson(path: string, body: Record<string, unknown>[]): Promise<void>; // eslint-disable-line no-unused-vars
download(path: string): Promise<string>;
download(path: string): Promise<string>; // eslint-disable-line no-unused-vars
listFiles(prefix: string): Promise<{ file: string; createdAt: Date }[]>;
listFiles(prefix: string): Promise<{ file: string; createdAt: Date }[]>; // eslint-disable-line no-unused-vars
getSignedUrl(
fileName: string,
ttlSeconds: number,
asAttachment?: boolean,
fileName: string, // eslint-disable-line no-unused-vars
ttlSeconds: number, // eslint-disable-line no-unused-vars
asAttachment?: boolean, // eslint-disable-line no-unused-vars
): Promise<string>;
getSignedUploadUrl(params: {
getSignedUploadUrl(params: { // eslint-disable-line no-unused-vars
path: string;
ttlSeconds: number;
sha256Hash: string;
@@ -50,7 +50,7 @@ export interface StorageService {
contentLength: number;
}): Promise<string>;
deleteFiles(paths: string[]): Promise<void>;
deleteFiles(paths: string[]): Promise<void>; // eslint-disable-line no-unused-vars
}
export class StorageServiceFactory {
@@ -213,7 +213,7 @@ class AzureBlobStorageService implements StorageService {
}
private async streamToString(
readableStream: NodeJS.ReadableStream,
readableStream: NodeJS.ReadableStream, // eslint-disable-line no-undef
): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: string[] = [];
@@ -24,12 +24,12 @@ export class DatabaseReadStream<EntityType> extends Readable {
constructor(
// the delegate function takes care of querying the database in a paginated manner
private queryDelegate: (
pageSize: number,
offset: number
private queryDelegate: ( // eslint-disable-line no-unused-vars
pageSize: number, // eslint-disable-line no-unused-vars
offset: number // eslint-disable-line no-unused-vars
) => Promise<Array<EntityType>>,
private pageSize: number,
private maxRecords?: number
private pageSize: number, // eslint-disable-line no-unused-vars
private maxRecords?: number // eslint-disable-line no-unused-vars
) {
super({ objectMode: true }); // Set object mode to true to allow pushing objects to the stream rather than strings or buffers
@@ -11,7 +11,7 @@ export function transformStreamToCsv(): Transform {
objectMode: true,
transform(
row: Record<string, any>,
encoding: BufferEncoding,
encoding: BufferEncoding, // eslint-disable-line no-undef
callback: TransformCallback,
): void {
if (isFirstChunk) {
@@ -9,8 +9,8 @@ export function transformStreamToJson(): Transform {
transform(
row: any,
encoding: BufferEncoding,
callback: TransformCallback
encoding: BufferEncoding, // eslint-disable-line no-undef, no-unused-vars
callback: TransformCallback,
): void {
if (isFirstElement) {
this.push("["); // Push the opening bracket for the first element
@@ -7,7 +7,7 @@ export function transformStreamToJsonl(): Transform {
transform(
row: Record<string, any>,
encoding: BufferEncoding,
encoding: BufferEncoding, // eslint-disable-line no-undef, no-unused-vars
callback: TransformCallback,
): void {
this.push(stringify(row) + "\n");
+24
View File
@@ -0,0 +1,24 @@
/**
* Client-safe utility functions for prompt handling
*/
export interface PromptMessage {
type?: string;
name?: string;
role?: string;
content?: string;
}
/**
* Extracts placeholder names from prompt messages.
* This is a client-safe version that doesn't depend on server-side types.
* @param messages Array of prompt messages
* @returns Array of placeholder names
*/
export function extractPlaceholderNames(messages: PromptMessage[]): string[] {
return messages
.filter((msg): msg is PromptMessage & { name: string } =>
msg.type === "placeholder" && typeof msg.name === "string"
)
.map(msg => msg.name);
}
+214 -132
View File
@@ -112,7 +112,7 @@ importers:
version: 7.12.0(eslint@8.57.0)(typescript@5.4.5)
'@vercel/style-guide':
specifier: ^6.0.0
version: 6.0.0(@next/eslint-plugin-next@14.2.15)(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8))(prettier@3.5.3)(typescript@5.4.5)(vitest@2.1.2(@types/node@20.14.8))
version: 6.0.0(@next/eslint-plugin-next@14.2.15)(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0))(prettier@3.5.3)(typescript@5.4.5)(vitest@2.1.2(@types/node@20.14.8)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5))(terser@5.43.1))
eslint-config-next:
specifier: ^14.2.15
version: 14.2.15(eslint@8.57.0)(typescript@5.4.5)
@@ -224,8 +224,8 @@ importers:
specifier: ^0.3.28
version: 0.3.28(@langchain/anthropic@0.3.22(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/aws@0.1.11(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(@langchain/google-genai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/google-vertexai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(zod@3.25.62))(axios@1.8.2)(cheerio@1.0.0)(handlebars@4.7.8)(openai@4.104.0(zod@3.25.62))
langfuse-langchain:
specifier: 3.37.4
version: 3.37.4(langchain@0.3.28(@langchain/anthropic@0.3.22(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/aws@0.1.11(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(@langchain/google-genai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/google-vertexai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(zod@3.25.62))(axios@1.8.2)(cheerio@1.0.0)(handlebars@4.7.8)(openai@4.104.0(zod@3.25.62)))
specifier: 3.38.1
version: 3.38.1(langchain@0.3.28(@langchain/anthropic@0.3.22(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/aws@0.1.11(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(@langchain/google-genai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/google-vertexai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(zod@3.25.62))(axios@1.8.2)(cheerio@1.0.0)(handlebars@4.7.8)(openai@4.104.0(zod@3.25.62)))
lodash:
specifier: ^4.17.21
version: 4.17.21
@@ -328,7 +328,7 @@ importers:
version: 5.4.5
vitest:
specifier: ^2.1.2
version: 2.1.2(@types/node@20.11.29)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.11.29)(typescript@5.4.5))(terser@5.43.0)
version: 2.1.2(@types/node@20.11.29)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.11.29)(typescript@5.4.5))(terser@5.43.1)
web:
dependencies:
@@ -758,7 +758,7 @@ importers:
version: 0.5.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5)))
'@testing-library/jest-dom':
specifier: ^6.4.6
version: 6.4.6(@jest/globals@29.7.0)(@types/jest@29.5.12)(jest@29.7.0(@types/node@20.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5)))(vitest@2.1.2(@types/node@20.10.5))
version: 6.4.6(@jest/globals@29.7.0)(@types/jest@29.5.12)(jest@29.7.0(@types/node@20.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5)))(vitest@2.1.2(@types/node@20.10.5)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.10.5)(typescript@5.4.5))(terser@5.43.1))
'@testing-library/react':
specifier: ^15.0.7
version: 15.0.7(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -1038,7 +1038,7 @@ importers:
version: 5.4.5
vitest:
specifier: ^2.1.2
version: 2.1.2(@types/node@20.11.29)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.11.29)(typescript@5.4.5))(terser@5.43.0)
version: 2.1.2(@types/node@20.11.29)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.11.29)(typescript@5.4.5))(terser@5.43.1)
wait-for-expect:
specifier: ^3.0.2
version: 3.0.2
@@ -8936,18 +8936,18 @@ packages:
typeorm:
optional: true
langfuse-core@3.37.4:
resolution: {integrity: sha512-yqXkQ1/8V9TxF8q6oULWPQ4cYZlUs1egN6JfGuhJSWb5x2Ymgt4Qh9of/Kz//CTM7IZgzZqvrlnx0VXhNKDITg==}
langfuse-core@3.38.1:
resolution: {integrity: sha512-qZsHZZ05eHv+EQoPmfrm6bWUflzvlCSAfT8EP1PP1WeHSwEcwT4p7qAs9H0EnWcUtm0dAdyd6U75HGWu3/ETUA==}
engines: {node: '>=18'}
langfuse-langchain@3.37.4:
resolution: {integrity: sha512-yh2+Rg+NqN6k+E29VW9lnLp9qpldBE3Ldhh/qsvInyk0hdhF64I/z0hWPFY4tYNvbzBXDHgApJ+66nSnXBf4Tg==}
langfuse-langchain@3.38.1:
resolution: {integrity: sha512-lkgEwUuGEtkmtxcMhfFn90FRAlGOSGVWczyBFMXck+1YcqnYLIuE/MNg3ufN54yPRVJFfEXj0kesZSLERGQ8eQ==}
engines: {node: '>=18'}
peerDependencies:
langchain: '>=0.0.157 <0.4.0'
langfuse@3.37.4:
resolution: {integrity: sha512-MxnJXkS3xqlydMKWouanghkWZb8OqoDns+NIAZoD+y/WQh1mtx40n8c0dOaT7OahzSfB9ZbWj+j+yqJww/PJYQ==}
langfuse@3.38.1:
resolution: {integrity: sha512-rv0x+NWi4JDN8KUyGcl8JQ7xmM/0UBXEcYmtBR+msLLImsM3I07CPE6imp3N2I4AbByPNKF4IU8Kq/LFS3D34A==}
engines: {node: '>=18'}
langium@3.0.0:
@@ -11310,11 +11310,6 @@ packages:
uglify-js:
optional: true
terser@5.43.0:
resolution: {integrity: sha512-CqNNxKSGKSZCunSvwKLTs8u8sGGlp27sxNZ4quGh0QeNuyHM0JSEM/clM9Mf4zUp6J+tO2gUXhgXT2YMMkwfKQ==}
engines: {node: '>=10'}
hasBin: true
terser@5.43.1:
resolution: {integrity: sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==}
engines: {node: '>=10'}
@@ -14359,12 +14354,26 @@ snapshots:
optionalDependencies:
'@types/node': 20.14.8
'@inquirer/confirm@5.0.2(@types/node@20.10.5)':
dependencies:
'@inquirer/core': 10.1.0(@types/node@20.10.5)
'@inquirer/type': 3.0.1(@types/node@20.10.5)
'@types/node': 20.10.5
optional: true
'@inquirer/confirm@5.0.2(@types/node@20.11.29)':
dependencies:
'@inquirer/core': 10.1.0(@types/node@20.11.29)
'@inquirer/type': 3.0.1(@types/node@20.11.29)
'@types/node': 20.11.29
'@inquirer/confirm@5.0.2(@types/node@20.14.8)':
dependencies:
'@inquirer/core': 10.1.0(@types/node@20.14.8)
'@inquirer/type': 3.0.1(@types/node@20.14.8)
'@types/node': 20.14.8
optional: true
'@inquirer/confirm@5.1.12(@types/node@20.14.8)':
dependencies:
'@inquirer/core': 10.1.13(@types/node@20.14.8)
@@ -14372,6 +14381,21 @@ snapshots:
optionalDependencies:
'@types/node': 20.14.8
'@inquirer/core@10.1.0(@types/node@20.10.5)':
dependencies:
'@inquirer/figures': 1.0.8
'@inquirer/type': 3.0.4(@types/node@20.10.5)
ansi-escapes: 4.3.2
cli-width: 4.1.0
mute-stream: 2.0.0
signal-exit: 4.1.0
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
yoctocolors-cjs: 2.1.2
transitivePeerDependencies:
- '@types/node'
optional: true
'@inquirer/core@10.1.0(@types/node@20.11.29)':
dependencies:
'@inquirer/figures': 1.0.8
@@ -14386,6 +14410,21 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
'@inquirer/core@10.1.0(@types/node@20.14.8)':
dependencies:
'@inquirer/figures': 1.0.8
'@inquirer/type': 3.0.4(@types/node@20.14.8)
ansi-escapes: 4.3.2
cli-width: 4.1.0
mute-stream: 2.0.0
signal-exit: 4.1.0
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
yoctocolors-cjs: 2.1.2
transitivePeerDependencies:
- '@types/node'
optional: true
'@inquirer/core@10.1.13(@types/node@20.14.8)':
dependencies:
'@inquirer/figures': 1.0.12
@@ -14483,14 +14522,34 @@ snapshots:
optionalDependencies:
'@types/node': 20.14.8
'@inquirer/type@3.0.1(@types/node@20.10.5)':
dependencies:
'@types/node': 20.10.5
optional: true
'@inquirer/type@3.0.1(@types/node@20.11.29)':
dependencies:
'@types/node': 20.11.29
'@inquirer/type@3.0.1(@types/node@20.14.8)':
dependencies:
'@types/node': 20.14.8
optional: true
'@inquirer/type@3.0.4(@types/node@20.10.5)':
optionalDependencies:
'@types/node': 20.10.5
optional: true
'@inquirer/type@3.0.4(@types/node@20.11.29)':
optionalDependencies:
'@types/node': 20.11.29
'@inquirer/type@3.0.4(@types/node@20.14.8)':
optionalDependencies:
'@types/node': 20.14.8
optional: true
'@inquirer/type@3.0.7(@types/node@20.14.8)':
optionalDependencies:
'@types/node': 20.14.8
@@ -17685,7 +17744,7 @@ snapshots:
lz-string: 1.5.0
pretty-format: 27.5.1
'@testing-library/jest-dom@6.4.6(@jest/globals@29.7.0)(@types/jest@29.5.12)(jest@29.7.0(@types/node@20.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5)))(vitest@2.1.2(@types/node@20.10.5))':
'@testing-library/jest-dom@6.4.6(@jest/globals@29.7.0)(@types/jest@29.5.12)(jest@29.7.0(@types/node@20.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5)))(vitest@2.1.2(@types/node@20.10.5)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.10.5)(typescript@5.4.5))(terser@5.43.1))':
dependencies:
'@adobe/css-tools': 4.4.0
'@babel/runtime': 7.24.7
@@ -17699,7 +17758,7 @@ snapshots:
'@jest/globals': 29.7.0
'@types/jest': 29.5.12
jest: 29.7.0(@types/node@20.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5))
vitest: 2.1.2(@types/node@20.10.5)
vitest: 2.1.2(@types/node@20.10.5)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.10.5)(typescript@5.4.5))(terser@5.43.1)
'@testing-library/react@15.0.7(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
@@ -18358,7 +18417,7 @@ snapshots:
'@ungap/structured-clone@1.2.0': {}
'@vercel/style-guide@6.0.0(@next/eslint-plugin-next@14.2.15)(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8))(prettier@3.5.3)(typescript@5.4.5)(vitest@2.1.2(@types/node@20.14.8))':
'@vercel/style-guide@6.0.0(@next/eslint-plugin-next@14.2.15)(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0))(prettier@3.5.3)(typescript@5.4.5)(vitest@2.1.2(@types/node@20.14.8)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5))(terser@5.43.1))':
dependencies:
'@babel/core': 7.24.3
'@babel/eslint-parser': 7.24.1(@babel/core@7.24.3)(eslint@8.57.0)
@@ -18366,19 +18425,19 @@ snapshots:
'@typescript-eslint/eslint-plugin': 7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)
'@typescript-eslint/parser': 7.12.0(eslint@8.57.0)(typescript@5.4.5)
eslint-config-prettier: 9.1.0(eslint@8.57.0)
eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0))
eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.29.1)
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0)
eslint-plugin-eslint-comments: 3.2.0(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-jest: 27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8))(typescript@5.4.5)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-jest: 27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0))(typescript@5.4.5)
eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.0)
eslint-plugin-playwright: 1.5.4(eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8))(typescript@5.4.5))(eslint@8.57.0)
eslint-plugin-playwright: 1.5.4(eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0))(typescript@5.4.5))(eslint@8.57.0)
eslint-plugin-react: 7.34.1(eslint@8.57.0)
eslint-plugin-react-hooks: 4.6.0(eslint@8.57.0)
eslint-plugin-testing-library: 6.2.0(eslint@8.57.0)(typescript@5.4.5)
eslint-plugin-tsdoc: 0.2.17
eslint-plugin-unicorn: 51.0.1(eslint@8.57.0)
eslint-plugin-vitest: 0.3.26(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)(vitest@2.1.2(@types/node@20.14.8))
eslint-plugin-vitest: 0.3.26(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)(vitest@2.1.2(@types/node@20.14.8)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5))(terser@5.43.1))
prettier-plugin-packagejson: 2.4.12(prettier@3.5.3)
optionalDependencies:
'@next/eslint-plugin-next': 14.2.15
@@ -18399,31 +18458,33 @@ snapshots:
chai: 5.1.1
tinyrainbow: 1.2.0
'@vitest/mocker@2.1.2(@vitest/spy@2.1.2)(msw@2.6.5(@types/node@20.11.29)(typescript@5.4.5))(vite@5.2.14(@types/node@20.11.29)(terser@5.43.0))':
'@vitest/mocker@2.1.2(@vitest/spy@2.1.2)(msw@2.6.5(@types/node@20.10.5)(typescript@5.4.5))(vite@5.2.14(@types/node@20.10.5)(terser@5.43.1))':
dependencies:
'@vitest/spy': 2.1.2
estree-walker: 3.0.3
magic-string: 0.30.11
optionalDependencies:
msw: 2.6.5(@types/node@20.10.5)(typescript@5.4.5)
vite: 5.2.14(@types/node@20.10.5)(terser@5.43.1)
optional: true
'@vitest/mocker@2.1.2(@vitest/spy@2.1.2)(msw@2.6.5(@types/node@20.11.29)(typescript@5.4.5))(vite@5.2.14(@types/node@20.11.29)(terser@5.43.1))':
dependencies:
'@vitest/spy': 2.1.2
estree-walker: 3.0.3
magic-string: 0.30.11
optionalDependencies:
msw: 2.6.5(@types/node@20.11.29)(typescript@5.4.5)
vite: 5.2.14(@types/node@20.11.29)(terser@5.43.0)
vite: 5.2.14(@types/node@20.11.29)(terser@5.43.1)
'@vitest/mocker@2.1.2(@vitest/spy@2.1.2)(vite@5.2.14(@types/node@20.10.5))':
'@vitest/mocker@2.1.2(@vitest/spy@2.1.2)(msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5))(vite@5.2.14(@types/node@20.14.8)(terser@5.43.1))':
dependencies:
'@vitest/spy': 2.1.2
estree-walker: 3.0.3
magic-string: 0.30.11
optionalDependencies:
vite: 5.2.14(@types/node@20.10.5)
optional: true
'@vitest/mocker@2.1.2(@vitest/spy@2.1.2)(vite@5.2.14(@types/node@20.14.8))':
dependencies:
'@vitest/spy': 2.1.2
estree-walker: 3.0.3
magic-string: 0.30.11
optionalDependencies:
vite: 5.2.14(@types/node@20.14.8)
msw: 2.6.5(@types/node@20.14.8)(typescript@5.4.5)
vite: 5.2.14(@types/node@20.14.8)(terser@5.43.1)
optional: true
'@vitest/pretty-format@2.1.2':
@@ -19505,7 +19566,7 @@ snapshots:
- supports-color
- ts-node
create-jest@29.7.0(@types/node@20.14.8):
create-jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0):
dependencies:
'@jest/types': 29.6.3
chalk: 4.1.2
@@ -20319,7 +20380,7 @@ snapshots:
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.0)
eslint-plugin-react: 7.34.1(eslint@8.57.0)
eslint-plugin-react-hooks: 4.6.0(eslint@8.57.0)
@@ -20336,7 +20397,7 @@ snapshots:
eslint-config-standard@17.1.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint-plugin-n@16.6.2(eslint@8.57.0))(eslint-plugin-promise@6.4.0(eslint@8.57.0))(eslint@8.57.0):
dependencies:
eslint: 8.57.0
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-n: 16.6.2(eslint@8.57.0)
eslint-plugin-promise: 6.4.0(eslint@8.57.0)
@@ -20346,9 +20407,9 @@ snapshots:
eslint-plugin-turbo: 2.5.4(eslint@8.57.0)(turbo@2.5.4)
turbo: 2.5.4
eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)):
eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.29.1):
dependencies:
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
eslint-import-resolver-node@0.3.9:
dependencies:
@@ -20363,8 +20424,8 @@ snapshots:
debug: 4.3.7(supports-color@5.5.0)
enhanced-resolve: 5.17.1
eslint: 8.57.0
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
fast-glob: 3.3.3
get-tsconfig: 4.8.1
is-core-module: 2.15.1
@@ -20381,7 +20442,7 @@ snapshots:
enhanced-resolve: 5.17.1
eslint: 8.57.0
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
fast-glob: 3.3.3
get-tsconfig: 4.8.1
is-core-module: 2.15.1
@@ -20392,17 +20453,6 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
eslint-module-utils@2.8.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 7.12.0(eslint@8.57.0)(typescript@5.4.5)
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0)
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.8.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0):
dependencies:
debug: 3.2.7
@@ -20414,6 +20464,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.8.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 7.12.0(eslint@8.57.0)(typescript@5.4.5)
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0)
transitivePeerDependencies:
- supports-color
eslint-plugin-es-x@7.8.0(eslint@8.57.0):
dependencies:
'@eslint-community/eslint-utils': 4.7.0(eslint@8.57.0)
@@ -20427,7 +20488,7 @@ snapshots:
eslint: 8.57.0
ignore: 5.3.2
eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0):
eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0):
dependencies:
array-includes: 3.1.7
array.prototype.findlastindex: 1.2.4
@@ -20454,40 +20515,13 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0):
dependencies:
array-includes: 3.1.7
array.prototype.findlastindex: 1.2.4
array.prototype.flat: 1.3.2
array.prototype.flatmap: 1.3.2
debug: 3.2.7
doctrine: 2.1.0
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
hasown: 2.0.2
is-core-module: 2.15.1
is-glob: 4.0.3
minimatch: 3.1.2
object.fromentries: 2.0.8
object.groupby: 1.0.3
object.values: 1.2.0
semver: 6.3.1
tsconfig-paths: 3.15.0
optionalDependencies:
'@typescript-eslint/parser': 7.12.0(eslint@8.57.0)(typescript@5.4.5)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
- supports-color
eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8))(typescript@5.4.5):
eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0))(typescript@5.4.5):
dependencies:
'@typescript-eslint/utils': 5.62.0(eslint@8.57.0)(typescript@5.4.5)
eslint: 8.57.0
optionalDependencies:
'@typescript-eslint/eslint-plugin': 7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)
jest: 29.7.0(@types/node@20.14.8)
jest: 29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0)
transitivePeerDependencies:
- supports-color
- typescript
@@ -20529,12 +20563,12 @@ snapshots:
eslint-plugin-only-warn@1.1.0: {}
eslint-plugin-playwright@1.5.4(eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8))(typescript@5.4.5))(eslint@8.57.0):
eslint-plugin-playwright@1.5.4(eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0))(typescript@5.4.5))(eslint@8.57.0):
dependencies:
eslint: 8.57.0
globals: 13.24.0
optionalDependencies:
eslint-plugin-jest: 27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8))(typescript@5.4.5)
eslint-plugin-jest: 27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0))(typescript@5.4.5)
eslint-plugin-prettier@5.1.3(@types/eslint@8.56.12)(eslint-config-prettier@9.1.0(eslint@8.57.0))(eslint@8.57.0)(prettier@3.3.3):
dependencies:
@@ -20617,13 +20651,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-vitest@0.3.26(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)(vitest@2.1.2(@types/node@20.14.8)):
eslint-plugin-vitest@0.3.26(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)(vitest@2.1.2(@types/node@20.14.8)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5))(terser@5.43.1)):
dependencies:
'@typescript-eslint/utils': 7.3.1(eslint@8.57.0)(typescript@5.4.5)
eslint: 8.57.0
optionalDependencies:
'@typescript-eslint/eslint-plugin': 7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)
vitest: 2.1.2(@types/node@20.14.8)
vitest: 2.1.2(@types/node@20.14.8)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5))(terser@5.43.1)
transitivePeerDependencies:
- supports-color
- typescript
@@ -21884,13 +21918,13 @@ snapshots:
- supports-color
- ts-node
jest-cli@29.7.0(@types/node@20.14.8):
jest-cli@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0):
dependencies:
'@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5))
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
create-jest: 29.7.0(@types/node@20.14.8)
create-jest: 29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0)
exit: 0.1.2
import-local: 3.1.0
jest-config: 29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5))
@@ -22191,7 +22225,7 @@ snapshots:
jest-worker@27.5.1:
dependencies:
'@types/node': 20.10.5
'@types/node': 20.14.8
merge-stream: 2.0.0
supports-color: 8.1.1
@@ -22214,12 +22248,12 @@ snapshots:
- supports-color
- ts-node
jest@29.7.0(@types/node@20.14.8):
jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0):
dependencies:
'@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5))
'@jest/types': 29.6.3
import-local: 3.1.0
jest-cli: 29.7.0(@types/node@20.14.8)
jest-cli: 29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0)
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@@ -22417,19 +22451,19 @@ snapshots:
- openai
- ws
langfuse-core@3.37.4:
langfuse-core@3.38.1:
dependencies:
mustache: 4.2.0
langfuse-langchain@3.37.4(langchain@0.3.28(@langchain/anthropic@0.3.22(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/aws@0.1.11(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(@langchain/google-genai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/google-vertexai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(zod@3.25.62))(axios@1.8.2)(cheerio@1.0.0)(handlebars@4.7.8)(openai@4.104.0(zod@3.25.62))):
langfuse-langchain@3.38.1(langchain@0.3.28(@langchain/anthropic@0.3.22(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/aws@0.1.11(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(@langchain/google-genai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/google-vertexai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(zod@3.25.62))(axios@1.8.2)(cheerio@1.0.0)(handlebars@4.7.8)(openai@4.104.0(zod@3.25.62))):
dependencies:
langchain: 0.3.28(@langchain/anthropic@0.3.22(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/aws@0.1.11(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(@langchain/google-genai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/google-vertexai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(zod@3.25.62))(axios@1.8.2)(cheerio@1.0.0)(handlebars@4.7.8)(openai@4.104.0(zod@3.25.62))
langfuse: 3.37.4
langfuse-core: 3.37.4
langfuse: 3.38.1
langfuse-core: 3.38.1
langfuse@3.37.4:
langfuse@3.38.1:
dependencies:
langfuse-core: 3.37.4
langfuse-core: 3.38.1
langium@3.0.0:
dependencies:
@@ -23127,6 +23161,32 @@ snapshots:
optionalDependencies:
msgpackr-extract: 3.0.3
msw@2.6.5(@types/node@20.10.5)(typescript@5.4.5):
dependencies:
'@bundled-es-modules/cookie': 2.0.1
'@bundled-es-modules/statuses': 1.0.1
'@bundled-es-modules/tough-cookie': 0.1.6
'@inquirer/confirm': 5.0.2(@types/node@20.10.5)
'@mswjs/interceptors': 0.37.1
'@open-draft/deferred-promise': 2.2.0
'@open-draft/until': 2.1.0
'@types/cookie': 0.6.0
'@types/statuses': 2.0.5
chalk: 4.1.2
graphql: 16.9.0
headers-polyfill: 4.0.3
is-node-process: 1.2.0
outvariant: 1.4.3
path-to-regexp: 6.3.0
strict-event-emitter: 0.5.1
type-fest: 4.27.0
yargs: 17.7.2
optionalDependencies:
typescript: 5.4.5
transitivePeerDependencies:
- '@types/node'
optional: true
msw@2.6.5(@types/node@20.11.29)(typescript@5.4.5):
dependencies:
'@bundled-es-modules/cookie': 2.0.1
@@ -23152,6 +23212,32 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5):
dependencies:
'@bundled-es-modules/cookie': 2.0.1
'@bundled-es-modules/statuses': 1.0.1
'@bundled-es-modules/tough-cookie': 0.1.6
'@inquirer/confirm': 5.0.2(@types/node@20.14.8)
'@mswjs/interceptors': 0.37.1
'@open-draft/deferred-promise': 2.2.0
'@open-draft/until': 2.1.0
'@types/cookie': 0.6.0
'@types/statuses': 2.0.5
chalk: 4.1.2
graphql: 16.9.0
headers-polyfill: 4.0.3
is-node-process: 1.2.0
outvariant: 1.4.3
path-to-regexp: 6.3.0
strict-event-emitter: 0.5.1
type-fest: 4.27.0
yargs: 17.7.2
optionalDependencies:
typescript: 5.4.5
transitivePeerDependencies:
- '@types/node'
optional: true
mustache@4.2.0: {}
mute-stream@2.0.0: {}
@@ -25180,14 +25266,6 @@ snapshots:
terser: 5.43.1
webpack: 5.97.1
terser@5.43.0:
dependencies:
'@jridgewell/source-map': 0.3.6
acorn: 8.15.0
commander: 2.20.3
source-map-support: 0.5.21
optional: true
terser@5.43.1:
dependencies:
'@jridgewell/source-map': 0.3.6
@@ -25681,12 +25759,12 @@ snapshots:
'@egjs/hammerjs': 2.0.17
component-emitter: 1.3.1
vite-node@2.1.2(@types/node@20.10.5):
vite-node@2.1.2(@types/node@20.10.5)(terser@5.43.1):
dependencies:
cac: 6.7.14
debug: 4.3.7(supports-color@5.5.0)
pathe: 1.1.2
vite: 5.2.14(@types/node@20.10.5)
vite: 5.2.14(@types/node@20.10.5)(terser@5.43.1)
transitivePeerDependencies:
- '@types/node'
- less
@@ -25698,12 +25776,12 @@ snapshots:
- terser
optional: true
vite-node@2.1.2(@types/node@20.11.29)(terser@5.43.0):
vite-node@2.1.2(@types/node@20.11.29)(terser@5.43.1):
dependencies:
cac: 6.7.14
debug: 4.3.7(supports-color@5.5.0)
pathe: 1.1.2
vite: 5.2.14(@types/node@20.11.29)(terser@5.43.0)
vite: 5.2.14(@types/node@20.11.29)(terser@5.43.1)
transitivePeerDependencies:
- '@types/node'
- less
@@ -25714,12 +25792,12 @@ snapshots:
- supports-color
- terser
vite-node@2.1.2(@types/node@20.14.8):
vite-node@2.1.2(@types/node@20.14.8)(terser@5.43.1):
dependencies:
cac: 6.7.14
debug: 4.3.7(supports-color@5.5.0)
pathe: 1.1.2
vite: 5.2.14(@types/node@20.14.8)
vite: 5.2.14(@types/node@20.14.8)(terser@5.43.1)
transitivePeerDependencies:
- '@types/node'
- less
@@ -25731,7 +25809,7 @@ snapshots:
- terser
optional: true
vite@5.2.14(@types/node@20.10.5):
vite@5.2.14(@types/node@20.10.5)(terser@5.43.1):
dependencies:
esbuild: 0.20.2
postcss: 8.5.3
@@ -25739,9 +25817,10 @@ snapshots:
optionalDependencies:
'@types/node': 20.10.5
fsevents: 2.3.3
terser: 5.43.1
optional: true
vite@5.2.14(@types/node@20.11.29)(terser@5.43.0):
vite@5.2.14(@types/node@20.11.29)(terser@5.43.1):
dependencies:
esbuild: 0.20.2
postcss: 8.5.3
@@ -25749,9 +25828,9 @@ snapshots:
optionalDependencies:
'@types/node': 20.11.29
fsevents: 2.3.3
terser: 5.43.0
terser: 5.43.1
vite@5.2.14(@types/node@20.14.8):
vite@5.2.14(@types/node@20.14.8)(terser@5.43.1):
dependencies:
esbuild: 0.20.2
postcss: 8.5.3
@@ -25759,12 +25838,13 @@ snapshots:
optionalDependencies:
'@types/node': 20.14.8
fsevents: 2.3.3
terser: 5.43.1
optional: true
vitest@2.1.2(@types/node@20.10.5):
vitest@2.1.2(@types/node@20.10.5)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.10.5)(typescript@5.4.5))(terser@5.43.1):
dependencies:
'@vitest/expect': 2.1.2
'@vitest/mocker': 2.1.2(@vitest/spy@2.1.2)(vite@5.2.14(@types/node@20.10.5))
'@vitest/mocker': 2.1.2(@vitest/spy@2.1.2)(msw@2.6.5(@types/node@20.10.5)(typescript@5.4.5))(vite@5.2.14(@types/node@20.10.5)(terser@5.43.1))
'@vitest/pretty-format': 2.1.2
'@vitest/runner': 2.1.2
'@vitest/snapshot': 2.1.2
@@ -25779,11 +25859,12 @@ snapshots:
tinyexec: 0.3.0
tinypool: 1.0.1
tinyrainbow: 1.2.0
vite: 5.2.14(@types/node@20.10.5)
vite-node: 2.1.2(@types/node@20.10.5)
vite: 5.2.14(@types/node@20.10.5)(terser@5.43.1)
vite-node: 2.1.2(@types/node@20.10.5)(terser@5.43.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 20.10.5
jsdom: 20.0.3
transitivePeerDependencies:
- less
- lightningcss
@@ -25795,10 +25876,10 @@ snapshots:
- terser
optional: true
vitest@2.1.2(@types/node@20.11.29)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.11.29)(typescript@5.4.5))(terser@5.43.0):
vitest@2.1.2(@types/node@20.11.29)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.11.29)(typescript@5.4.5))(terser@5.43.1):
dependencies:
'@vitest/expect': 2.1.2
'@vitest/mocker': 2.1.2(@vitest/spy@2.1.2)(msw@2.6.5(@types/node@20.11.29)(typescript@5.4.5))(vite@5.2.14(@types/node@20.11.29)(terser@5.43.0))
'@vitest/mocker': 2.1.2(@vitest/spy@2.1.2)(msw@2.6.5(@types/node@20.11.29)(typescript@5.4.5))(vite@5.2.14(@types/node@20.11.29)(terser@5.43.1))
'@vitest/pretty-format': 2.1.2
'@vitest/runner': 2.1.2
'@vitest/snapshot': 2.1.2
@@ -25813,8 +25894,8 @@ snapshots:
tinyexec: 0.3.0
tinypool: 1.0.1
tinyrainbow: 1.2.0
vite: 5.2.14(@types/node@20.11.29)(terser@5.43.0)
vite-node: 2.1.2(@types/node@20.11.29)(terser@5.43.0)
vite: 5.2.14(@types/node@20.11.29)(terser@5.43.1)
vite-node: 2.1.2(@types/node@20.11.29)(terser@5.43.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 20.11.29
@@ -25829,10 +25910,10 @@ snapshots:
- supports-color
- terser
vitest@2.1.2(@types/node@20.14.8):
vitest@2.1.2(@types/node@20.14.8)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5))(terser@5.43.1):
dependencies:
'@vitest/expect': 2.1.2
'@vitest/mocker': 2.1.2(@vitest/spy@2.1.2)(vite@5.2.14(@types/node@20.14.8))
'@vitest/mocker': 2.1.2(@vitest/spy@2.1.2)(msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5))(vite@5.2.14(@types/node@20.14.8)(terser@5.43.1))
'@vitest/pretty-format': 2.1.2
'@vitest/runner': 2.1.2
'@vitest/snapshot': 2.1.2
@@ -25847,11 +25928,12 @@ snapshots:
tinyexec: 0.3.0
tinypool: 1.0.1
tinyrainbow: 1.2.0
vite: 5.2.14(@types/node@20.14.8)
vite-node: 2.1.2(@types/node@20.14.8)
vite: 5.2.14(@types/node@20.14.8)(terser@5.43.1)
vite-node: 2.1.2(@types/node@20.14.8)(terser@5.43.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 20.14.8
jsdom: 20.0.3
transitivePeerDependencies:
- less
- lightningcss
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.75.4",
"version": "3.78.2",
"private": true,
"license": "MIT",
"engines": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 779 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

+35 -2
View File
@@ -6740,7 +6740,7 @@ components:
prompt:
type: array
items:
$ref: '#/components/schemas/ChatMessage'
$ref: '#/components/schemas/ChatMessageWithPlaceholders'
config:
nullable: true
labels:
@@ -6854,6 +6854,31 @@ components:
- config
- labels
- tags
ChatMessageWithPlaceholders:
title: ChatMessageWithPlaceholders
oneOf:
- type: object
allOf:
- type: object
properties:
type:
type: string
enum:
- chatmessage
- $ref: '#/components/schemas/ChatMessage'
required:
- type
- type: object
allOf:
- type: object
properties:
type:
type: string
enum:
- placeholder
- $ref: '#/components/schemas/PlaceholderMessage'
required:
- type
ChatMessage:
title: ChatMessage
type: object
@@ -6865,6 +6890,14 @@ components:
required:
- role
- content
PlaceholderMessage:
title: PlaceholderMessage
type: object
properties:
name:
type: string
required:
- name
TextPrompt:
title: TextPrompt
type: object
@@ -6882,7 +6915,7 @@ components:
prompt:
type: array
items:
$ref: '#/components/schemas/ChatMessage'
$ref: '#/components/schemas/ChatMessageWithPlaceholders'
required:
- prompt
allOf:
+1 -1
View File
@@ -2151,7 +2151,7 @@
"auth": null,
"body": {
"mode": "raw",
"raw": "{\n \"type\": \"chat\",\n \"name\": \"example\",\n \"prompt\": [\n {\n \"role\": \"example\",\n \"content\": \"example\"\n }\n ],\n \"config\": \"UNKNOWN\",\n \"labels\": [\n \"example\"\n ],\n \"tags\": [\n \"example\"\n ],\n \"commitMessage\": \"example\"\n}",
"raw": "{\n \"type\": \"chat\",\n \"name\": \"example\",\n \"prompt\": [\n {\n \"type\": \"chatmessage\",\n \"role\": \"example\",\n \"content\": \"example\"\n }\n ],\n \"config\": \"UNKNOWN\",\n \"labels\": [\n \"example\"\n ],\n \"tags\": [\n \"example\"\n ],\n \"commitMessage\": \"example\"\n}",
"options": {
"raw": {
"language": "json"
@@ -244,6 +244,67 @@ describe("/api/public/scores API Endpoint", () => {
"test-key": "test-value-updated",
});
});
it("should post score with score config if in valid range", async () => {
const configId = v4();
const traceId = v4();
const scoreId = v4();
const { projectId: projectId, auth } = await createOrgProjectAndApiKey();
const config = await prisma.scoreConfig.create({
data: {
name: "score-name",
id: configId,
dataType: "NUMERIC",
maxValue: 100,
projectId: projectId,
},
});
const trace = createTrace({
id: traceId,
project_id: projectId,
});
await createTracesCh([trace]);
const score = createTraceScore({
id: scoreId,
project_id: projectId,
trace_id: traceId,
name: "score-name",
value: 100,
source: "API",
comment: "comment",
metadata: { "test-key": "test-value" },
observation_id: null,
environment: "production",
config_id: config.id,
});
await createScoresCh([score]);
const fetchedScore = await makeZodVerifiedAPICall(
GetScoreResponseV1,
"GET",
`/api/public/scores/${scoreId}`,
undefined,
auth,
);
expect(fetchedScore.body?.id).toBe(scoreId);
expect(fetchedScore.body?.traceId).toBe(traceId);
expect(fetchedScore.body?.name).toBe("score-name");
expect(fetchedScore.body?.value).toBe(100);
expect(fetchedScore.body?.configId).toBe(configId);
expect(fetchedScore.body?.observationId).toBeNull();
expect(fetchedScore.body?.comment).toBe("comment");
expect(fetchedScore.body?.source).toBe("API");
expect(fetchedScore.body?.projectId).toBe(projectId);
expect(fetchedScore.body?.environment).toBe("production");
expect(fetchedScore.body?.metadata).toEqual({
"test-key": "test-value",
});
});
});
describe("GET /api/public/scores", () => {
@@ -0,0 +1,152 @@
/** @jest-environment node */
import { ChatMessageType, compileChatMessages, extractPlaceholderNames } from "@langfuse/shared";
describe("compileChatMessages", () => {
it("should compile message placeholders with provided values", () => {
// Simulates how message placeholders would be compiled
// during execution (e.g., in playground or experiments)
const promptTemplate = [
{ role: "system", content: "You are a helpful assistant." },
{
type: ChatMessageType.Placeholder,
name: "conversation_history"
},
{ role: "user", content: "{{user_question}}" }
];
const placeholderValues = {
conversation_history: [
{ role: "user", content: "Hello!" },
{ role: "assistant", content: "Hi there! How can I help you?" },
{ role: "user", content: "What's the weather like?" }
]
};
const textVariables = {
user_question: "Can you continue our conversation?"
};
// Simulate compilation logic that would happen in playground/experiments
const compiledMessages = compileChatMessages(
promptTemplate,
placeholderValues,
textVariables
);
expect(compiledMessages).toEqual([
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Hello!" },
{ role: "assistant", content: "Hi there! How can I help you?" },
{ role: "user", content: "What's the weather like?" },
{ role: "user", content: "Can you continue our conversation?" }
]);
});
it("should throw error when placeholder value is missing", () => {
const promptTemplate = [
{ role: "system", content: "You are a helpful assistant." },
{
type: ChatMessageType.Placeholder,
name: "missing_placeholder"
},
{ role: "user", content: "Hello" }
];
const placeholderValues = {};
expect(() => {
compileChatMessages(promptTemplate, placeholderValues);
}).toThrow("Missing value for message placeholder: missing_placeholder");
});
it("should throw error when placeholder messages lack required properties", () => {
const promptTemplate = [
{
type: ChatMessageType.Placeholder,
name: "invalid_messages"
}
];
// Test missing role property
const placeholderValuesNoRole = {
invalid_messages: [
{ content: "Hello" }
]
};
expect(() => {
compileChatMessages(promptTemplate, placeholderValuesNoRole);
}).toThrow("Invalid message format in placeholder 'invalid_messages': messages must have 'role' and 'content' properties");
// Test missing content property
const placeholderValuesNoContent = {
invalid_messages: [
{ role: "user" }
]
};
expect(() => {
compileChatMessages(promptTemplate, placeholderValuesNoContent);
}).toThrow("Invalid message format in placeholder 'invalid_messages': messages must have 'role' and 'content' properties");
});
it("should compile placeholders without applying text substitutions when no variables provided", () => {
const promptTemplate = [
{ role: "system", content: "You are a helpful assistant. {{system_var}}" },
{
type: ChatMessageType.Placeholder,
name: "history"
},
{ role: "user", content: "{{user_var}}" }
];
const placeholderValues = {
history: [
{ role: "user", content: "Previous message with {{var}}" },
{ role: "assistant", content: "Response with {{another_var}}" }
]
};
// No text variables provided
const compiledMessages = compileChatMessages(promptTemplate, placeholderValues);
expect(compiledMessages).toEqual([
{ role: "system", content: "You are a helpful assistant. {{system_var}}" },
{ role: "user", content: "Previous message with {{var}}" },
{ role: "assistant", content: "Response with {{another_var}}" },
{ role: "user", content: "{{user_var}}" }
]);
});
it("should extract all placeholder names from messages", () => {
const promptTemplate = [
{ role: "system", content: "System message" },
{
type: ChatMessageType.Placeholder,
name: "history"
},
{ role: "user", content: "User message" },
{
type: ChatMessageType.Placeholder,
name: "context"
}
];
const placeholderNames = extractPlaceholderNames(promptTemplate);
expect(placeholderNames).toEqual(["history", "context"]);
});
it("should return empty array when no placeholders exist", () => {
const promptTemplate = [
{ role: "system", content: "System message" },
{ role: "user", content: "User message" }
];
const placeholderNames = extractPlaceholderNames(promptTemplate);
expect(placeholderNames).toEqual([]);
});
});
@@ -0,0 +1,679 @@
/**
* @fileoverview Integration tests for Pivot Table widget functionality in dashboard router
*
* This test suite validates the complete data pipeline for pivot table widgets:
* - Query generation and execution through executeQuery function
* - SQL generation by QueryBuilder for various pivot table configurations
* - Data transformation from raw query results to pivot table structure
* - Integration with ClickHouse database and error handling
*
* Test Coverage:
* - Zero dimension pivot tables (grand total only)
* - Single dimension pivot tables with subtotals
* - Two dimension pivot tables with nested structure
* - Row limiting functionality
* - Error handling for malformed queries
* - Integration with existing dashboard query infrastructure
*/
import { randomUUID } from "crypto";
import {
createTrace,
createTracesCh,
createObservation,
createObservationsCh,
} from "@langfuse/shared/src/server";
import { type QueryType } from "@/src/features/query/types";
import { executeQuery } from "@/src/features/dashboard/server/dashboard-router";
import {
transformToPivotTable,
type DatabaseRow,
} from "@/src/features/widgets/utils/pivot-table-utils";
import { QueryBuilder } from "@/src/features/query/server/queryBuilder";
describe("Dashboard Router - Pivot Table Integration", () => {
// Single project ID for all tests
const projectId = randomUUID();
// Time references for test data
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 3600000);
const twoHoursAgo = new Date(now.getTime() - 7200000);
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 3600000);
// Time ranges for queries - ISO format for query builder
const defaultFromTime = threeDaysAgo.toISOString();
const defaultToTime = new Date(now.getTime() + 3600000).toISOString(); // 1 hour in future
// Test data statistics for verification
const testDataStats = {
totalTraces: 0,
environmentCounts: {} as Record<string, number>,
modelCounts: {} as Record<string, number>,
totalObservations: 0,
};
beforeAll(async () => {
// Create diverse test data for pivot table testing
const traces = [
// Production environment traces
...Array(6)
.fill(0)
.map((_, i) =>
createTrace({
project_id: projectId,
name: "chat-completion",
environment: "production",
timestamp: now.getTime() - i * 10000,
user_id: `user-prod-${i}`,
}),
),
// Development environment traces
...Array(4)
.fill(0)
.map((_, i) =>
createTrace({
project_id: projectId,
name: "embeddings",
environment: "development",
timestamp: oneHourAgo.getTime() - i * 15000,
user_id: `user-dev-${i}`,
}),
),
// Staging environment traces
...Array(3)
.fill(0)
.map((_, i) =>
createTrace({
project_id: projectId,
name: "summarize",
environment: "staging",
timestamp: twoHoursAgo.getTime() - i * 20000,
user_id: `user-staging-${i}`,
}),
),
];
// Insert traces into ClickHouse
await createTracesCh(traces);
// Create observations with different models for each trace
const observations = [];
// Production observations - GPT models
for (let i = 0; i < 6; i++) {
const traceId = traces[i].id;
observations.push(
createObservation({
project_id: projectId,
trace_id: traceId,
name: "gpt-generation",
type: "generation",
environment: "production",
start_time: now.getTime() - i * 10000,
completion_start_time: now.getTime() - i * 10000 + 500,
end_time: now.getTime() - i * 10000 + 2000,
provided_model_name: i < 3 ? "gpt-4-turbo" : "gpt-3.5-turbo",
}),
);
}
// Development observations - Claude models
for (let i = 0; i < 4; i++) {
const traceId = traces[6 + i].id;
observations.push(
createObservation({
project_id: projectId,
trace_id: traceId,
name: "claude-generation",
type: "generation",
environment: "development",
start_time: oneHourAgo.getTime() - i * 15000,
completion_start_time: oneHourAgo.getTime() - i * 15000 + 800,
end_time: oneHourAgo.getTime() - i * 15000 + 3000,
provided_model_name: i < 2 ? "claude-3-opus" : "claude-3-sonnet",
}),
);
}
// Staging observations - Mixed models
for (let i = 0; i < 3; i++) {
const traceId = traces[10 + i].id;
observations.push(
createObservation({
project_id: projectId,
trace_id: traceId,
name: "mixed-generation",
type: "generation",
environment: "staging",
start_time: twoHoursAgo.getTime() - i * 20000,
completion_start_time: twoHoursAgo.getTime() - i * 20000 + 600,
end_time: twoHoursAgo.getTime() - i * 20000 + 2500,
provided_model_name: "gpt-4-turbo",
}),
);
}
// Insert observations into ClickHouse
await createObservationsCh(observations);
// Calculate test data statistics for verification
testDataStats.totalTraces = traces.length;
testDataStats.totalObservations = observations.length;
// Count by environment
traces.forEach((trace) => {
testDataStats.environmentCounts[trace.environment] =
(testDataStats.environmentCounts[trace.environment] || 0) + 1;
});
// Count by model
observations.forEach((obs) => {
if (obs.provided_model_name) {
testDataStats.modelCounts[obs.provided_model_name] =
(testDataStats.modelCounts[obs.provided_model_name] || 0) + 1;
}
});
});
describe("executeQuery function with pivot table configurations", () => {
it("should execute zero-dimension pivot table query (grand total only)", async () => {
const query: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: {
granularity: "day",
},
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
chartConfig: {
type: "PIVOT_TABLE",
row_limit: 20,
},
};
const result = await executeQuery(projectId, query);
// Verify basic query execution
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
// Results should be grouped by time dimension when timeDimension is present
expect(result.length).toBeGreaterThan(0);
// Each row should have time_dimension and aggregated count
result.forEach((row) => {
expect(row).toHaveProperty("time_dimension");
expect(row).toHaveProperty("count_count");
expect(typeof row.count_count).toBe("string"); // ClickHouse returns numbers as strings
});
// Sum all counts to verify total
const totalCount = result.reduce(
(sum, row) => sum + parseInt(row.count_count as string),
0,
);
expect(totalCount).toBe(testDataStats.totalTraces);
});
it("should execute single-dimension pivot table query with environment grouping", async () => {
const query: QueryType = {
view: "traces",
dimensions: [{ field: "environment" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: {
granularity: "day",
},
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: [{ field: "environment", direction: "asc" }],
chartConfig: {
type: "PIVOT_TABLE",
row_limit: 20,
},
};
const result = await executeQuery(projectId, query);
// Verify query execution
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
// Should have one row per environment per time dimension
expect(result.length).toBeGreaterThan(0);
// Verify structure of results
result.forEach((row) => {
expect(row).toHaveProperty("environment");
expect(row).toHaveProperty("time_dimension");
expect(row).toHaveProperty("count_count");
expect(typeof row.environment).toBe("string");
expect(typeof row.count_count).toBe("string"); // ClickHouse returns numbers as strings
});
// Verify data accuracy by checking environment counts
const environmentTotals = result.reduce(
(acc, row) => {
const env = row.environment as string;
acc[env] =
((acc[env] as number) || 0) + parseInt(row.count_count as string);
return acc;
},
{} as Record<string, number>,
);
expect(environmentTotals["production"]).toBe(
testDataStats.environmentCounts["production"],
);
expect(environmentTotals["development"]).toBe(
testDataStats.environmentCounts["development"],
);
expect(environmentTotals["staging"]).toBe(
testDataStats.environmentCounts["staging"],
);
});
it("should execute two-dimension pivot table query with environment and model grouping", async () => {
const query: QueryType = {
view: "observations",
dimensions: [{ field: "environment" }, { field: "providedModelName" }],
metrics: [
{ measure: "count", aggregation: "count" },
{ measure: "totalTokens", aggregation: "sum" },
],
filters: [],
timeDimension: {
granularity: "day",
},
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: [
{ field: "environment", direction: "asc" },
{ field: "providedModelName", direction: "asc" },
],
chartConfig: {
type: "PIVOT_TABLE",
row_limit: 20,
},
};
const result = await executeQuery(projectId, query);
// Verify query execution
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
// Should have combinations of environment and model
result.forEach((row) => {
expect(row).toHaveProperty("environment");
expect(row).toHaveProperty("providedModelName");
expect(row).toHaveProperty("time_dimension");
expect(row).toHaveProperty("count_count");
expect(row).toHaveProperty("sum_totalTokens");
expect(typeof row.environment).toBe("string");
expect(typeof row.providedModelName).toBe("string");
expect(typeof row.count_count).toBe("string");
expect(typeof row.sum_totalTokens).toBe("string");
});
// Verify total observation count matches test data
const totalObservations = result.reduce(
(sum, row) => sum + parseInt(row.count_count as string),
0,
);
expect(totalObservations).toBe(testDataStats.totalObservations);
});
it("should handle empty results gracefully", async () => {
const futureTime = new Date(now.getTime() + 86400000).toISOString(); // 1 day in future
const farFutureTime = new Date(now.getTime() + 172800000).toISOString(); // 2 days in future
const query: QueryType = {
view: "traces",
dimensions: [{ field: "environment" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: {
granularity: "day",
},
fromTimestamp: futureTime,
toTimestamp: farFutureTime,
orderBy: null,
chartConfig: {
type: "PIVOT_TABLE",
row_limit: 20,
},
};
const result = await executeQuery(projectId, query);
// Should handle empty results without errors
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
// Time dimension queries can return rows with 0 counts for time filling
expect(result.length).toBeGreaterThanOrEqual(0);
});
});
describe("Query Builder integration with pivot table configurations", () => {
it("should generate correct SQL for zero-dimension pivot table", () => {
const query: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: {
granularity: "day",
},
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
chartConfig: {
type: "PIVOT_TABLE",
row_limit: 20,
},
};
const queryBuilder = new QueryBuilder(query.chartConfig);
const { query: sql, parameters } = queryBuilder.build(query, projectId);
// Verify SQL generation
expect(sql).toBeDefined();
expect(typeof sql).toBe("string");
expect(parameters).toBeDefined();
expect(typeof parameters).toBe("object");
// SQL should contain GROUP BY for time dimension when timeDimension is present
expect(sql.toLowerCase()).toContain("group by");
// Should contain aggregation
expect(sql.toLowerCase()).toContain("count(");
});
it("should generate correct SQL for single-dimension pivot table", () => {
const query: QueryType = {
view: "traces",
dimensions: [{ field: "environment" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: {
granularity: "day",
},
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: [{ field: "environment", direction: "asc" }],
chartConfig: {
type: "PIVOT_TABLE",
row_limit: 20,
},
};
const queryBuilder = new QueryBuilder(query.chartConfig);
const { query: sql, parameters } = queryBuilder.build(query, projectId);
// Verify SQL generation
expect(sql).toBeDefined();
expect(typeof sql).toBe("string");
expect(parameters).toBeDefined();
// SQL should contain GROUP BY for dimension
expect(sql.toLowerCase()).toContain("group by");
expect(sql.toLowerCase()).toContain("environment");
// Should contain ORDER BY
expect(sql.toLowerCase()).toContain("order by");
});
it("should generate correct SQL for two-dimension pivot table", () => {
const query: QueryType = {
view: "observations",
dimensions: [{ field: "environment" }, { field: "providedModelName" }],
metrics: [
{ measure: "count", aggregation: "count" },
{ measure: "totalTokens", aggregation: "sum" },
],
filters: [],
timeDimension: {
granularity: "day",
},
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: [
{ field: "environment", direction: "asc" },
{ field: "providedModelName", direction: "asc" },
],
chartConfig: {
type: "PIVOT_TABLE",
row_limit: 20,
},
};
const queryBuilder = new QueryBuilder(query.chartConfig);
const { query: sql, parameters } = queryBuilder.build(query, projectId);
// Verify SQL generation
expect(sql).toBeDefined();
expect(typeof sql).toBe("string");
expect(parameters).toBeDefined();
// SQL should contain GROUP BY for both dimensions
expect(sql.toLowerCase()).toContain("group by");
expect(sql.toLowerCase()).toContain("environment");
expect(sql.toLowerCase()).toContain("providedmodelname");
// Should contain multiple aggregations
expect(sql.toLowerCase()).toContain("count(");
expect(sql.toLowerCase()).toContain("sum(");
// Should contain ORDER BY for both dimensions
expect(sql.toLowerCase()).toContain("order by");
});
});
describe("End-to-end pivot table data transformation", () => {
it("should transform query results to pivot table structure for zero dimensions", async () => {
const query: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: null, // No time dimension for simpler test
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
chartConfig: {
type: "PIVOT_TABLE",
row_limit: 20,
},
};
// Execute query to get raw data
const rawData = await executeQuery(projectId, query);
// Transform to pivot table structure
const pivotTableData = transformToPivotTable(rawData as DatabaseRow[], {
dimensions: [],
metrics: ["count_count"],
rowLimit: 20,
});
// Verify transformation
expect(pivotTableData).toBeDefined();
expect(Array.isArray(pivotTableData)).toBe(true);
expect(pivotTableData.length).toBe(1); // Should have only grand total
const totalRow = pivotTableData[0];
expect(totalRow.type).toBe("total");
expect(totalRow.level).toBe(0);
expect(totalRow.label).toBe("Total");
expect(totalRow.isTotal).toBe(true);
expect(totalRow.values).toHaveProperty("count_count");
});
it("should transform query results to pivot table structure for single dimension", async () => {
const query: QueryType = {
view: "traces",
dimensions: [{ field: "environment" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: null, // No time dimension for simpler test
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: [{ field: "environment", direction: "asc" }],
chartConfig: {
type: "PIVOT_TABLE",
row_limit: 20,
},
};
// Execute query to get raw data
const rawData = await executeQuery(projectId, query);
// Transform to pivot table structure
const pivotTableData = transformToPivotTable(rawData as DatabaseRow[], {
dimensions: ["environment"],
metrics: ["count_count"],
rowLimit: 20,
});
// Verify transformation
expect(pivotTableData).toBeDefined();
expect(Array.isArray(pivotTableData)).toBe(true);
expect(pivotTableData.length).toBeGreaterThan(1); // Should have data rows + total
// Should have data rows for each environment
const dataRows = pivotTableData.filter((row) => row.type === "data");
const totalRow = pivotTableData.find((row) => row.type === "total");
expect(dataRows.length).toBeGreaterThan(0);
expect(totalRow).toBeDefined();
expect(totalRow!.isTotal).toBe(true);
// Verify data row structure
dataRows.forEach((row) => {
expect(row.type).toBe("data");
expect(row.level).toBe(0);
expect(row.values).toHaveProperty("count_count");
expect(typeof row.values.count_count).toBe("number");
});
});
it("should transform query results to pivot table structure for two dimensions", async () => {
const query: QueryType = {
view: "observations",
dimensions: [{ field: "environment" }, { field: "providedModelName" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: null, // No time dimension for simpler test
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: [
{ field: "environment", direction: "asc" },
{ field: "providedModelName", direction: "asc" },
],
chartConfig: {
type: "PIVOT_TABLE",
row_limit: 20,
},
};
// Execute query to get raw data
const rawData = await executeQuery(projectId, query);
// Transform to pivot table structure
const pivotTableData = transformToPivotTable(rawData as DatabaseRow[], {
dimensions: ["environment", "providedModelName"],
metrics: ["count_count"],
rowLimit: 20,
});
// Verify transformation
expect(pivotTableData).toBeDefined();
expect(Array.isArray(pivotTableData)).toBe(true);
expect(pivotTableData.length).toBeGreaterThan(1);
// Should have nested structure with data rows, subtotals, and grand total
const dataRows = pivotTableData.filter((row) => row.type === "data");
const subtotalRows = pivotTableData.filter(
(row) => row.type === "subtotal",
);
const totalRow = pivotTableData.find((row) => row.type === "total");
expect(dataRows.length).toBeGreaterThan(0);
expect(totalRow).toBeDefined();
// Verify indentation levels
dataRows.forEach((row) => {
expect(row.level).toBe(1); // Second level for two dimensions
expect(row.values).toHaveProperty("count_count");
});
if (subtotalRows.length > 0) {
subtotalRows.forEach((row) => {
expect(row.level).toBe(0); // First level subtotals
expect(row.isSubtotal).toBe(true);
});
}
expect(totalRow!.level).toBe(0);
expect(totalRow!.isTotal).toBe(true);
});
});
describe("Error handling and edge cases", () => {
it("should handle empty results gracefully in transformation", async () => {
// Transform empty data to pivot table structure
const pivotTableData = transformToPivotTable([], {
dimensions: ["environment"],
metrics: ["count_count"],
rowLimit: 20,
});
// Should handle empty data gracefully
expect(pivotTableData).toBeDefined();
expect(Array.isArray(pivotTableData)).toBe(true);
// Should still have total row with zero values
expect(pivotTableData.length).toBe(1);
const totalRow = pivotTableData[0];
expect(totalRow.type).toBe("total");
expect(totalRow.values.count_count).toBe(0);
});
it("should handle missing project data gracefully", async () => {
const nonExistentProjectId = randomUUID();
const query: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: {
granularity: "day",
},
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
chartConfig: {
type: "PIVOT_TABLE",
row_limit: 20,
},
};
const result = await executeQuery(nonExistentProjectId, query);
// Should return results even for non-existent project (time fill creates rows)
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThanOrEqual(0);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,383 @@
/**
* @fileoverview Unit Tests for PivotTable React Component
*
* Comprehensive test suite for the PivotTable component functionality including:
* - Component rendering with various data scenarios
* - Proper styling and CSS class application
* - Indentation behavior for nested dimensions
* - Empty data and error state handling
* - Metric value formatting and display
* - Column header formatting
*
* Uses Jest and React Testing Library for component testing.
* This test focuses on component behavior rather than data transformation logic.
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import {
PivotTable,
type PivotTableProps,
} from "@/src/features/widgets/chart-library/PivotTable";
import { type DataPoint } from "@/src/features/widgets/chart-library/chart-props";
describe("PivotTable Component", () => {
describe("Basic Rendering", () => {
test("renders table with simple data", () => {
const data: DataPoint[] = [
{
time_dimension: "2024-01-01",
dimension: "gpt-4",
metric: 100,
},
{
time_dimension: "2024-01-01",
dimension: "gpt-3.5",
metric: 75,
},
];
const props: PivotTableProps = {
data,
config: {
dimensions: ["model"],
metrics: ["metric"],
},
};
render(<PivotTable {...props} />);
// Check table structure exists
expect(screen.getByRole("table")).toBeInTheDocument();
// Should have dimension column and metric column
expect(screen.getAllByRole("columnheader")).toHaveLength(2);
});
test("displays data when no configuration provided", () => {
const data: DataPoint[] = [
{
time_dimension: "2024-01-01",
dimension: "test",
metric: 50,
},
];
const props: PivotTableProps = {
data,
};
render(<PivotTable {...props} />);
// Should still render a table
expect(screen.getByRole("table")).toBeInTheDocument();
});
});
describe("Column Header Formatting", () => {
test("formats single dimension header correctly", () => {
const data: DataPoint[] = [
{
time_dimension: "2024-01-01",
dimension: "test",
metric: 100,
},
];
const props: PivotTableProps = {
data,
config: {
dimensions: ["model_name"],
metrics: ["request_count"],
},
};
render(<PivotTable {...props} />);
expect(
screen.getByRole("columnheader", { name: "Model Name" }),
).toBeInTheDocument();
expect(
screen.getByRole("columnheader", { name: "Request Count" }),
).toBeInTheDocument();
});
test("formats multiple dimension headers correctly", () => {
const data: DataPoint[] = [
{
time_dimension: "2024-01-01",
dimension: "test",
metric: 100,
},
];
const props: PivotTableProps = {
data,
config: {
dimensions: ["model_name", "time_period"],
metrics: ["request_count", "avg_duration"],
},
};
render(<PivotTable {...props} />);
expect(
screen.getByRole("columnheader", { name: "Model Name / Time Period" }),
).toBeInTheDocument();
expect(
screen.getByRole("columnheader", { name: "Request Count" }),
).toBeInTheDocument();
expect(
screen.getByRole("columnheader", { name: "Avg Duration" }),
).toBeInTheDocument();
});
test("defaults to 'Dimension' when no dimensions configured", () => {
const data: DataPoint[] = [
{
time_dimension: "2024-01-01",
dimension: "test",
metric: 100,
},
];
const props: PivotTableProps = {
data,
config: {
dimensions: [],
metrics: ["metric"],
},
};
render(<PivotTable {...props} />);
expect(
screen.getByRole("columnheader", { name: "Dimension" }),
).toBeInTheDocument();
});
});
describe("Empty Data Handling", () => {
test("displays 'No data available' for empty data array", () => {
const props: PivotTableProps = {
data: [],
config: {
dimensions: ["model"],
metrics: ["metric"],
},
};
render(<PivotTable {...props} />);
expect(screen.getByText("No data available")).toBeInTheDocument();
expect(screen.queryByRole("table")).not.toBeInTheDocument();
});
test("displays 'No data available' for null data", () => {
const props: PivotTableProps = {
data: null as any,
config: {
dimensions: ["model"],
metrics: ["metric"],
},
};
render(<PivotTable {...props} />);
expect(screen.getByText("No data available")).toBeInTheDocument();
expect(screen.queryByRole("table")).not.toBeInTheDocument();
});
test("displays 'No data available' for undefined data", () => {
const props: PivotTableProps = {
data: undefined as any,
config: {
dimensions: ["model"],
metrics: ["metric"],
},
};
render(<PivotTable {...props} />);
expect(screen.getByText("No data available")).toBeInTheDocument();
expect(screen.queryByRole("table")).not.toBeInTheDocument();
});
});
describe("Responsive Design", () => {
test("includes overflow-auto class for responsive scrolling", () => {
const data: DataPoint[] = [
{
time_dimension: "2024-01-01",
dimension: "test",
metric: 100,
},
];
const props: PivotTableProps = {
data,
config: {
dimensions: ["model"],
metrics: ["metric"],
},
};
const { container } = render(<PivotTable {...props} />);
const pivotTableContainer = container.firstChild as HTMLElement;
expect(pivotTableContainer).toHaveClass("h-full", "overflow-auto");
});
});
describe("Accessibility", () => {
test("provides proper table structure for screen readers", () => {
const data: DataPoint[] = [
{
time_dimension: "2024-01-01",
dimension: "gpt-4",
metric: 100,
},
];
const props: PivotTableProps = {
data,
config: {
dimensions: ["model"],
metrics: ["metric"],
},
};
render(<PivotTable {...props} />);
// Check table has proper semantic structure
expect(screen.getByRole("table")).toBeInTheDocument();
expect(screen.getAllByRole("columnheader")).toHaveLength(2);
// Should have at least header row
expect(screen.getAllByRole("row").length).toBeGreaterThanOrEqual(1);
});
test("provides proper cell associations", () => {
const data: DataPoint[] = [
{
time_dimension: "2024-01-01",
dimension: "test",
metric: 100,
},
];
const props: PivotTableProps = {
data,
config: {
dimensions: ["model"],
metrics: ["metric"],
},
};
render(<PivotTable {...props} />);
// Header cells should be properly associated
const headers = screen.getAllByRole("columnheader");
expect(headers.length).toBeGreaterThan(0);
// Each header should be part of a row
headers.forEach((header) => {
expect(header.closest("tr")).toBeInTheDocument();
});
});
});
describe("Component Props", () => {
test("handles missing config gracefully", () => {
const data: DataPoint[] = [
{
time_dimension: "2024-01-01",
dimension: "test",
metric: 100,
},
];
const props: PivotTableProps = {
data,
// No config provided
};
render(<PivotTable {...props} />);
// Should still render a table
expect(screen.getByRole("table")).toBeInTheDocument();
});
test("passes accessibilityLayer prop correctly", () => {
const data: DataPoint[] = [
{
time_dimension: "2024-01-01",
dimension: "test",
metric: 100,
},
];
const props: PivotTableProps = {
data,
accessibilityLayer: true,
};
// Should render without error
expect(() => render(<PivotTable {...props} />)).not.toThrow();
});
});
describe("Data Processing Integration", () => {
test("handles various metric data types", () => {
const data: DataPoint[] = [
{
time_dimension: "2024-01-01",
dimension: "test1",
metric: 100, // number
},
{
time_dimension: "2024-01-01",
dimension: "test2",
metric: [
[10, 20],
[30, 40],
], // nested array
},
];
const props: PivotTableProps = {
data,
config: {
dimensions: ["model"],
metrics: ["metric"],
},
};
// Should render without error
expect(() => render(<PivotTable {...props} />)).not.toThrow();
expect(screen.getByRole("table")).toBeInTheDocument();
});
test("handles missing dimension data", () => {
const data: DataPoint[] = [
{
time_dimension: "2024-01-01",
dimension: undefined, // missing dimension
metric: 100,
},
];
const props: PivotTableProps = {
data,
config: {
dimensions: ["model"],
metrics: ["metric"],
},
};
// Should render without error
expect(() => render(<PivotTable {...props} />)).not.toThrow();
expect(screen.getByRole("table")).toBeInTheDocument();
});
});
});
+1 -1
View File
@@ -8,7 +8,7 @@ import {
LegacyPromptSchema,
PromptType,
type LegacyValidatedPrompt,
} from "@/src/features/prompts/server/utils/validation";
} from "@langfuse/shared";
import { getObservationById } from "@langfuse/shared/src/server";
describe("/api/public/prompts API Endpoint", () => {
+128 -8
View File
@@ -3,20 +3,22 @@
import { prisma } from "@langfuse/shared/src/db";
import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
import { v4 as uuidv4, v4 } from "uuid";
import { type Prompt } from "@langfuse/shared";
import {
PromptSchema,
PromptType,
type ValidatedPrompt,
} from "@/src/features/prompts/server/utils/validation";
type ChatMessage,
type Prompt,
} from "@langfuse/shared";
import { parsePromptDependencyTags } from "@langfuse/shared";
import { nanoid } from "ai";
import { generateId, nanoid } from "ai";
import { type PromptsMetaResponse } from "@/src/features/prompts/server/actions/getPromptsMeta";
import {
createOrgProjectAndApiKey,
getObservationById,
MAX_PROMPT_NESTING_DEPTH,
ChatMessageType,
} from "@langfuse/shared/src/server";
import { randomUUID } from "node:crypto";
@@ -452,6 +454,52 @@ describe("/api/public/v2/prompts API Endpoint", () => {
expect(validatedPrompt.commitMessage).toBe("chore: setup initial prompt");
});
it("should create and fetch a chat prompt with message placeholders", async () => {
const promptName = `prompt-name-message-placeholders${generateId()}`;
const commitMessage = "feat: add message placeholders support";
const chatMessages = [
{ role: "system", content: "You are a helpful assistant with conversation context." },
{
type: ChatMessageType.Placeholder,
name: "conversation_history"
},
{ role: "user", content: "{{user_question}}" }
];
const response = await makeAPICall("POST", baseURI, {
name: promptName,
prompt: chatMessages,
type: "chat",
labels: ["production"],
commitMessage: commitMessage
});
expect(response.status).toBe(201);
const { body: fetchedPrompt } = await makeAPICall(
"GET",
`${baseURI}/${promptName}`,
undefined,
);
const validatedPrompt = validatePrompt(fetchedPrompt);
expect(validatedPrompt.name).toBe(promptName);
expect(validatedPrompt.prompt).toEqual(chatMessages);
expect(validatedPrompt.type).toBe("chat");
expect(validatedPrompt.version).toBe(1);
expect(validatedPrompt.labels).toEqual(["production", "latest"]);
expect(validatedPrompt.createdBy).toBe("API");
expect(validatedPrompt.config).toEqual({});
expect(validatedPrompt.commitMessage).toBe(commitMessage);
// Verify the placeholder message structure is preserved
const messages = validatedPrompt.prompt as ChatMessage[];
const placeholderMessage = messages[1] as { type: ChatMessageType.Placeholder; name: string };
expect(placeholderMessage.type).toBe(ChatMessageType.Placeholder);
expect(placeholderMessage.name).toBe("conversation_history");
});
it("should fail if chat prompt has string prompt", async () => {
const promptName = "prompt-name";
const response = await makeAPICall("POST", baseURI, {
@@ -705,7 +753,9 @@ describe("/api/public/v2/prompts API Endpoint", () => {
});
expect(response.status).toBe(400);
expect(response.body.message).toBe("Invalid request data");
const hasExpectedMessage = JSON.stringify(response.body.error).includes(`"message":"${expectedError}"`);
const hasExpectedMessage = JSON.stringify(response.body.error).includes(
`"message":"${expectedError}"`,
);
expect(hasExpectedMessage).toBe(true);
};
@@ -723,10 +773,19 @@ describe("/api/public/v2/prompts API Endpoint", () => {
it("should reject invalid prompt names", async () => {
// Test invalid patterns
await testInvalidName("/invalid-name", "Name cannot start with a slash");
await testInvalidName(
"/invalid-name",
"Name cannot start with a slash",
);
await testInvalidName("invalid-name/", "Name cannot end with a slash");
await testInvalidName("invalid//name", "Name cannot contain consecutive slashes");
await testInvalidName("invalid|name", "Prompt name cannot contain '|' character");
await testInvalidName(
"invalid//name",
"Name cannot contain consecutive slashes",
);
await testInvalidName(
"invalid|name",
"Prompt name cannot contain '|' character",
);
await testInvalidName("new", "Prompt name cannot be 'new'");
await testInvalidName("", "Enter a name");
});
@@ -848,7 +907,68 @@ describe("/api/public/v2/prompts API Endpoint", () => {
// Verify the name with slashes is preserved
expect(validatedPrompt.name).toBe(promptName);
expect(validatedPrompt.name).toContain("/");
expect(validatedPrompt.prompt).toBe("This is a prompt in a folder structure");
expect(validatedPrompt.prompt).toBe(
"This is a prompt in a folder structure",
);
});
it("should prevent creating a prompt with both a variable and placeholder with the same name", async () => {
const promptName = "prompt-same-name-conflict-" + nanoid();
// Try to create a prompt where the same name is used as both a variable and a placeholder
const response = await makeAPICall("POST", baseURI, {
name: promptName,
prompt: [
{ role: "system", content: "Hello {{userName}}" },
{ type: ChatMessageType.Placeholder, name: "userName" },
{ role: "user", content: "How are you?" }
],
type: "chat",
});
// This should fail with a 400 error
expect(response.status).toBe(400);
expect(response.body).toHaveProperty("error");
expect(response.body).toHaveProperty("message");
// @ts-expect-error
expect(response.body.message).toContain("variables and placeholders must be unique");
// @ts-expect-error
expect(response.body.message).toContain("userName");
});
it("should allow creating a new version of a prompt with placeholder names that conflict a variable name in a previous version", async () => {
const promptName = "prompt-with-variable-conflict-" + nanoid();
// First, create a chat prompt with a message variable
const v1Response = await makeAPICall("POST", baseURI, {
name: promptName,
prompt: [
{ role: "system", content: "You are a helpful {{conversationHistory}}" },
{ role: "user", content: "Continue our conversation" }
],
type: "chat",
labels: ["production"],
});
expect(v1Response.status).toBe(201);
// Try to create a new version with a text variable that has the same name as the placeholder
const v2Response = await makeAPICall("POST", baseURI, {
name: promptName,
prompt: [
{ role: "system", content: "You are a helpful assistant with context: {{newHistory}}" },
{ type: "placeholder", name: "conversationHistory" },
{ role: "user", content: "Continue our conversation" }
],
type: "chat"
});
// This should succeed, we allow cross-version name reuse
expect(v2Response.status).toBe(201);
expect(v2Response.body).toHaveProperty("id");
expect(v2Response.body).toHaveProperty("version");
// @ts-expect-error - Response body type is flexible for testing
expect(v2Response.body.version).toBe(2);
});
});
+3 -1
View File
@@ -3427,7 +3427,9 @@ describe("queryBuilder", () => {
};
// Execute histogram query with custom bins
const queryBuilder = new QueryBuilder(customBinHistogramQuery.chartConfig);
const queryBuilder = new QueryBuilder(
customBinHistogramQuery.chartConfig,
);
const { query: compiledQuery, parameters } = queryBuilder.build(
customBinHistogramQuery,
projectId,
@@ -8,6 +8,7 @@ import {
SYSTEM_ROLES,
type ChatMessageWithId,
type LLMToolCall,
type PlaceholderMessage,
} from "@langfuse/shared";
import { Button } from "@/src/components/ui/button";
import { Card, CardContent } from "@/src/components/ui/card";
@@ -45,15 +46,17 @@ const ROLES: ChatMessageRole[] = [
const getRoleNamePlaceholder = (role: string) => {
switch (role) {
case ChatMessageRole.System:
return "a system";
return "a system message";
case ChatMessageRole.Developer:
return "a developer";
return "a developer message";
case ChatMessageRole.Assistant:
return "an assistant";
return "an assistant message";
case ChatMessageRole.User:
return "a user";
return "a user message";
case ChatMessageRole.Tool:
return "a tool response";
return "a tool response message";
case "placeholder":
return "placeholder name (e.g. msg_history)";
default:
return `a ${role}`;
}
@@ -92,6 +95,9 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
} = useSortable({ id: message.id });
const toggleRole = () => {
// Only allow role toggling for messages that have a role property (not placeholder messages)
if (!('role' in message)) return;
// if user has set custom roles, available roles will be non-empty and we toggle through custom and default roles (assistant, user)
if (!!availableRoles && Boolean(availableRoles.length)) {
let randomRole = availableRoles[roleIndex % availableRoles.length];
@@ -113,7 +119,7 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
(toolCallIds && toolCallIds.length > 0),
);
const currentIndex = eligibleRoles.indexOf(
message.role as ChatMessageRole,
('role' in message ? message.role : ChatMessageRole.User) as ChatMessageRole,
);
const nextRole =
eligibleRoles[(currentIndex + 1) % eligibleRoles.length];
@@ -196,13 +202,28 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
};
const onValueChange = useCallback(
(value: string) =>
updateMessage(message.type, message.id, "content", value),
(value: string) => {
if (message.type === ChatMessageType.Placeholder) {
updateMessage(message.type, message.id, "name", value);
} else {
updateMessage(message.type, message.id, "content", value);
}
},
[message.id, message.type, updateMessage],
);
const showDragHandle = !SYSTEM_ROLES.includes(message.role);
const onPlaceholderNameChange = useCallback(
(value: string) => {
if (message.type === ChatMessageType.Placeholder) {
updateMessage(message.type, message.id, "name", value);
}
},
[message.id, message.type, updateMessage],
);
const showDragHandle = !('role' in message && SYSTEM_ROLES.includes(message.role));
const showToolCallSelect = message.type === ChatMessageType.ToolResult;
const isPlaceholder = message.type === ChatMessageType.Placeholder;
return (
<Card
@@ -233,14 +254,20 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
)}
>
<div className="flex w-[4rem] flex-shrink-0 flex-col gap-1">
<Button
onClick={toggleRole}
type="button"
variant="ghost"
className="h-6 w-full px-1 py-0 text-[10px] font-semibold text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
{capitalize(message.role)}
</Button>
{isPlaceholder ? (
<span className="inline-flex items-center justify-center font-mono h-6 w-full text-[9px] rounded-md bg-accent px-4 text-muted-foreground">
placeholder
</span>
) : (
<Button
onClick={toggleRole}
type="button"
variant="ghost"
className="h-6 w-full px-1 py-0 text-[10px] font-semibold text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
{capitalize(message.role)}
</Button>
)}
</div>
<div className="flex flex-1 flex-col gap-1">
<div className="flex gap-2">
@@ -271,11 +298,19 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
</SelectContent>
</Select>
)}
<MemoizedEditor
value={message.content}
onChange={onValueChange}
role={message.role}
/>
{isPlaceholder ? (
<MemoizedEditor
value={(message as PlaceholderMessage).name || ""}
onChange={onPlaceholderNameChange}
role={message.type}
/>
) : (
<MemoizedEditor
value={message.content}
onChange={onValueChange}
role={message.role}
/>
)}
</div>
{message.type === ChatMessageType.AssistantToolCall && (
<ToolCalls toolCalls={message.toolCalls as LLMToolCall[]} />
@@ -303,7 +338,7 @@ const MemoizedEditor = memo(function MemoizedEditor(props: {
onChange: (value: string) => void;
}) {
const { value, role, onChange } = props;
const placeholder = `Enter ${getRoleNamePlaceholder(role)} message here.`;
const placeholder = `Enter ${getRoleNamePlaceholder(role)} here.`;
return (
<CodeMirrorEditor
+65 -26
View File
@@ -2,9 +2,16 @@ import { PlusCircleIcon } from "lucide-react";
import { useEffect, useRef } from "react";
import { Button } from "@/src/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/src/components/ui/tooltip";
import {
ChatMessageRole,
ChatMessageType,
type ChatMessageWithId,
SYSTEM_ROLES,
} from "@langfuse/shared";
@@ -60,8 +67,9 @@ export const ChatMessages: React.FC<ChatMessagesProps> = (props) => {
if (newIndex < 0 || oldIndex < 0) {
return;
}
// prevent reordering system messages
if (SYSTEM_ROLES.includes(messages[newIndex].role)) {
// prevent reordering system messages, placeholders can be reordered
const targetMessage = messages[newIndex];
if (targetMessage.type !== ChatMessageType.Placeholder && SYSTEM_ROLES.includes(targetMessage.role)) {
return;
}
const newMessages = arrayMove(messages, oldIndex, newIndex);
@@ -114,35 +122,66 @@ const AddMessageButton: React.FC<AddMessageButtonProps> = ({
messages,
addMessage,
}) => {
const lastMessageRole = messages[messages.length - 1]?.role;
// Skip placeholder messages when determining last roles
const lastMessageWithRole = messages.slice().reverse().find((msg): msg is ChatMessageWithId & { role: string } => msg.type !== ChatMessageType.Placeholder);
const lastMessageRole = lastMessageWithRole?.role;
const nextMessageRole =
lastMessageRole === ChatMessageRole.User
? ChatMessageRole.Assistant
: ChatMessageRole.User;
const addRegularMessage = () => {
if (nextMessageRole === ChatMessageRole.User) {
addMessage({
role: nextMessageRole,
content: "",
type: ChatMessageType.User,
});
} else {
addMessage({
role: nextMessageRole,
content: "",
type: ChatMessageType.AssistantText,
});
}
};
const addPlaceholderMessage = () => {
addMessage({
type: ChatMessageType.Placeholder,
name: "",
});
};
return (
<Button
type="button" // prevents submitting a form if this button is inside a form
variant="outline"
className="w-full"
onClick={() => {
if (nextMessageRole === ChatMessageRole.User) {
addMessage({
role: nextMessageRole,
content: "",
type: ChatMessageType.User,
});
} else {
addMessage({
role: nextMessageRole,
content: "",
type: ChatMessageType.AssistantText,
});
}
}}
>
<PlusCircleIcon size={14} className="mr-2" />
<p>Add message</p>
</Button>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={addRegularMessage}
>
<PlusCircleIcon size={14} className="mr-2" />
<p>Add message</p>
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
className="flex-1"
onClick={addPlaceholderMessage}
>
<PlusCircleIcon size={14} className="mr-2" />
<p>Add message placeholder</p>
</Button>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">Adds a placeholder to inject message pairs, e.g. a message history (with &quot;role&quot;, &quot;content&quot; pairs) when compiling the message in the SDK.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
};
+3 -3
View File
@@ -1,15 +1,15 @@
import type { ChatMessage, ChatMessageWithId } from "@langfuse/shared";
import type { ChatMessage, ChatMessageWithId, PlaceholderMessage } from "@langfuse/shared";
export type MessagesContext = {
messages: ChatMessageWithId[];
addMessage: (message: ChatMessage) => ChatMessageWithId;
addMessage: (message: ChatMessage | PlaceholderMessage) => ChatMessageWithId;
setMessages: (messages: ChatMessageWithId[]) => void;
deleteMessage: (id: string) => void;
updateMessage: <
T extends ChatMessageWithId["type"],
Key extends keyof Omit<
Extract<ChatMessageWithId, { type: T }>,
"id" | "type" | "role"
"id" | "type"
>,
Value = Extract<ChatMessageWithId, { type: T }>[Key],
>(
@@ -1,7 +1,7 @@
import { v4 as uuidv4 } from "uuid";
import { type ChatMessage, type ChatMessageWithId } from "@langfuse/shared";
import { type ChatMessage, type ChatMessageWithIdNoPlaceholders } from "@langfuse/shared";
export function createEmptyMessage(message: ChatMessage): ChatMessageWithId {
export function createEmptyMessage(message: ChatMessage): ChatMessageWithIdNoPlaceholders {
return {
...message,
content: message.content ?? "",
+1
View File
@@ -338,6 +338,7 @@ export function DeleteEvalConfigButton(props: DeleteButtonProps) {
source: isTableAction ? "table-single-row" : "eval config detail",
})
}
customDeletePrompt="This action cannot be undone and removes all logs associated with this running evaluator. Scores produced by this evaluator will not be deleted."
entityToDeleteName="running evaluator"
executeDeleteMutation={executeDeleteMutation}
isDeleteMutationLoading={evaluatorMutation.isLoading}
+2 -2
View File
@@ -289,13 +289,13 @@ export default function Layout(props: PropsWithChildren) {
rel="icon"
type="image/png"
sizes="32x32"
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-32x32.png`}
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-32x32${env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV" ? "-dev" : ""}.png`}
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-16x16.png`}
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-16x16${env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV" ? "-dev" : ""}.png`}
/>
</Head>
<div>
@@ -29,6 +29,15 @@ type SidebarNotification = {
};
const notifications: SidebarNotification[] = [
{
id: "python-sdk-v3",
title: "New Python SDK v3",
description:
"Python SDK V3 offers significant improvements in developer experience, performance, and integrations.",
link: "https://langfuse.com/docs/sdk/python/sdk-v3#upgrade-from-v2",
linkTitle: "Upgrade to v3",
createdAt: "2025-06-27",
},
{
id: "lw3-5",
title: "Launch Week #3: Day 5",
@@ -102,7 +111,7 @@ const notifications: SidebarNotification[] = [
linkContent: (
// eslint-disable-next-line @next/next/no-img-element
<img
alt="Langfuse Github stars"
alt="Langfuse GitHub stars"
src="https://img.shields.io/github/stars/langfuse/langfuse?label=langfuse&amp;style=social"
/>
),
+2 -1
View File
@@ -125,11 +125,12 @@ export const ChatMlMessageSchema = z
...other,
...additional_kwargs,
}))
.transform(({ role, name, content, audio, ...other }) => ({
.transform(({ role, name, content, audio, type, ...other }) => ({
role,
name,
content,
audio,
type,
...(Object.keys(other).length === 0 ? {} : { json: other }),
}));
export type ChatMlMessageSchema = z.infer<typeof ChatMlMessageSchema>;
+49 -27
View File
@@ -234,10 +234,14 @@ export const OpenAiMessageView: React.FC<{
return !!message.json;
};
const isPlaceholderMessage = (message: ChatMlMessageSchema) => {
return message.type === 'placeholder';
};
const messagesToRender = useMemo(
() =>
messages.filter(
(message) => shouldRenderContent(message) || shouldRenderJson(message),
(message) => shouldRenderContent(message) || shouldRenderJson(message) || isPlaceholderMessage(message),
),
[messages],
);
@@ -257,39 +261,57 @@ export const OpenAiMessageView: React.FC<{
)
.map((message, index) => (
<Fragment key={index}>
{shouldRenderContent(message) &&
(shouldRenderMarkdown ? (
{isPlaceholderMessage(message) ? (
shouldRenderMarkdown ? (
<MarkdownJsonView
title={message.name ?? message.role}
content={message.content || '""'}
className={cn(!!message.json && "rounded-b-none")}
customCodeHeaderClassName={cn(
message.role === "assistant" && "bg-secondary",
message.role === "system" && "bg-primary-foreground",
)}
audio={message.audio}
title="Placeholder"
content={message.name || 'Unnamed placeholder'}
customCodeHeaderClassName={cn("bg-primary-foreground")}
/>
) : (
<JSONView
title={message.name ?? message.role}
json={message.content}
title="Placeholder"
json={message.name || 'Unnamed placeholder'}
projectIdForPromptButtons={projectIdForPromptButtons}
className={cn(!!message.json && "rounded-b-none")}
/>
))}
{shouldRenderJson(message) && (
<JSONView
title={
message.content
? undefined
: (message.name ?? message.role)
}
json={message.json}
projectIdForPromptButtons={projectIdForPromptButtons}
className={cn(
!!message.content && "rounded-t-none border-t-0",
)
) : (
<>
{shouldRenderContent(message) &&
(shouldRenderMarkdown ? (
<MarkdownJsonView
title={message.name ?? message.role}
content={message.content || '""'}
className={cn(!!message.json && !isPlaceholderMessage(message) && "rounded-b-none")}
customCodeHeaderClassName={cn(
message.role === "assistant" && "bg-secondary",
message.role === "system" && "bg-primary-foreground",
)}
audio={message.audio}
/>
) : (
<JSONView
title={message.name ?? message.role}
json={message.content}
projectIdForPromptButtons={projectIdForPromptButtons}
className={cn(!!message.json && !isPlaceholderMessage(message) && "rounded-b-none")}
/>
))}
{shouldRenderJson(message) && !isPlaceholderMessage(message) && (
<JSONView
title={
message.content
? undefined
: (message.name ?? message.role)
}
json={message.json}
projectIdForPromptButtons={projectIdForPromptButtons}
className={cn(
!!message.content && "rounded-t-none border-t-0",
)}
/>
)}
/>
</>
)}
{isCollapsed !== null && index === 0 ? (
<Button
+10 -9
View File
@@ -390,17 +390,18 @@ const ObservationTreeNodeCard = ({
)}
ref={currentObservationRef}
>
{/* Type badge */}
<ItemBadge
type={observation.type}
isSmall
className="flex-shrink-0 scale-75"
/>
{/* Type badge and name */}
<div className="flex items-center gap-2 flex-shrink-0">
<ItemBadge
type={observation.type}
isSmall
className="flex-shrink-0 scale-75"
/>
<span className="text-sm font-medium whitespace-nowrap">{observation.name}</span>
</div>
{/* Name and duration */}
{/* Duration and Comments */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{observation.name}</span>
{comments && showComments ? (
<CommentCountIcon count={comments.get(observation.id)} />
) : null}
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.75.4";
export const VERSION = "v3.78.2";
@@ -120,6 +120,7 @@ export const DatasetForm = (props: DatasetFormProps) => {
allNames: allDatasetNames,
form,
errorMessage: "Dataset name already exists.",
whitelistedName: props.mode === "update" ? props.datasetName : undefined,
});
function onSubmit(values: z.infer<typeof formSchema>) {
@@ -110,7 +110,11 @@ export function DatasetsTable(props: { projectId: string }) {
size: 200,
cell: ({ row }) => {
const description: RowData["description"] = row.getValue("description");
return <div className="h-full overflow-y-auto flex items-center">{description}</div>;
return (
<div className="flex h-full items-center overflow-y-auto">
{description}
</div>
);
},
},
{
@@ -189,6 +193,7 @@ export function DatasetsTable(props: { projectId: string }) {
datasetId={key.id}
datasetName={key.name}
datasetDescription={row.getValue("description") ?? undefined}
datasetMetadata={row.getValue("metadata") ?? undefined}
/>
</DropdownMenuItem>
<DropdownMenuItem asChild>
@@ -456,13 +456,15 @@ export function PreviewCsvImport({
</Button>
<Button
disabled={
selectedInputColumn.size === 0 &&
selectedExpectedColumn.size === 0 &&
selectedMetadataColumn.size === 0
(selectedInputColumn.size === 0 &&
selectedExpectedColumn.size === 0 &&
selectedMetadataColumn.size === 0) ||
progress.status === "processing"
}
loading={progress.status === "processing"}
onClick={handleImport}
>
Import
{progress.status === "processing" ? "Importing..." : "Import"}
</Button>
{progress.status === "processing" && (
<div className="mt-2">
@@ -6,7 +6,7 @@ import { cn } from "@/src/utils/tailwind";
import { type RouterOutput } from "@/src/utils/types";
import { type EvalTemplate } from "@langfuse/shared";
import Link from "next/link";
import { Fragment } from "react";
import { Fragment, useMemo } from "react";
const VARIABLE_COLORS = [
"text-primary-accent",
@@ -128,9 +128,14 @@ export const EvaluationPromptPreview = ({
className?: string;
controlButtons?: React.ReactNode;
}) => {
const memoizedVariables = useMemo(
() => variableMapping.map(({ templateVariable }) => templateVariable),
[variableMapping],
);
const { extractedVariables, isExtracting } = useExtractVariables({
variables: variableMapping.map(({ templateVariable }) => templateVariable),
variableMapping: variableMapping,
variables: memoizedVariables,
variableMapping,
trace: trace,
isLoading,
});
@@ -396,21 +396,27 @@ export const InnerEvaluatorForm = (props: {
<div className="flex items-center gap-2">
{form.watch("target") === "trace" && !props.disabled && (
<>
<span className="text-xs text-muted-foreground">Show preview</span>
<span className="text-xs text-muted-foreground">Show Preview</span>
<Switch
checked={showPreview}
onCheckedChange={setShowPreview}
disabled={props.disabled}
/>
{traceWithObservations && showPreview && (
<DetailPageNav
currentId={traceWithObservations.id}
listKey="traces"
path={(entry) =>
`/project/${props.projectId}/evals/new?evaluator=${props.evalTemplate.id}&traceId=${entry.id}`
}
/>
)}
{showPreview &&
(traceWithObservations ? (
<DetailPageNav
currentId={traceWithObservations.id}
listKey="traces"
path={(entry) =>
`/project/${props.projectId}/evals/new?evaluator=${props.evalTemplate.id}&traceId=${entry.id}`
}
/>
) : (
<div className="flex flex-row gap-1">
<Skeleton className="h-8 w-[54px]" />
<Skeleton className="h-8 w-[54px]" />
</div>
))}
</>
)}
</div>
@@ -723,11 +729,12 @@ export const InnerEvaluatorForm = (props: {
controlButtons={mappingControlButtons}
/>
) : (
<div className="flex max-h-full min-h-[200px] w-full flex-col gap-1 lg:w-2/3">
<div className="flex flex-row items-end justify-between">
<span className="h-fit px-1 text-start text-sm font-medium">
Evaluation Prompt
</span>
<div className="flex max-h-full min-h-48 w-full flex-col gap-1 lg:w-2/3">
<div className="flex flex-row items-center justify-between py-0 text-sm font-medium capitalize">
<div className="flex flex-row items-center gap-2">
Evaluation Prompt Preview
<Skeleton className="h-[25px] w-[63px]" />
</div>
<div className="flex justify-end">
{mappingControlButtons}
</div>
@@ -44,7 +44,8 @@ export function useExtractVariables({
const previousMappingRef = useRef<string>("");
// Create a stable string representation of the current mapping for comparison
const currentMappingString = JSON.stringify(variableMapping);
const currentMappingString =
variables.length > 0 ? JSON.stringify(variableMapping) : "";
// Create a stable reference to the trace ID
const traceId = trace?.id;
@@ -59,10 +60,14 @@ export function useExtractVariables({
useEffect(() => {
// Return early conditions
if (isLoading || !variables.length) {
setExtractedVariables(
variables.map((variable) => ({ variable, value: "n/a" })),
);
if (isLoading) {
setExtractedVariables([]);
return;
}
// If no variables, only update if current state is not empty
if (!Boolean(variables.length)) {
setExtractedVariables((prev) => (prev.length === 0 ? prev : []));
return;
}
@@ -638,7 +638,7 @@ export const CreateExperimentsForm = ({
<span>
Given current prompt, dataset item input must
contain at least one of these first-level JSON
keys, mapped to a string value:
keys:
</span>
<ul className="my-2 ml-2 list-inside list-disc">
{expectedColumns.map((col) => (
@@ -646,8 +646,9 @@ export const CreateExperimentsForm = ({
))}
</ul>
<span>
These will be used as the input to your
prompt.
Variables (like {"{{variable}}"}) should be mapped to string values.
Placeholders should be mapped to arrays of message objects.
These will be used as the input to your prompt.
</span>
</div>
) : (
@@ -721,7 +722,7 @@ export const CreateExperimentsForm = ({
<Loader2 className="h-3 w-3 animate-spin" />
</CardTitle>
<CardDescription className="text-foreground">
Checking dataset items against prompt variables
Checking dataset items against prompt variables and placeholders
</CardDescription>
</CardHeader>
</Card>
@@ -747,7 +748,7 @@ export const CreateExperimentsForm = ({
<CircleCheck className="h-4 w-4" />
</CardTitle>
<div className="text-sm">
Matches between dataset items and prompt variables
Matches between dataset items and prompt variables/placeholders
<ul className="my-2 ml-2 list-inside list-disc">
{Object.entries(
validationResult.data.variablesMap ?? {},
@@ -760,7 +761,7 @@ export const CreateExperimentsForm = ({
</li>
))}
</ul>
Items missing all prompt variables will be excluded from
Items missing all required variables and placeholders will be excluded from
the experiment.
</div>
</CardHeader>
@@ -1,8 +1,7 @@
import { useMemo } from "react";
import { api } from "@/src/utils/api";
import { type UseFormReturn } from "react-hook-form";
import { extractVariables } from "@langfuse/shared";
import { PromptType } from "@/src/features/prompts/server/utils/validation";
import { extractVariables, PromptType, extractPlaceholderNames, type PromptMessage } from "@langfuse/shared";
type ExperimentPromptDataProps = {
projectId: string;
@@ -23,11 +22,18 @@ export function useExperimentPromptData({
const prompt = promptMeta.data?.find((p) => p.id === promptId);
if (!prompt) return [];
return extractVariables(
const extractedVariables = extractVariables(
prompt.type === PromptType.Text
? (prompt?.prompt?.toString() ?? "")
: JSON.stringify(prompt?.prompt),
);
const promptMessages = prompt?.type === PromptType.Chat && Array.isArray(prompt.prompt)
? prompt.prompt
: [];
const placeholderNames = extractPlaceholderNames(promptMessages as PromptMessage[]);
return [...extractedVariables, ...placeholderNames];
}, [promptId, promptMeta.data]);
const promptsByName = useMemo(
+14 -5
View File
@@ -2,23 +2,25 @@ import { z } from "zod/v4";
import { randomUUID } from "crypto";
import {
type ExperimentMetadata,
ExperimentCreateQueue,
QueueJobs,
QueueName,
redis,
ZodModelConfig,
ExperimentCreateQueue,
} from "@langfuse/shared/src/server";
import {
createTRPCRouter,
protectedProjectProcedure,
} from "@/src/server/api/trpc";
import { PromptType } from "@/src/features/prompts/server/utils/validation";
import {
type DatasetItem,
DatasetStatus,
extractVariables,
datasetItemMatchesVariable,
UnauthorizedError,
PromptType,
extractPlaceholderNames,
type PromptMessage,
} from "@langfuse/shared";
import { throwIfNoProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
@@ -97,10 +99,17 @@ export const experimentsRouter = createTRPCRouter({
: JSON.stringify(prompt.prompt),
);
if (!Boolean(extractedVariables.length)) {
const promptMessages = prompt?.type === PromptType.Chat && Array.isArray(prompt.prompt)
? prompt.prompt
: [];
const placeholderNames = extractPlaceholderNames(promptMessages as PromptMessage[]);
const allVariables = [...extractedVariables, ...placeholderNames];
if (!Boolean(allVariables.length)) {
return {
isValid: false,
message: "Selected prompt has no variables.",
message: "Selected prompt has no variables or placeholders.",
};
}
@@ -121,7 +130,7 @@ export const experimentsRouter = createTRPCRouter({
const variablesMap = validateDatasetItems(
datasetItems,
extractedVariables,
allVariables,
);
if (!Boolean(Object.keys(variablesMap).length)) {

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