Compare commits

..
25 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
90 changed files with 5256 additions and 582 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.
+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/>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.76.0",
"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 {
@@ -178,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",
};
@@ -241,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",
};
@@ -274,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({
+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({
+2 -1
View File
@@ -105,10 +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;
@@ -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);
+2 -1
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";
@@ -45,7 +46,7 @@ export * from "./features/prompts/parsePromptDependencyTags";
export * from "./features/prompts/validation";
export * from "./features/prompts/types";
export * from "./features/prompts/constants";
export * from "./server/llm/compileChatMessages";
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
}
@@ -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;
@@ -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,
@@ -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;
},
@@ -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,
},
);
},
);
}
@@ -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;
@@ -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.76.0",
"version": "3.78.2",
"private": true,
"license": "MIT",
"engines": {
@@ -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,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();
});
});
});
@@ -911,6 +911,65 @@ describe("/api/public/v2/prompts API Endpoint", () => {
"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);
});
});
describe("when fetching a prompt list", () => {
+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,
+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}
@@ -111,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"
/>
),
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.76.0";
export const VERSION = "v3.78.2";
@@ -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">
@@ -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,7 +1,7 @@
import { useMemo } from "react";
import { api } from "@/src/utils/api";
import { type UseFormReturn } from "react-hook-form";
import { extractVariables, PromptType } from "@langfuse/shared";
import { extractVariables, PromptType, extractPlaceholderNames, type PromptMessage } from "@langfuse/shared";
type ExperimentPromptDataProps = {
projectId: string;
@@ -22,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(
+9 -15
View File
@@ -2,12 +2,11 @@ import { z } from "zod/v4";
import { randomUUID } from "crypto";
import {
type ExperimentMetadata,
ExperimentCreateQueue,
QueueJobs,
QueueName,
redis,
ZodModelConfig,
ExperimentCreateQueue,
type PlaceholderMessage,
} from "@langfuse/shared/src/server";
import {
createTRPCRouter,
@@ -20,6 +19,8 @@ import {
datasetItemMatchesVariable,
UnauthorizedError,
PromptType,
extractPlaceholderNames,
type PromptMessage,
} from "@langfuse/shared";
import { throwIfNoProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
@@ -98,24 +99,17 @@ export const experimentsRouter = createTRPCRouter({
: JSON.stringify(prompt.prompt),
);
if (!Boolean(extractedVariables.length)) {
return {
isValid: false,
message: "Selected prompt has no variables.",
};
}
const promptMessages = prompt?.type === PromptType.Chat && Array.isArray(prompt.prompt)
? prompt.prompt
: [];
const hasPlaceholders = promptMessages.some((msg): msg is PlaceholderMessage =>
(msg as PlaceholderMessage).type === "placeholder"
);
const placeholderNames = extractPlaceholderNames(promptMessages as PromptMessage[]);
if (hasPlaceholders) {
const allVariables = [...extractedVariables, ...placeholderNames];
if (!Boolean(allVariables.length)) {
return {
isValid: false,
message: "Selected prompt has placeholders, those are not yet supported for experiments.",
message: "Selected prompt has no variables or placeholders.",
};
}
@@ -136,7 +130,7 @@ export const experimentsRouter = createTRPCRouter({
const variablesMap = validateDatasetItems(
datasetItems,
extractedVariables,
allVariables,
);
if (!Boolean(Object.keys(variablesMap).length)) {
@@ -619,7 +619,7 @@ export class OtelIngestionProcessor {
}
private hasTraceUpdates(attributes: Record<string, unknown>): boolean {
return [
const hasExactMatchingAttributeName = [
LangfuseOtelSpanAttributes.TRACE_NAME,
LangfuseOtelSpanAttributes.TRACE_INPUT,
LangfuseOtelSpanAttributes.TRACE_OUTPUT,
@@ -637,6 +637,13 @@ export class OtelIngestionProcessor {
`${LangfuseOtelSpanAttributes.TRACE_METADATA}.langfuse_user_id`,
`${LangfuseOtelSpanAttributes.TRACE_METADATA}.langfuse_tags`,
].some((traceAttribute) => Boolean(attributes[traceAttribute]));
const attributeKeys = Object.keys(attributes);
const hasTraceMetadataKey = attributeKeys.some((key) =>
key.startsWith(LangfuseOtelSpanAttributes.TRACE_METADATA),
);
return hasExactMatchingAttributeName || hasTraceMetadataKey;
}
private extractResourceAttributes(
@@ -122,7 +122,7 @@ export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
title={
isAvailable
? "Test in LLM playground"
: "Test in LLM playground is not available since messages are not in valid ChatML format or tool calls have been used. If you think this is not correct, please open a Github issue."
: "Test in LLM playground is not available since messages are not in valid ChatML format or tool calls have been used. If you think this is not correct, please open a GitHub issue."
}
onClick={handleClick}
asChild
@@ -6,14 +6,17 @@ import { type ChatMessage, PromptChatMessageListSchema } from "@langfuse/shared"
import { usePlaygroundContext } from "../context";
import { type PlaceholderMessageFillIn } from "../types";
import { useNamingConflicts } from "../hooks/useNamingConflicts";
export const MessagePlaceholderComponent: React.FC<{
messagePlaceholder: PlaceholderMessageFillIn;
}> = ({ messagePlaceholder }) => {
const { updateMessagePlaceholderValue, deleteMessagePlaceholder } =
const { updateMessagePlaceholderValue, deleteMessagePlaceholder, promptVariables, messagePlaceholders } =
usePlaygroundContext();
const { name, value, isUsed } = messagePlaceholder;
const [error, setError] = useState<string | null>(null);
const { isPlaceholderConflicting } = useNamingConflicts(promptVariables, messagePlaceholders);
const hasConflict = isPlaceholderConflicting(name);
const handleInputChange = useCallback((jsonString: string) => {
try {
@@ -39,7 +42,7 @@ export const MessagePlaceholderComponent: React.FC<{
<div className="mb-1 flex flex-row items-center">
<span className="flex flex-1 flex-row space-x-2 text-xs">
<UsedIcon size={16} color={iconColor} />
<p className="min-w-[90px] truncate font-mono" title={name}>
<p className={`min-w-[90px] truncate font-mono ${hasConflict ? 'text-red-500' : ''}`} title={name}>
{name ? name : "Unnamed placeholder"}
</p>
</span>
@@ -60,12 +63,17 @@ export const MessagePlaceholderComponent: React.FC<{
onChange={handleInputChange}
mode="json"
minHeight="none"
className="max-h-[15rem] w-full resize-y p-1 font-mono text-xs focus:outline-none"
className={`max-h-[15rem] w-full resize-y p-1 font-mono text-xs focus:outline-none ${hasConflict ? 'border border-red-500' : ''}`}
editable={true}
lineNumbers={false}
placeholder={`[\n {\n "role": "user",\n "content": "Hello!"\n },\n {\n "role": "assistant",\n "content": "Hi there!"\n }\n]`}
/>
{hasConflict && (
<p className="mt-1 text-xs text-red-500">
Placeholder name conflicts with variable. Names must be unique.
</p>
)}
{error && (
<p className="mt-1 text-xs text-red-500">{error}</p>
)}
@@ -4,13 +4,16 @@ import { type PromptVariable } from "@langfuse/shared";
import { CodeMirrorEditor } from "@/src/components/editor";
import { usePlaygroundContext } from "../context";
import { useNamingConflicts } from "../hooks/useNamingConflicts";
export const PromptVariableComponent: React.FC<{
promptVariable: PromptVariable;
}> = ({ promptVariable }) => {
const { updatePromptVariableValue, deletePromptVariable } =
const { updatePromptVariableValue, deletePromptVariable, promptVariables, messagePlaceholders } =
usePlaygroundContext();
const { name, value, isUsed } = promptVariable;
const { isVariableConflicting } = useNamingConflicts(promptVariables, messagePlaceholders);
const hasConflict = isVariableConflicting(name);
const handleInputChange = (value: string) => {
updatePromptVariableValue(name, value);
@@ -32,7 +35,7 @@ export const PromptVariableComponent: React.FC<{
<div className="mb-1 flex flex-row items-center">
<span className="flex flex-1 flex-row space-x-2 text-xs">
<p title={isUsedTooltip}>{isUsedIcon}</p>
<p className="min-w-[90px] truncate font-mono" title={name}>
<p className={`min-w-[90px] truncate font-mono ${hasConflict ? 'text-red-500' : ''}`} title={name}>
{name}
</p>
</span>
@@ -53,11 +56,17 @@ export const PromptVariableComponent: React.FC<{
onChange={(e) => handleInputChange(e)}
mode="prompt"
minHeight="none"
className="max-h-[10rem] w-full resize-y p-1 font-mono text-xs focus:outline-none"
className={`max-h-[10rem] w-full resize-y p-1 font-mono text-xs focus:outline-none ${hasConflict ? 'border border-red-500' : ''}`}
editable={true}
lineNumbers={false}
placeholder={name}
/>
{hasConflict && (
<p className="mt-1 text-xs text-red-500">
Variable name conflicts with placeholder. Names must be unique.
</p>
)}
</div>
);
};
@@ -0,0 +1,43 @@
import { useMemo } from "react";
import { type PromptVariable } from "@langfuse/shared";
import { type PlaceholderMessageFillIn } from "../types";
export interface NamingConflictInfo {
hasConflicts: boolean;
conflictingNames: string[];
isVariableConflicting: (variableName: string) => boolean;
isPlaceholderConflicting: (placeholderName: string) => boolean;
}
/**
* Detects naming conflicts between variables and placeholders in the playground.
* Returns names of conflicting variables and placeholders.
*/
export const useNamingConflicts = (
variables: PromptVariable[],
placeholders: PlaceholderMessageFillIn[]
): NamingConflictInfo => {
return useMemo(() => {
const variableNames = variables.map(v => v.name);
const placeholderNames = placeholders.map(p => p.name);
const conflictingNames = variableNames.filter(name =>
placeholderNames.includes(name)
);
const hasConflicts = conflictingNames.length > 0;
const isVariableConflicting = (variableName: string) =>
conflictingNames.includes(variableName);
const isPlaceholderConflicting = (placeholderName: string) =>
conflictingNames.includes(placeholderName);
return {
hasConflicts,
conflictingNames,
isVariableConflicting,
isPlaceholderConflicting,
};
}, [variables, placeholders]);
};
@@ -12,7 +12,13 @@ import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
import { useOrderByState } from "@/src/features/orderBy/hooks/useOrderByState";
import { promptsTableColsWithOptions } from "@/src/server/api/definitions/promptsTable";
import { NumberParam, StringParam, useQueryParams, withDefault } from "use-query-params";
import {
NumberParam,
StringParam,
useQueryParams,
withDefault,
useQueryParam,
} from "use-query-params";
import { createColumnHelper } from "@tanstack/react-table";
import { joinTableCoreAndMetrics } from "@/src/components/table/utils/joinTableCoreAndMetrics";
import { Skeleton } from "@/src/components/ui/skeleton";
@@ -40,7 +46,11 @@ type PromptTableRow = {
};
function createRow(
data: Partial<PromptTableRow> & { id: string; name: string; type: "folder" | "text" | "chat" }
data: Partial<PromptTableRow> & {
id: string;
name: string;
type: "folder" | "text" | "chat";
},
): PromptTableRow {
return {
version: undefined,
@@ -52,12 +62,14 @@ function createRow(
};
}
function isFolder(row: PromptTableRow): row is PromptTableRow & { type: "folder" } {
function isFolder(
row: PromptTableRow,
): row is PromptTableRow & { type: "folder" } {
return row.type === "folder";
}
function getDisplayName(fullPath: string, currentFolderPath: string): string {
return currentFolderPath === ''
return currentFolderPath === ""
? fullPath
: fullPath.substring(currentFolderPath.length + 1);
}
@@ -65,9 +77,9 @@ function getDisplayName(fullPath: string, currentFolderPath: string): string {
function createBreadcrumbItems(currentFolderPath: string) {
if (!currentFolderPath) return [];
const segments = currentFolderPath.split('/');
const segments = currentFolderPath.split("/");
return segments.map((name, i) => {
const folderPath = segments.slice(0, i + 1).join('/');
const folderPath = segments.slice(0, i + 1).join("/");
return {
name,
folderPath,
@@ -95,12 +107,27 @@ export function PromptTable() {
folder: StringParam,
});
const [searchQuery, setSearchQuery] = useQueryParam(
"search",
withDefault(StringParam, null),
);
// Reset pagination when search query changes
useEffect(() => {
setQueryParams({
pageIndex: 0,
pageSize: queryParams.pageSize,
folder: queryParams.folder,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
const paginationState = {
pageIndex: queryParams.pageIndex,
pageSize: queryParams.pageSize,
};
const currentFolderPath = queryParams.folder || '';
const currentFolderPath = queryParams.folder || "";
const prompts = api.prompts.all.useQuery(
{
@@ -110,6 +137,7 @@ export function PromptTable() {
filter: filterState,
orderBy: orderByState,
pathPrefix: currentFolderPath,
searchQuery: searchQuery || undefined,
},
{
enabled: Boolean(projectId),
@@ -167,7 +195,7 @@ export function PromptTable() {
const prefix = `${currentFolderPath}/`;
if (promptName.startsWith(prefix)) {
const remainingPath = promptName.substring(prefix.length);
const slashIndex = remainingPath.indexOf('/');
const slashIndex = remainingPath.indexOf("/");
if (slashIndex > 0) {
// Subfolder
@@ -181,7 +209,7 @@ export function PromptTable() {
}
} else {
// Root level
const slashIndex = promptName.indexOf('/');
const slashIndex = promptName.indexOf("/");
if (slashIndex > 0) {
const folderName = promptName.substring(0, slashIndex);
uniqueFolders.add(folderName);
@@ -197,11 +225,13 @@ export function PromptTable() {
// Add folder rows
for (const folderPath of uniqueFolders) {
const folderName = getDisplayName(folderPath, currentFolderPath);
combinedRows.push(createRow({
id: folderPath,
name: folderName,
type: "folder",
}));
combinedRows.push(
createRow({
id: folderPath,
name: folderName,
type: "folder",
}),
);
}
// Add matching prompts
@@ -216,7 +246,7 @@ export function PromptTable() {
labels: prompt.labels,
tags: prompt.tags,
numberOfObservations: Number(prompt.observationCount ?? 0),
})
}),
);
}
@@ -284,7 +314,7 @@ export function PromptTable() {
setQueryParams({
folder: rowData.id,
pageIndex: 0,
pageSize: queryParams.pageSize
pageSize: queryParams.pageSize,
});
}}
title={displayName || ""}
@@ -397,7 +427,7 @@ export function PromptTable() {
return (
<>
{currentFolderPath && (
<div className="pt-2 ml-2">
<div className="ml-2 pt-2">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
@@ -407,38 +437,40 @@ export function PromptTable() {
setQueryParams({
folder: undefined,
pageIndex: 0,
pageSize: queryParams.pageSize
pageSize: queryParams.pageSize,
});
}}
>
<Home className="h-4 w-4" />
</BreadcrumbLink>
</BreadcrumbItem>
{createBreadcrumbItems(currentFolderPath).flatMap((item, index, array) => [
index > 0 && (
<BreadcrumbSeparator key={`sep-${item.folderPath}`}>
<Slash />
</BreadcrumbSeparator>
),
<BreadcrumbItem key={item.folderPath}>
{index === array.length - 1 ? (
<BreadcrumbPage>{item.name}</BreadcrumbPage>
) : (
<BreadcrumbLink
className="cursor-pointer hover:underline"
onClick={() => {
setQueryParams({
folder: item.folderPath,
pageIndex: 0,
pageSize: queryParams.pageSize
});
}}
>
{item.name}
</BreadcrumbLink>
)}
</BreadcrumbItem>
])}
{createBreadcrumbItems(currentFolderPath).flatMap(
(item, index, array) => [
index > 0 && (
<BreadcrumbSeparator key={`sep-${item.folderPath}`}>
<Slash />
</BreadcrumbSeparator>
),
<BreadcrumbItem key={item.folderPath}>
{index === array.length - 1 ? (
<BreadcrumbPage>{item.name}</BreadcrumbPage>
) : (
<BreadcrumbLink
className="cursor-pointer hover:underline"
onClick={() => {
setQueryParams({
folder: item.folderPath,
pageIndex: 0,
pageSize: queryParams.pageSize,
});
}}
>
{item.name}
</BreadcrumbLink>
)}
</BreadcrumbItem>,
],
)}
</BreadcrumbList>
</Breadcrumb>
</div>
@@ -451,6 +483,14 @@ export function PromptTable() {
filterState={filterState}
setFilterState={useDebounce(setFilterState)}
columnsWithCustomSelect={["labels", "tags"]}
searchConfig={{
metadataSearchFields: ["Name", "Tags"],
updateQuery: useDebounce(setSearchQuery, 300),
currentQuery: searchQuery ?? undefined,
tableAllowsFullTextSearch: false,
setSearchType: undefined,
searchType: undefined,
}}
/>
<DataTable
columns={promptColumns}
@@ -468,11 +508,12 @@ export function PromptTable() {
isError: false,
data: processedRowData.rows?.map((item) => ({
id: item.id,
name: item.type === 'folder'
? item.name
: currentFolderPath
? item.name.substring(currentFolderPath.length + 1)
: item.name,
name:
item.type === "folder"
? item.name
: currentFolderPath
? item.name.substring(currentFolderPath.length + 1)
: item.name,
version: item.version,
createdAt: item.createdAt,
type: item.type,
@@ -8,6 +8,7 @@ import {
type CreatePromptTRPCType,
LATEST_PROMPT_LABEL,
PromptType,
extractVariables,
} from "@langfuse/shared";
import { type PrismaClient } from "@langfuse/shared/src/db";
import { removeLabelsFromPreviousPromptVersions } from "@/src/features/prompts/server/utils/updatePromptLabels";
@@ -16,6 +17,7 @@ import {
PromptContentSchema,
PromptService,
redis,
extractPlaceholderNames,
} from "@langfuse/shared/src/server";
export type CreatePromptParams = CreatePromptTRPCType & {
@@ -32,6 +34,22 @@ type DuplicatePromptParams = {
prisma: PrismaClient;
};
const extractChatVariableAndPlaceholderNames = (
chatPrompt: Array<any>,
): { variables: string[], placeholders: string[] } => {
const placeholders = extractPlaceholderNames(chatPrompt);
const variables: string[] = [];
for (const message of chatPrompt) {
if (message && 'content' in message && typeof message.content === 'string') {
variables.push(...extractVariables(message.content));
}
}
return { variables: [...new Set(variables)], placeholders: [...new Set(placeholders)] };
};
export const createPrompt = async ({
projectId,
name,
@@ -55,6 +73,17 @@ export const createPrompt = async ({
);
}
// Prevent naming collisions between variables and placeholders
if (type === PromptType.Chat && Array.isArray(prompt)) {
const { variables, placeholders } = extractChatVariableAndPlaceholderNames(prompt);
const conflictingNames = variables.filter(v => placeholders.includes(v));
if (conflictingNames.length > 0) {
throw new InvalidRequestError(
`Cannot create prompt, variables and placeholders must be unique, the following are not: ${conflictingNames.join(", ")}`
);
}
}
const finalLabels = [...labels, LATEST_PROMPT_LABEL]; // Newly created prompts are always labeled as 'latest'
// If tags are undefined, use the tags from the latest prompt version
@@ -185,7 +214,7 @@ export const duplicatePrompt = async ({
});
if (!existingPrompt) {
throw new Error(`Existing prompt not found: ${promptId}`);
throw new InvalidRequestError(`Existing prompt not found: ${promptId}`);
}
// if defined as single version, duplicate current prompt as new prompt v1
@@ -253,7 +282,7 @@ export const duplicatePrompt = async ({
return promptResult;
});
// If you need the created prompt, fetch it separately since createMany doesn't return created records
// Fetch the created prompt to return
const createdPrompt = await prisma.prompt.findFirst({
where: {
name,
@@ -40,6 +40,7 @@ const PromptFilterOptions = z.object({
orderBy: orderBy,
...paginationZod,
pathPrefix: z.string().optional(),
searchQuery: z.string().optional(),
});
export const promptRouter = createTRPCRouter({
@@ -90,6 +91,10 @@ export const promptRouter = createTRPCRouter({
? Prisma.sql` AND (p.name LIKE ${input.pathPrefix + "/%"} OR p.name = ${input.pathPrefix})`
: Prisma.empty;
const searchFilter = input.searchQuery
? Prisma.sql` AND (p.name ILIKE ${`%${input.searchQuery}%`} OR EXISTS (SELECT 1 FROM UNNEST(p.tags) AS tag WHERE tag ILIKE ${`%${input.searchQuery}%`}))`
: Prisma.empty;
const [prompts, promptCount] = await Promise.all([
// prompts
ctx.prisma.$queryRaw<Array<Prompt>>(
@@ -111,6 +116,7 @@ export const promptRouter = createTRPCRouter({
input.limit,
input.page,
pathFilter,
searchFilter,
),
),
// promptCount
@@ -123,6 +129,7 @@ export const promptRouter = createTRPCRouter({
1, // limit
0, // page,
pathFilter,
searchFilter,
),
),
]);
@@ -1233,6 +1240,7 @@ const generatePromptQuery = (
limit: number,
page: number,
pathFilter: Prisma.Sql = Prisma.empty,
searchFilter: Prisma.Sql = Prisma.empty,
) => {
return Prisma.sql`
SELECT
@@ -1244,11 +1252,13 @@ const generatePromptQuery = (
WHERE "project_id" = ${projectId}
${filterCondition}
${pathFilter}
${searchFilter}
GROUP BY name
)
AND "project_id" = ${projectId}
${filterCondition}
${pathFilter}
${searchFilter}
${orderCondition}
LIMIT ${limit} OFFSET ${page * limit};
`;
+1 -1
View File
@@ -114,7 +114,7 @@ export const query = z
}),
)
.nullable(),
// Chart configuration for chart-specific settings like histogram bins
// Chart configuration for chart-specific settings like histogram bins and pivot table dimensions
chartConfig: z
.object({
type: z.string(),
@@ -11,15 +11,24 @@ import { type DashboardWidgetChartType } from "@langfuse/shared/src/db";
import { Button } from "@/src/components/ui/button";
import { AlertCircle } from "lucide-react";
import { BigNumber } from "@/src/features/widgets/chart-library/BigNumber";
import { PivotTable } from "@/src/features/widgets/chart-library/PivotTable";
export const Chart = ({
chartType,
data,
rowLimit,
chartConfig,
}: {
chartType: DashboardWidgetChartType;
data: DataPoint[];
rowLimit: number;
chartConfig?: {
type: DashboardWidgetChartType;
row_limit?: number;
bins?: number;
dimensions?: string[];
metrics?: string[];
};
}) => {
const [forceRender, setForceRender] = useState(false);
const shouldWarn = data.length > 2000 && !forceRender;
@@ -58,6 +67,15 @@ export const Chart = ({
case "NUMBER": {
return <BigNumber data={renderedData} />;
}
case "PIVOT_TABLE": {
// Extract pivot table configuration from chartConfig
const pivotConfig = {
dimensions: chartConfig?.dimensions ?? [],
metrics: chartConfig?.metrics ?? ["metric"], // Use metrics from chartConfig
rowLimit: chartConfig?.row_limit ?? rowLimit,
};
return <PivotTable data={renderedData} config={pivotConfig} />;
}
default:
return <HorizontalBarChart data={renderedData.slice(0, rowLimit)} />;
}
@@ -0,0 +1,269 @@
/**
* @fileoverview PivotTable Chart Component
*
* A configurable pivot table widget component that displays data in a tabular format
* with support for multiple dimensions (currently up to 2), metrics as columns,
* subtotals, and grand totals.
*
* Features:
* - Dynamic dimension support (0-N dimensions, currently limited to 2)
* - Proper indentation for nested dimension levels
* - Subtotal and grand total calculations
* - Responsive design within dashboard grid
* - Consistent styling with Langfuse design system
* - Row limiting to prevent performance issues
*
* Usage:
* Used as part of the dashboard widget system to display tabular data
* visualizations with grouping and aggregation capabilities.
*/
import React, { useMemo } from "react";
import { cn } from "@/src/utils/tailwind";
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from "@/src/components/ui/table";
import {
transformToPivotTable,
extractDimensionValues,
extractMetricValues,
type PivotTableRow,
type PivotTableConfig,
type DatabaseRow,
DEFAULT_ROW_LIMIT,
} from "@/src/features/widgets/utils/pivot-table-utils";
import { type ChartProps } from "@/src/features/widgets/chart-library/chart-props";
import { numberFormatter } from "@/src/utils/numbers";
import { formatMetricName } from "@/src/features/widgets/utils";
/**
* Props interface for the PivotTable component
* Uses standard chart data structure with pivot-specific configuration
*/
export interface PivotTableProps {
/** Array of data points from the chart query */
data: ChartProps["data"];
/** Pivot table specific configuration */
config?: PivotTableConfig;
/** Chart configuration from shadcn/ui (for consistency with other charts) */
chartConfig?: ChartProps["config"];
/** Accessibility layer flag */
accessibilityLayer?: boolean;
}
/**
* Individual row component for the pivot table
* Handles styling, indentation, and content display for each row type
*/
const PivotTableRowComponent: React.FC<{
row: PivotTableRow;
metrics: string[];
}> = ({ row, metrics }) => {
return (
<TableRow
className={cn(
"border-b transition-colors hover:bg-muted/30",
row.isSubtotal && "bg-muted/30",
row.isTotal && "bg-muted/50",
)}
>
{/* Dimension column with indentation and styling */}
<TableCell
className={cn(
"p-2 align-middle font-normal",
// Apply indentation based on level using explicit Tailwind classes
row.level === 1 && "pl-6", // 1.5rem indentation for level 1
row.level === 2 && "pl-10", // 2.5rem indentation for level 2
// Bold styling for subtotal and total rows
(row.isSubtotal || row.isTotal) && "font-semibold",
)}
style={{
// Fallback for levels beyond 2 using inline styles
paddingLeft:
row.level > 2 ? `${row.level * 1.5 + 0.5}rem` : undefined,
}}
>
{row.label}
</TableCell>
{/* Metric columns */}
{metrics.map((metric) => (
<TableCell
key={metric}
className={cn(
"p-2 text-right align-middle tabular-nums",
(row.isSubtotal || row.isTotal) && "font-semibold",
)}
>
{formatMetricValue(row.values[metric])}
</TableCell>
))}
</TableRow>
);
};
/**
* Formats metric values for display in the table
* Handles numbers and strings with appropriate formatting
*
* @param value - The metric value to format
* @returns Formatted string for display
*/
function formatMetricValue(value: number | string): string {
if (typeof value === "string") {
return value;
}
return numberFormatter(value, 2).replace(/\.00$/, "");
}
/**
* Formats metric names for column headers
*
* @param metricName - The metric field name
* @returns Formatted column header
*/
function formatColumnHeader(metricName: string): string {
return formatMetricName(metricName);
}
/**
* Main PivotTable Component
*
* Transforms flat data into a pivot table structure and renders it with
* proper styling, indentation, and responsive behavior.
*
* @param data - Array of data points from the chart query
* @param config - Pivot table configuration including dimensions and metrics
*/
export const PivotTable: React.FC<PivotTableProps> = ({ data, config }) => {
// Transform chart data into pivot table structure
const pivotTableRows = useMemo(() => {
if (!data || data.length === 0) {
return [];
}
// Extract configuration with defaults
const pivotConfig: PivotTableConfig = {
dimensions: config?.dimensions ?? [],
metrics: config?.metrics ?? ["metric"], // Default to 'metric' field from DataPoint
rowLimit: config?.rowLimit ?? DEFAULT_ROW_LIMIT,
};
// Transform DataPoint[] to DatabaseRow[] format using utility functions
const databaseRows: DatabaseRow[] = data.map((point) => {
// Cast the point to any to access dynamic fields from the query
const rowData = point as any;
// Create a database row with all fields from the original data
const row: DatabaseRow = { ...rowData };
// Use utility functions to ensure proper extraction and parsing
const dimensionValues = extractDimensionValues(
row,
pivotConfig.dimensions,
);
const metricValues = extractMetricValues(row, pivotConfig.metrics);
// Combine dimension and metric values into the final row
const result: DatabaseRow = {
...dimensionValues,
...metricValues,
};
// Include time dimension if present
if (point.time_dimension !== undefined) {
result.time_dimension = point.time_dimension;
}
// Include legacy 'metric' field for backward compatibility
if (point.metric !== undefined) {
if (typeof point.metric === "number") {
result.metric = point.metric;
} else if (Array.isArray(point.metric)) {
result.metric = point.metric
.flat()
.reduce((sum, val) => sum + val, 0);
}
}
return result;
});
try {
return transformToPivotTable(databaseRows, pivotConfig);
} catch (error) {
console.error("Error transforming data to pivot table:", error);
return [];
}
}, [data, config]);
// Extract metrics from configuration or fallback to default
const metrics = useMemo(() => {
return config?.metrics ?? ["metric"];
}, [config?.metrics]);
// Handle empty data state
if (!data || data.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-sm text-muted-foreground">No data available</p>
</div>
</div>
);
}
// Handle transformation errors
if (pivotTableRows.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-sm text-muted-foreground">
Unable to process data for pivot table
</p>
</div>
</div>
);
}
return (
<div className="h-full overflow-auto px-5 pb-2">
<Table>
<TableHeader>
<TableRow className="border-b bg-muted/50">
{/* Dimension column header */}
<TableHead className="p-2 text-left font-medium">
{config?.dimensions && config.dimensions.length > 0
? config.dimensions.map(formatColumnHeader).join(" / ") // Show all dimensions
: "Dimension"}
</TableHead>
{/* Metric column headers */}
{metrics.map((metric) => (
<TableHead key={metric} className="p-2 text-right font-medium">
{formatColumnHeader(metric)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{pivotTableRows.map((row) => (
<PivotTableRowComponent key={row.id} row={row} metrics={metrics} />
))}
</TableBody>
</Table>
</div>
);
};
export default PivotTable;
@@ -51,6 +51,7 @@ export const isTimeSeriesChart = (
case "PIE":
case "HISTOGRAM":
case "NUMBER":
case "PIVOT_TABLE":
return false;
default:
return false;
@@ -82,6 +83,8 @@ export function getChartTypeDisplayName(
return "Big Number (Total Value)";
case "HISTOGRAM":
return "Histogram (Total Value)";
case "PIVOT_TABLE":
return "Pivot Table (Total Value)";
default:
return "Unknown Chart Type";
}
@@ -17,10 +17,10 @@ import {
Loader2,
} from "lucide-react";
import { useRouter } from "next/router";
import { startCase } from "lodash";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { showErrorToast } from "@/src/features/notifications/showErrorToast";
import { DownloadButton } from "@/src/features/widgets/chart-library/DownloadButton";
import { formatMetricName } from "@/src/features/widgets/utils";
export interface WidgetPlacement {
id: string;
@@ -113,10 +113,23 @@ export function DashboardWidget({
return [];
}
return queryResult.data.map((item: any) => {
// Get the dimension field (first dimension in the query)
const dimensionField =
widget.data.dimensions.slice().shift()?.field ?? "none";
// Get the metric field (first metric in the query with its aggregation)
if (widget.data.chartType === "PIVOT_TABLE") {
// For pivot tables, preserve all raw data fields without any transformation
// The PivotTable component will extract the appropriate metric fields
// using the metric field names passed via chartConfig
return {
dimension:
widget.data.dimensions.length > 0
? (widget.data.dimensions[0]?.field ?? "dimension")
: "dimension", // Fallback for compatibility
metric: 0, // Placeholder - not used for pivot tables
time_dimension: item["time_dimension"],
// Include all original query fields for pivot table processing
...item,
};
}
// Regular chart processing for non-pivot tables
const metric = widget.data.metrics.slice().shift() ?? {
measure: "count",
agg: "count",
@@ -124,6 +137,8 @@ export function DashboardWidget({
const metricField = `${metric.agg}_${metric.measure}`;
const metricValue = item[metricField];
const dimensionField =
widget.data.dimensions.slice().shift()?.field ?? "none";
return {
dimension:
item[dimensionField] !== undefined
@@ -136,7 +151,7 @@ export function DashboardWidget({
// Objects / numbers / booleans are stringified to avoid React key issues
return String(val);
})()
: startCase(metricField === "count_count" ? "Count" : metricField),
: formatMetricName(metricField),
metric: Array.isArray(metricValue)
? metricValue
: Number(metricValue || 0),
@@ -276,6 +291,16 @@ export function DashboardWidget({
? 100
: (widget.data.chartConfig.row_limit ?? 100)
}
chartConfig={{
...widget.data.chartConfig,
// For PIVOT_TABLE, enhance chartConfig with dimensions and metric field names
...(widget.data.chartType === "PIVOT_TABLE" && {
dimensions: widget.data.dimensions.map((dim) => dim.field),
metrics: widget.data.metrics.map(
(metric) => `${metric.agg}_${metric.measure}`,
),
}),
}}
/>
</div>
</div>
File diff suppressed because it is too large Load Diff
+3
View File
@@ -6,3 +6,6 @@ export { Chart } from "./chart-library/Chart";
export { WidgetForm } from "./components/WidgetForm";
export { DashboardWidgetTable, DeleteWidget } from "./components/WidgetTable";
export { DashboardWidget } from "./components/DashboardWidget";
// Export types and utilities
export { type WidgetChartConfig } from "./utils";
+71 -13
View File
@@ -1,26 +1,74 @@
import { startCase } from "lodash";
import { type FilterState } from "@langfuse/shared";
import { type DashboardWidgetChartType } from "@langfuse/shared/src/db";
// Shared widget chart configuration types
export type WidgetChartConfig = {
type: DashboardWidgetChartType;
row_limit?: number;
bins?: number;
};
/**
* Formats a metric name for display, handling special cases like count_count -> Count
*/
export function formatMetricName(metricName: string): string {
// Handle the count_count -> Count conversion
const cleanedName = metricName === "count_count" ? "Count" : metricName;
return startCase(cleanedName);
}
/**
* Formats multiple metric names for display, showing first 3 and "+ X more" if needed
*/
export function formatMultipleMetricNames(metricNames: string[]): string {
if (metricNames.length === 0) return "No Metrics";
if (metricNames.length === 1) return formatMetricName(metricNames[0]);
const formattedNames = metricNames.map(formatMetricName);
if (metricNames.length <= 3) {
return formattedNames.join(", ");
}
const firstThree = formattedNames.slice(0, 3).join(", ");
const remaining = metricNames.length - 3;
return `${firstThree} + ${remaining} more`;
}
export function buildWidgetName({
aggregation,
measure,
dimension,
view,
metrics,
isMultiMetric = false,
}: {
aggregation: string;
measure: string;
dimension: string;
view: string;
metrics?: string[];
isMultiMetric?: boolean;
}) {
const meas = startCase(measure);
let base: string;
if (measure.toLowerCase() === "count") {
// For count measures, ignore aggregation and only show the measure
base = meas;
if (isMultiMetric && metrics && metrics.length > 0) {
// Handle multi-metric scenarios (like pivot tables)
const metricDisplay = formatMultipleMetricNames(metrics);
base = metricDisplay;
} else {
const agg = startCase(aggregation.toLowerCase());
base = `${agg} ${meas}`;
// Handle single metric scenarios (existing logic)
const meas = formatMetricName(measure);
if (measure.toLowerCase() === "count") {
// For count measures, ignore aggregation and only show the measure
base = meas;
} else {
const agg = startCase(aggregation.toLowerCase());
base = `${agg} ${meas}`;
}
}
if (dimension && dimension !== "none") {
base += ` by ${startCase(dimension)}`;
}
@@ -34,24 +82,34 @@ export function buildWidgetDescription({
dimension,
view,
filters,
metrics,
isMultiMetric = false,
}: {
aggregation: string;
measure: string;
dimension: string;
view: string;
filters: FilterState;
metrics?: string[];
isMultiMetric?: boolean;
}) {
// Base sentence: "Shows the <agg> <measure> of <view> ..."
const measLabel = startCase(measure.toLowerCase());
const viewLabel = startCase(view);
let sentence: string;
if (measure.toLowerCase() === "count") {
sentence = `Shows the count of ${viewLabel}`;
if (isMultiMetric && metrics && metrics.length > 0) {
// Handle multi-metric scenarios
const metricDisplay = formatMultipleMetricNames(metrics);
sentence = `Shows ${metricDisplay.toLowerCase()} of ${viewLabel}`;
} else {
const aggLabel = startCase(aggregation.toLowerCase());
sentence = `Shows the ${aggLabel.toLowerCase()} ${measLabel.toLowerCase()} of ${viewLabel}`;
// Handle single metric scenarios (existing logic)
const measLabel = formatMetricName(measure);
if (measure.toLowerCase() === "count") {
sentence = `Shows the count of ${viewLabel}`;
} else {
const aggLabel = startCase(aggregation.toLowerCase());
sentence = `Shows the ${aggLabel.toLowerCase()} ${measLabel.toLowerCase()} of ${viewLabel}`;
}
}
// Dimension clause
@@ -0,0 +1,610 @@
/**
* @fileoverview Utility functions and type definitions for Pivot Table widget functionality
*
* This module provides core utilities for transforming flat query results into nested
* pivot table structures with configurable dimensions, subtotals, and grand totals.
*
* Key Features:
* - Configurable maximum dimensions (currently limited to 2)
* - Support for 0-N dimensions with proper nesting and indentation
* - Automatic subtotal generation for first dimension level
* - Grand total calculation across all metrics
* - Row limiting to prevent performance issues
*
* Usage:
* - Used by PivotTable React component for data transformation
* - Integrated with QueryBuilder for SQL generation
* - Supports future expansion beyond current 2-dimension limit
*/
import { isNotNullOrUndefined } from "@/src/utils/types";
/**
* Default dimension limit for pivot table data rows
* This prevents performance issues and maintains readability
*/
export const MAX_PIVOT_TABLE_DIMENSIONS = 2;
/**
* Default row limit for pivot table data rows (excluding total rows)
* This prevents performance issues with large datasets while maintaining
* useful data visibility.
*/
export const DEFAULT_ROW_LIMIT = 20;
/**
* Maximum number of metrics allowed in a pivot table
* This prevents performance issues and maintains readability
*/
export const MAX_PIVOT_TABLE_METRICS = 10;
/**
* Represents a single row in the processed pivot table structure
* Supports different row types for data, subtotals, and grand totals
* with appropriate styling and indentation levels.
*/
export interface PivotTableRow {
/** Unique identifier for this row */
id: string;
/** Type of row determines styling and behavior */
type: "data" | "subtotal" | "total";
/** Indentation level for nested dimensions (0-based) */
level: number;
/** Display label for the row (dimension value or "Total"/"Subtotal") */
label: string;
/** Metric values for this row, keyed by metric name */
values: Record<string, number | string>;
/** Whether this row represents a subtotal */
isSubtotal?: boolean;
/** Whether this row represents the grand total */
isTotal?: boolean;
/** Original dimension values for this row (for data rows only) */
dimensionValues?: Record<string, string>;
}
/**
* Configuration for pivot table data transformation
* Defines the structure and limits for the resulting table
*/
export interface PivotTableConfig {
/** Array of dimension field names (max length = MAX_PIVOT_TABLE_DIMENSIONS) */
dimensions: string[];
/** Array of metric field names to display as columns */
metrics: string[];
/** Maximum number of data rows to display (before totals) */
rowLimit?: number;
}
/**
* Raw database row structure from query results
* Contains dimension values and metric calculations
*/
export interface DatabaseRow {
/** Dimension field values */
[dimensionField: string]: string | number | null;
}
/**
* Validates that the provided configuration is valid for pivot table generation
*
* @param config - Pivot table configuration to validate
* @throws Error if configuration is invalid
*/
export function validatePivotTableConfig(config: PivotTableConfig): void {
if (config.dimensions.length > MAX_PIVOT_TABLE_DIMENSIONS) {
throw new Error(
`Cannot create pivot table with ${config.dimensions.length} dimensions. ` +
`Maximum supported dimensions: ${MAX_PIVOT_TABLE_DIMENSIONS}`,
);
}
if (config.metrics.length === 0) {
throw new Error("At least one metric is required for pivot table");
}
if (config.metrics.length > MAX_PIVOT_TABLE_METRICS) {
throw new Error(
`Cannot create pivot table with ${config.metrics.length} metrics. ` +
`Maximum supported metrics: ${MAX_PIVOT_TABLE_METRICS}`,
);
}
if (config.rowLimit !== undefined && config.rowLimit <= 0) {
throw new Error("Row limit must be a positive number");
}
}
/**
* Generates a unique row ID for pivot table rows
* Uses dimension values and row type to create stable identifiers
*
* @param dimensionValues - Values for each dimension field
* @param type - Type of row (data, subtotal, total)
* @param level - Indentation level for the row
* @returns Unique string identifier for the row
*/
export function generateRowId(
dimensionValues: Record<string, string>,
type: PivotTableRow["type"],
level: number,
): string {
const valueKey = Object.entries(dimensionValues)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}:${value}`)
.join("|");
return `${type}-${level}-${valueKey}`;
}
/**
* Creates an empty metric values object with all metrics set to 0
* Used as a fallback for rows with missing data
*
* @param metrics - Array of metric field names
* @returns Object with all metrics initialized to 0
*/
export function createEmptyMetricValues(
metrics: string[],
): Record<string, number> {
return metrics.reduce(
(acc, metric) => {
acc[metric] = 0;
return acc;
},
{} as Record<string, number>,
);
}
/**
* Recursively processes dimensions to create nested pivot table structure
* This function handles N dimensions dynamically instead of hardcoded cases
*
* @param data - Array of database rows to process at this level
* @param remainingDimensions - Array of dimension names still to be processed
* @param metrics - Array of metric field names
* @param currentLevel - Current indentation level (0-based)
* @param dimensionPath - Array of dimension values from parent levels for labeling
* @returns Array of pivot table rows for this level and all nested levels
*/
function processLevelRecursively(
data: DatabaseRow[],
remainingDimensions: string[],
metrics: string[],
totalDimensions: number,
dimensionPath: string[],
): PivotTableRow[] {
const rows: PivotTableRow[] = [];
const currentDimensionIndex = totalDimensions - remainingDimensions.length;
// Base case: no more dimensions to process, create data rows
if (remainingDimensions.length === 0) {
// Data rows should be at level (totalDimensions - 1) for proper indentation
const dataLevel = Math.max(0, totalDimensions - 1);
// Create data rows for the final level
const dataRows = data.map((row, index) => {
const dimensionValues = extractDimensionValues(row, []);
const metricValues = extractMetricValues(row, metrics);
// Create label from dimension path (all parent dimension values)
const label =
dimensionPath.length > 0 ? dimensionPath.join(" - ") : "Data";
return {
id: `data-${dataLevel}-${dimensionPath.join("-")}-${index}`,
type: "data" as const,
level: dataLevel,
label,
values: metricValues,
dimensionValues,
};
});
return dataRows;
}
// Recursive case: process current dimension and recurse on remaining dimensions
const [currentDimension, ...nextDimensions] = remainingDimensions;
const groups = groupDataByDimension(data, currentDimension!);
const sortedGroups = Object.entries(groups).sort(([a], [b]) =>
a.localeCompare(b),
);
for (const [dimensionValue, groupData] of sortedGroups) {
const newDimensionPath = [...dimensionPath, dimensionValue];
// Add subtotal row for this dimension group BEFORE processing child rows
// Only if there are more dimensions to process (not the deepest level)
if (nextDimensions.length > 0 && groupData.length > 0) {
const subtotalValues = calculateSubtotals(groupData, metrics);
const subtotalRow = createSubtotalRow(
dimensionValue,
subtotalValues,
currentDimensionIndex, // Subtotals at current dimension level
);
rows.push(subtotalRow);
}
// Recursively process remaining dimensions for this group
const childRows = processLevelRecursively(
groupData,
nextDimensions,
metrics,
totalDimensions,
newDimensionPath,
);
rows.push(...childRows);
}
return rows;
}
/**
* Transforms flat query results into nested pivot table structure with totals and subtotals
* Uses a recursive algorithm to handle N dimensions dynamically instead of hardcoded cases
*
* Features:
* - Supports any number of dimensions up to MAX_PIVOT_TABLE_DIMENSIONS
* - Creates subtotals at each dimension level except the deepest
* - Proper indentation and nesting for hierarchical data
* - Grand total calculation across all data
* - Row limiting applied before processing for performance
*
* Algorithm:
* 1. Recursively groups data by each dimension in order
* 2. At each level, processes remaining dimensions for nested structure
* 3. Adds subtotals for non-leaf dimension groups
* 4. Creates data rows at the deepest level with full dimension path
* 5. Appends grand total row at the end
*
* @param data - Array of raw database rows from query
* @param config - Configuration for pivot table generation
* @returns Array of processed rows ready for table rendering
*
* @example
* ```typescript
* const data = [
* { userId: "user1", country: "US", count: 10, avg_score: 85 },
* { userId: "user2", country: "US", count: 5, avg_score: 90 },
* { userId: "user3", country: "CA", count: 8, avg_score: 78 }
* ];
*
* const config = {
* dimensions: ["country", "userId"],
* metrics: ["count", "avg_score"],
* rowLimit: 20
* };
*
* const result = transformToPivotTable(data, config);
* // Returns:
* // 1. Data rows for each user grouped by country
* // 2. Subtotal rows for each country
* // 3. Grand total row for all data
* ```
*/
export function transformToPivotTable(
data: DatabaseRow[],
config: PivotTableConfig,
): PivotTableRow[] {
// Validate configuration
validatePivotTableConfig(config);
const { dimensions, metrics, rowLimit = DEFAULT_ROW_LIMIT } = config;
// Handle empty data
if (data.length === 0) {
return [createGrandTotalRow(metrics, createEmptyMetricValues(metrics))];
}
// Handle zero dimensions - just return grand total
if (dimensions.length === 0) {
const grandTotalValues = calculateGrandTotals(data, metrics);
return [createGrandTotalRow(metrics, grandTotalValues)];
}
// Apply row limit to data before processing
const limitedData = data.slice(0, rowLimit);
// Add grand total row at the beginning
const grandTotalValues = calculateGrandTotals(limitedData, metrics);
const grandTotalRow = createGrandTotalRow(metrics, grandTotalValues);
// Process dimensions recursively
const pivotRows = processLevelRecursively(
limitedData,
dimensions,
metrics,
dimensions.length, // total number of dimensions
[], // dimension path for labeling
);
return [grandTotalRow, ...pivotRows];
}
/**
* Utility function to get dimension values from a database row
* Extracts only the specified dimension fields from the row data
*
* @param row - Database row containing all field values
* @param dimensions - Array of dimension field names to extract
* @returns Object containing only dimension field values
*/
export function extractDimensionValues(
row: DatabaseRow,
dimensions: string[],
): Record<string, string> {
return dimensions.reduce(
(acc, dimension) => {
const value = row[dimension];
acc[dimension] = value?.toString() ?? "";
return acc;
},
{} as Record<string, string>,
);
}
/**
* Utility function to get metric values from a database row
* Extracts only the specified metric fields from the row data
*
* @param row - Database row containing all field values
* @param metrics - Array of metric field names to extract
* @returns Object containing only metric field values
*/
export function extractMetricValues(
row: DatabaseRow,
metrics: string[],
): Record<string, number> {
return metrics.reduce(
(acc, metric) => {
const value = row[metric];
if (typeof value === "number") {
acc[metric] = value;
} else if (typeof value === "string") {
const parsedValue = parseFloat(value);
acc[metric] = isNaN(parsedValue) ? 0 : parsedValue;
} else {
acc[metric] = 0;
}
return acc;
},
{} as Record<string, number>,
);
}
/**
* Type guard to check if a row is a data row (not subtotal or total)
*
* @param row - Pivot table row to check
* @returns True if the row contains actual data, false for summary rows
*/
export function isDataRow(row: PivotTableRow): boolean {
return row.type === "data";
}
/**
* Type guard to check if a row is a subtotal row
*
* @param row - Pivot table row to check
* @returns True if the row is a subtotal, false otherwise
*/
export function isSubtotalRow(row: PivotTableRow): boolean {
return row.type === "subtotal" || row.isSubtotal === true;
}
/**
* Type guard to check if a row is the grand total row
*
* @param row - Pivot table row to check
* @returns True if the row is the grand total, false otherwise
*/
export function isTotalRow(row: PivotTableRow): boolean {
return row.type === "total" || row.isTotal === true;
}
/**
* Groups data by a single dimension field
* Used for creating single-level groupings in pivot table
*
* @param data - Array of database rows to group
* @param dimensionField - Field name to group by
* @returns Object with dimension values as keys and arrays of rows as values
*/
export function groupDataByDimension(
data: DatabaseRow[],
dimensionField: string,
): Record<string, DatabaseRow[]> {
return data.reduce(
(acc, row) => {
const dimensionValue =
(row[dimensionField]?.toString() ?? "").trim() || "n/a";
if (!acc[dimensionValue]) {
acc[dimensionValue] = [];
}
acc[dimensionValue]!.push(row);
return acc;
},
{} as Record<string, DatabaseRow[]>,
);
}
/**
* Detects the aggregation type from a metric field name
* Parses field names like "count_count", "avg_latency", "sum_tokens", "p95_duration"
*
* @param metricName - The metric field name to analyze
* @returns The aggregation type (count, sum, avg, min, max, p95, etc.)
*/
function detectAggregationType(metricName: string): string {
// Extract the aggregation prefix from field names like "count_count", "avg_latency"
const parts = metricName.split("_");
if (parts.length >= 2) {
const prefix = parts[0]?.toLowerCase();
// Map common aggregation prefixes
switch (prefix) {
case "count":
return "count";
case "sum":
return "sum";
case "avg":
case "average":
return "avg";
case "min":
return "min";
case "max":
return "max";
case "p95":
case "p99":
case "p50":
return "percentile";
default:
// Default to sum for unknown aggregations
return "sum";
}
}
// Default to sum if we can't determine the type
return "sum";
}
/**
* Applies the correct aggregation function based on the aggregation type
*
* @param values - Array of numeric values to aggregate
* @param aggregationType - The type of aggregation to perform
* @returns The aggregated result
*/
function applyAggregation(values: number[], aggregationType: string): number {
if (values.length === 0) return 0;
switch (aggregationType) {
case "count":
case "sum":
return values.reduce((sum, val) => sum + val, 0);
case "avg":
return values.reduce((sum, val) => sum + val, 0) / values.length;
case "min":
return Math.min(...values);
case "max":
return Math.max(...values);
case "percentile":
// For percentiles in subtotals/totals, we'll use the average of the percentile values
// This is a reasonable approximation since we can't recalculate the true percentile
return values.reduce((sum, val) => sum + val, 0) / values.length;
default:
// Default to sum
return values.reduce((sum, val) => sum + val, 0);
}
}
/**
* Calculates subtotals for a group of data rows
* Aggregates metric values across all rows in the group using the correct aggregation function
*
* @param data - Array of database rows to calculate subtotals for
* @param metrics - Array of metric field names to aggregate
* @returns Object with metric names as keys and calculated totals as values
*/
export function calculateSubtotals(
data: DatabaseRow[],
metrics: string[],
): Record<string, number> {
const subtotals: Record<string, number> = {};
for (const metric of metrics) {
// Extract all values for this metric using the utility function
const values = data
.map((row) => extractMetricValues(row, [metric])[metric])
.filter(isNotNullOrUndefined);
// Detect aggregation type and apply correct function
const aggregationType = detectAggregationType(metric);
const result = applyAggregation(values, aggregationType);
// Round to 10 decimal places to avoid floating-point precision issues
subtotals[metric] = Math.round(result * 1e10) / 1e10;
}
return subtotals;
}
/**
* Calculates grand totals across all data rows
* Aggregates metric values across the entire dataset
*
* @param data - Array of database rows to calculate grand totals for
* @param metrics - Array of metric field names to aggregate
* @returns Object with metric names as keys and calculated grand totals as values
*/
export function calculateGrandTotals(
data: DatabaseRow[],
metrics: string[],
): Record<string, number> {
return calculateSubtotals(data, metrics);
}
/**
* Creates a subtotal row for a specific dimension group
* Generates subtotal row with appropriate styling and values
*
* @param dimensionValue - The dimension value this subtotal represents
* @param subtotalValues - Calculated subtotal values for metrics
* @param level - Indentation level for the row
* @returns Formatted pivot table subtotal row
*/
export function createSubtotalRow(
dimensionValue: string,
subtotalValues: Record<string, number>,
level: number,
): PivotTableRow {
const dimensionValues = { subtotal: dimensionValue };
return {
id: generateRowId(dimensionValues, "subtotal", level),
type: "subtotal",
level,
label: `${dimensionValue} (Subtotal)`,
values: subtotalValues,
isSubtotal: true,
dimensionValues,
};
}
/**
* Creates the grand total row for the pivot table
* Generates final total row with all metric grand totals
*
* @param metrics - Array of metric field names
* @param grandTotalValues - Calculated grand total values for metrics
* @returns Formatted pivot table grand total row
*/
export function createGrandTotalRow(
metrics: string[],
grandTotalValues: Record<string, number>,
): PivotTableRow {
const dimensionValues = { total: "grand" };
return {
id: generateRowId(dimensionValues, "total", 0),
type: "total",
level: 0,
label: "Total",
values: grandTotalValues,
isTotal: true,
dimensionValues,
};
}
+2 -2
View File
@@ -195,7 +195,7 @@ export function SSOButtons({
loading={providerSigningIn === "github"}
>
<FaGithub className="mr-3" size={18} />
Github
GitHub
</Button>
)}
{authProviders.githubEnterprise && (
@@ -205,7 +205,7 @@ export function SSOButtons({
loading={providerSigningIn === "github-enterprise"}
>
<FaGithub className="mr-3" size={18} />
Github Enterprise
GitHub Enterprise
</Button>
)}
{authProviders.gitlab && (
@@ -7,6 +7,7 @@ import { showSuccessToast } from "@/src/features/notifications/showSuccessToast"
import { type DashboardWidgetChartType } from "@langfuse/shared/src/db";
import { type views, type metricAggregations } from "@/src/features/query";
import { type z } from "zod/v4";
import { type WidgetChartConfig } from "@/src/features/widgets/utils";
export default function EditWidget() {
const router = useRouter();
@@ -58,7 +59,7 @@ export default function EditWidget() {
metrics: { measure: string; agg: string }[];
filters: any[];
chartType: DashboardWidgetChartType;
chartConfig: { type: DashboardWidgetChartType; row_limit?: number; bins?: number };
chartConfig: WidgetChartConfig;
}) => {
if (!widgetId) return;
@@ -98,6 +99,10 @@ export default function EditWidget() {
name: widgetData.name,
description: widgetData.description,
view: widgetData.view as z.infer<typeof views>,
// Pass complete arrays for editing mode
metrics: widgetData.metrics,
dimensions: widgetData.dimensions,
// Keep single values for backward compatibility and fallbacks
dimension: widgetData.dimensions.slice().shift()?.field ?? "none",
measure: widgetData.metrics.slice().shift()?.measure ?? "count",
aggregation:
@@ -1,7 +1,7 @@
import { useRouter } from "next/router";
import Page from "@/src/components/layouts/page";
import { api } from "@/src/utils/api";
import { WidgetForm } from "@/src/features/widgets";
import { type WidgetChartConfig, WidgetForm } from "@/src/features/widgets";
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
import { showErrorToast } from "@/src/features/notifications/showErrorToast";
import { type DashboardWidgetChartType } from "@langfuse/shared/src/db";
@@ -49,7 +49,7 @@ export default function NewWidget() {
metrics: { measure: string; agg: string }[];
filters: any[];
chartType: DashboardWidgetChartType;
chartConfig: { type: DashboardWidgetChartType; row_limit?: number; bins?: number };
chartConfig: WidgetChartConfig;
}) => {
if (!widgetData.name.trim()) {
showErrorToast("Error", "Widget name is required");
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "worker",
"version": "3.76.0",
"version": "3.78.2",
"description": "",
"license": "MIT",
"private": true,
@@ -13,7 +13,7 @@
"start": "dotenv -e ../.env -- node dist/index.js",
"build": "tsc",
"dev": "dotenv -e ../.env -- nodemon src/index.ts",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 21",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 0",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
"refill-ingestion-events": "dotenv -e ../.env -- tsx src/scripts/s3-ingestion-event-relpay.ts",
"refill-billing-event": "dotenv -e ../.env -- tsx src/scripts/refill-billing-event.ts"
@@ -313,6 +313,187 @@ describe("create experiment jobs", () => {
}, 10_000);
});
describe("create experiment jobs with placeholders", () => {
const setupPlaceholderTest = async (promptConfig: any, datasetItemInput: any) => {
await pruneDatabase();
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
const datasetId = randomUUID();
const runId = randomUUID();
const promptId = "03f834cc-c089-4bcb-9add-b14cadcdf47c";
// Create prompt
await prisma.prompt.create({
data: {
id: promptId,
projectId,
name: promptConfig.name,
prompt: promptConfig.prompt,
type: "chat",
version: 1,
createdBy: "test-user",
},
});
// Create dataset
await prisma.dataset.create({
data: {
id: datasetId,
projectId,
name: "Test Dataset",
},
});
// Create dataset run with metadata
await kyselyPrisma.$kysely
.insertInto("dataset_runs")
.values({
id: runId,
name: "Test Run",
project_id: projectId,
dataset_id: datasetId,
metadata: {
prompt_id: promptId,
provider: "openai",
model: "gpt-3.5-turbo",
model_params: { temperature: 0 },
},
})
.execute();
// Create dataset item
await prisma.datasetItem.create({
data: {
id: randomUUID(),
projectId,
datasetId,
input: datasetItemInput,
},
});
// Create API key
await prisma.llmApiKeys.create({
data: {
id: randomUUID(),
projectId,
provider: "openai",
adapter: LLMAdapter.OpenAI,
displaySecretKey: "test-key",
secretKey: encrypt("test-key"),
},
});
return { projectId, datasetId, runId };
};
test("creates experiment job with multiple placeholders containing variables", async () => {
const { projectId, datasetId, runId } = await setupPlaceholderTest(
{
name: "Test Multiple Placeholders",
prompt: [
{ role: "system", content: "You are a helpful assistant." },
{ type: "placeholder", name: "conversation_history" },
{ type: "placeholder", name: "user_context" },
{ role: "user", content: "Please help me." }
]
},
{
conversation_history: [
{ role: "user", content: "Hello {{name}}!" },
{ role: "assistant", content: "Hi there!" }
],
user_context: [
{ role: "system", content: "User is a {{role}}" }
],
name: "John",
role: "developer"
}
);
const payload = {
projectId,
datasetId,
runId,
};
await createExperimentJob({ event: payload });
const runItems = await kyselyPrisma.$kysely
.selectFrom("dataset_run_items")
.selectAll()
.where("project_id", "=", projectId)
.execute();
expect(runItems.length).toBe(1);
expect(runItems[0].project_id).toBe(projectId);
expect(runItems[0].dataset_run_id).toBe(runId);
expect(runItems[0].trace_id).toBeDefined();
}, 10_000);
test("handles empty placeholder arrays", async () => {
const { projectId, datasetId, runId } = await setupPlaceholderTest(
{
name: "Test Empty Placeholder",
prompt: [
{ role: "system", content: "You are a helpful assistant." },
{ type: "placeholder", name: "empty_history" },
{ role: "user", content: "Start conversation." }
]
},
{ empty_history: [] }
);
const payload = {
projectId,
datasetId,
runId,
};
await createExperimentJob({ event: payload });
const runItems = await kyselyPrisma.$kysely
.selectFrom("dataset_run_items")
.selectAll()
.where("project_id", "=", projectId)
.execute();
expect(runItems.length).toBe(1);
expect(runItems[0].project_id).toBe(projectId);
expect(runItems[0].dataset_run_id).toBe(runId);
expect(runItems[0].trace_id).toBeDefined();
}, 10_000);
test("fails when placeholder has invalid message format", async () => {
const { projectId, datasetId, runId } = await setupPlaceholderTest(
{
name: "Test Invalid Placeholder",
prompt: [
{ role: "system", content: "You are a helpful assistant." },
{ type: "placeholder", name: "invalid_messages" },
{ role: "user", content: "Help me." }
]
},
{ invalid_messages: "this should be an array or object" }
);
const payload = {
projectId,
datasetId,
runId,
};
await createExperimentJob({ event: payload });
// Should not create run items for invalid placeholder format
const runItems = await kyselyPrisma.$kysely
.selectFrom("dataset_run_items")
.selectAll()
.where("project_id", "=", projectId)
.execute();
expect(runItems.length).toBe(0);
}, 10_000);
});
describe("create experiment job calls with langfuse server side tracing", async () => {
await pruneDatabase();
const mockEvent = {
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.76.0";
export const VERSION = "v3.78.2";
+1 -1
View File
@@ -232,6 +232,6 @@ const EnvSchema = z.object({
});
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));
@@ -49,6 +49,7 @@ import {
TraceDomain,
Observation,
DatasetItem,
QUEUE_ERROR_MESSAGES,
} from "@langfuse/shared";
import { kyselyPrisma, prisma } from "@langfuse/shared/src/db";
import { backOff } from "exponential-backoff";
@@ -877,11 +878,11 @@ export async function extractVariablesFromTracingData({
// user facing errors
if (!observation) {
logger.warn(
`Observation ${mapping.objectName} for trace ${traceId} not found. Please ensure the mapped data exists and consider extending the job delay.`,
`Observation ${mapping.objectName} for trace ${traceId} not found. ${QUEUE_ERROR_MESSAGES.MAPPED_DATA_ERROR}`,
);
// this should only happen for deleted data or data replication lags across clickhouse nodes.
throw new LangfuseNotFoundError(
`Observation ${mapping.objectName} for trace ${traceId} not found. Please ensure the mapped data exists and consider extending the job delay.`,
`Observation ${mapping.objectName} for trace ${traceId} not found. ${QUEUE_ERROR_MESSAGES.MAPPED_DATA_ERROR}`,
);
}
@@ -18,11 +18,13 @@ import {
import { kyselyPrisma, prisma } from "@langfuse/shared/src/db";
import { type ExperimentCreateEventSchema } from "@langfuse/shared/src/server";
import {
datasetItemMatchesVariable,
extractVariables,
InvalidRequestError,
LangfuseNotFoundError,
type Prisma,
extractVariables,
datasetItemMatchesVariable,
PromptType,
QUEUE_ERROR_MESSAGES,
stringifyValue,
} from "@langfuse/shared";
import { backOff } from "exponential-backoff";
@@ -45,11 +47,14 @@ const replaceVariablesInPrompt = (
prompt: PromptContent,
itemInput: Record<string, any>,
variables: string[],
placeholderNames: string[] = [],
): ChatMessage[] => {
const processContent = (content: string) => {
// Extract only relevant variables from itemInput
// Extract only Handlebars variables from itemInput (exclude message placeholders)
const filteredContext = Object.fromEntries(
Object.entries(itemInput).filter(([key]) => variables.includes(key)),
Object.entries(itemInput).filter(
([key]) => variables.includes(key) && !placeholderNames.includes(key),
),
);
// Apply Handlebars ONLY if the content contains `{{variable}}` pattern
@@ -70,42 +75,47 @@ const replaceVariablesInPrompt = (
];
}
const placeholderNames = extractPlaceholderNames(prompt as PromptMessage[]);
const placeholderValues: MessagePlaceholderValues = {};
// itemInput to placeholderValues
for (const placeholderName of placeholderNames) {
if (!(placeholderName in itemInput)) {
// TODO: handle missing placeholder values
// throw new Error(`Missing placeholder value for '${placeholderName}'`);
continue;
// TODO: should we throw?
throw new Error(`Missing placeholder value for '${placeholderName}'`);
}
const value = itemInput[placeholderName];
// for stringified arrays (e.g. from dataset processing)
let actualValue = value;
if (typeof value === 'string') {
if (typeof value === "string") {
try {
actualValue = JSON.parse(value);
} catch (_e) {
throw new Error(`Invalid placeholder value for '${placeholderName}': unable to parse JSON`);
throw new Error(
`Invalid placeholder value for '${placeholderName}': unable to parse JSON`,
);
}
}
if (!Array.isArray(actualValue)) {
throw new Error(`Placeholder '${placeholderName}' must be an array of messages`);
throw new Error(
`Placeholder '${placeholderName}' must be an array of messages`,
);
}
const validMessages = actualValue.every(msg =>
typeof msg === 'object' &&
msg !== null &&
'role' in msg &&
'content' in msg
const validMessages = actualValue.every(
(msg) =>
typeof msg === "object" &&
msg !== null &&
"role" in msg &&
"content" in msg,
);
if (!validMessages) {
throw new Error(`Invalid placeholder value for '${placeholderName}': messages must have 'role' and 'content' properties`);
throw new Error(
`Invalid placeholder value for '${placeholderName}': messages must have 'role' and 'content' properties`,
);
}
placeholderValues[placeholderName] = actualValue.map(msg => ({
placeholderValues[placeholderName] = actualValue.map((msg) => ({
...msg,
type: ChatMessageType.PublicAPICreated as const,
}));
@@ -114,11 +124,9 @@ const replaceVariablesInPrompt = (
const compiledMessages = compileChatMessages(
prompt as PromptMessage[],
placeholderValues,
{}
{},
);
// TODO: validate correctness
// handlebars variable substitution to all messages
return compiledMessages.map((message) => ({
...message,
content: processContent(message.content),
@@ -227,13 +235,13 @@ export const createExperimentJob = async ({
});
if (!apiKey) {
throw new LangfuseNotFoundError(
`API key for provider ${provider} not found`,
`${QUEUE_ERROR_MESSAGES.API_KEY_ERROR} ${provider} not found`,
);
}
const validatedApiKey = LLMApiKeySchema.safeParse(apiKey);
if (!validatedApiKey.success) {
throw new InvalidRequestError(
`API key for provider ${provider} not found.`,
`${QUEUE_ERROR_MESSAGES.API_KEY_ERROR} ${provider} not found.`,
);
}
@@ -251,15 +259,16 @@ export const createExperimentJob = async ({
// extract variables from prompt
const extractedVariables = extractVariables(
prompt?.type === "text"
prompt?.type === PromptType.Text
? (prompt.prompt?.toString() ?? "")
: JSON.stringify(prompt.prompt),
);
// also extract placeholder names if prompt is an array
const placeholderNames = prompt?.type !== "text" && Array.isArray(validatedPrompt.data)
? extractPlaceholderNames(validatedPrompt.data as PromptMessage[])
: [];
// also extract placeholder names if prompt is a chat prompt
const placeholderNames =
prompt?.type === PromptType.Chat && Array.isArray(validatedPrompt.data)
? extractPlaceholderNames(validatedPrompt.data as PromptMessage[])
: [];
const allVariables = [...extractedVariables, ...placeholderNames];
// validate dataset items against prompt configuration
@@ -275,7 +284,7 @@ export const createExperimentJob = async ({
if (!validatedDatasetItems.length) {
throw new InvalidRequestError(
`No Dataset ${datasetId} item input matches expected prompt variable format`,
`No Dataset ${datasetId} item input matches expected prompt variables or placeholders format`,
);
}
@@ -305,7 +314,8 @@ export const createExperimentJob = async ({
messages = replaceVariablesInPrompt(
validatedPrompt.data,
datasetItem.input, // validated format
extractedVariables,
allVariables,
placeholderNames,
);
} catch (error) {
// skip this dataset item if there is an error replacing variables
+14 -11
View File
@@ -1,5 +1,10 @@
import { Job } from "bullmq";
import { ApiError, BaseError, LangfuseNotFoundError } from "@langfuse/shared";
import {
ApiError,
BaseError,
LangfuseNotFoundError,
QUEUE_ERROR_MESSAGES,
} from "@langfuse/shared";
import { kyselyPrisma } from "@langfuse/shared/src/db";
import { sql } from "kysely";
import {
@@ -150,22 +155,20 @@ export const evalJobExecutorQueueProcessor = async (
e instanceof LangfuseNotFoundError ||
(e instanceof BaseError &&
e.message.includes(
"Could not parse response content as the length limit was reached",
QUEUE_ERROR_MESSAGES.OUTPUT_TOKENS_TOO_LONG_ERROR,
)) || // output tokens too long
(e instanceof BaseError && e.message.includes("API key for provider")) || // api key not provided
(e instanceof BaseError &&
e.message.includes(
"`No default model or custom model found for project",
)) || // api key not provided
e.message.includes(QUEUE_ERROR_MESSAGES.API_KEY_ERROR)) || // api key not provided
(e instanceof BaseError &&
e.message.includes(QUEUE_ERROR_MESSAGES.NO_DEFAULT_MODEL_ERROR)) || // api key not provided
(e instanceof ApiError && e.httpCode >= 400 && e.httpCode < 500) || // do not error and retry on 4xx errors. They are visible to the user in the UI but do not alert us.
(e instanceof ApiError && e.message.includes("TypeError")) || // Zod parsing the response failed. User should update prompt to consistently return expected output structure.
(e instanceof ApiError &&
e.message.includes("Error: Unterminated string in JSON at position")) || // When evaluator model is configured with too low max_tokens, the structured output response is invalid JSON
(e instanceof ApiError && e.message.includes("is not valid JSON")) || // When evaluator model is not consistently returning valid JSON on structured output calls
e.message.includes(QUEUE_ERROR_MESSAGES.TOO_LOW_MAX_TOKENS_ERROR)) || // When evaluator model is configured with too low max_tokens, the structured output response is invalid JSON
(e instanceof ApiError &&
e.message.includes(QUEUE_ERROR_MESSAGES.INVALID_JSON_ERROR)) || // When evaluator model is not consistently returning valid JSON on structured output calls
(e instanceof BaseError &&
e.message.includes(
"Please ensure the mapped data exists and consider extending the job delay.",
)) // Trace not found.
e.message.includes(QUEUE_ERROR_MESSAGES.MAPPED_DATA_ERROR)) // Trace not found.
) {
return;
}
@@ -26,7 +26,7 @@ export class ClickhouseWriter {
queue: ClickhouseQueue;
isIntervalFlushInProgress: boolean;
intervalId: NodeJS.Timeout | null = null;
intervalId: NodeJS.Timeout | null = null; // eslint-disable-line no-undef
private constructor() {
this.batchSize = env.LANGFUSE_INGESTION_CLICKHOUSE_WRITE_BATCH_SIZE;
@@ -239,11 +239,11 @@ export class ClickhouseWriter {
}
export enum TableName {
Traces = "traces",
TracesMt = "traces_mt",
Scores = "scores",
Observations = "observations",
BlobStorageFileLog = "blob_storage_file_log",
Traces = "traces", // eslint-disable-line no-unused-vars
TracesMt = "traces_mt", // eslint-disable-line no-unused-vars
Scores = "scores", // eslint-disable-line no-unused-vars
Observations = "observations", // eslint-disable-line no-unused-vars
BlobStorageFileLog = "blob_storage_file_log", // eslint-disable-line no-unused-vars
}
type RecordInsertType<T extends TableName> = T extends TableName.Scores
+146 -9
View File
@@ -71,7 +71,11 @@ CREATE TABLE traces_all_amt
`name` SimpleAggregateFunction(anyLast, String),
-- Metadata properties
`metadata` AggregateFunction(argMax, Map(LowCardinality(String), String), DateTime64(3)),
-- Metadata takes the last seen value in this form.
-- If we spread it across multiple rows (keys + values) we might be able to allow updates/overwrites.
-- Indexing speed in this case is unclear though.
`metadata_argmax` AggregateFunction(argMax, Map(LowCardinality(String), String), DateTime64(3)),
`metadata` SimpleAggregateFunction(anyLast, Map(String, String)),
`user_id_argmax` AggregateFunction(argMax, String, DateTime64(3)),
`session_id_argmax` AggregateFunction(argMax, String, DateTime64(3)),
`environment_argmax` AggregateFunction(argMax, String, DateTime64(3)),
@@ -121,14 +125,15 @@ SELECT
anyLast(t0.name) as name,
-- Metadata properties
argMaxState(t0.metadata, t0.event_ts) as metadata,
argMaxState(t0.metadata, if(t0.metadata <> '{}', t0.event_ts, toDateTime64(0,3))) as metadata_argmax,
anyLastMap(t0.metadata) as metadata,
argMaxState(t0.user_id, if(t0.user_id <> '', t0.event_ts, toDateTime64(0, 3))) as user_id_argmax,
argMaxState(t0.session_id, if(t0.session_id <> '', t0.event_ts, toDateTime64(0, 3))) as session_id_argmax,
argMaxState(t0.environment, if(t0.environment <> '', t0.event_ts, toDateTime64(0, 3))) as environment_argmax,
anyLast(t0.user_id) as user_id,
anyLast(t0.session_id) as session_id,
anyLast(t0.environment) as environment,
groupUniqArrayArray(t0.tags) as tags,
groupUniqArrayArray(t0.tags) as tags,
argMaxState(t0.version, if(t0.version <> '', t0.event_ts, toDateTime64(0, 3))) as version,
argMaxState(t0.release, if(t0.release <> '', t0.event_ts, toDateTime64(0, 3))) as release,
@@ -165,7 +170,8 @@ CREATE TABLE traces_7d_amt
`name` SimpleAggregateFunction(anyLast, String),
-- Metadata properties
`metadata` AggregateFunction(argMax, Map(LowCardinality(String), String), DateTime64(3)),
`metadata_argmax` AggregateFunction(argMax, Map(LowCardinality(String), String), DateTime64(3)),
`metadata` SimpleAggregateFunction(anyLast, Map(String, String)),
`user_id_argmax` AggregateFunction(argMax, String, DateTime64(3)),
`session_id_argmax` AggregateFunction(argMax, String, DateTime64(3)),
`environment_argmax` AggregateFunction(argMax, String, DateTime64(3)),
@@ -215,14 +221,15 @@ SELECT
anyLast(t0.name) as name,
-- Metadata properties
argMaxState(t0.metadata, t0.event_ts) as metadata,
argMaxState(t0.metadata, if(t0.metadata <> '{}', t0.event_ts, toDateTime64(0,3))) as metadata_argmax,
anyLastMap(t0.metadata) as metadata,
argMaxState(t0.user_id, if(t0.user_id <> '', t0.event_ts, toDateTime64(0, 3))) as user_id_argmax,
argMaxState(t0.session_id, if(t0.session_id <> '', t0.event_ts, toDateTime64(0, 3))) as session_id_argmax,
argMaxState(t0.environment, if(t0.environment <> '', t0.event_ts, toDateTime64(0, 3))) as environment_argmax,
anyLast(t0.user_id) as user_id,
anyLast(t0.session_id) as session_id,
anyLast(t0.environment) as environment,
groupUniqArrayArray(t0.tags) as tags,
groupUniqArrayArray(t0.tags) as tags,
argMaxState(t0.version, if(t0.version <> '', t0.event_ts, toDateTime64(0, 3))) as version,
argMaxState(t0.release, if(t0.release <> '', t0.event_ts, toDateTime64(0, 3))) as release,
@@ -259,7 +266,8 @@ CREATE TABLE traces_30d_amt
`name` SimpleAggregateFunction(anyLast, String),
-- Metadata properties
`metadata` AggregateFunction(argMax, Map(LowCardinality(String), String), DateTime64(3)),
`metadata_argmax` AggregateFunction(argMax, Map(LowCardinality(String), String), DateTime64(3)),
`metadata` SimpleAggregateFunction(anyLast, Map(String, String)),
`user_id_argmax` AggregateFunction(argMax, String, DateTime64(3)),
`session_id_argmax` AggregateFunction(argMax, String, DateTime64(3)),
`environment_argmax` AggregateFunction(argMax, String, DateTime64(3)),
@@ -309,14 +317,15 @@ SELECT
anyLast(t0.name) as name,
-- Metadata properties
argMaxState(t0.metadata, t0.event_ts) as metadata,
argMaxState(t0.metadata, if(t0.metadata <> '{}', t0.event_ts, toDateTime64(0,3))) as metadata_argmax,
anyLastMap(t0.metadata) as metadata,
argMaxState(t0.user_id, if(t0.user_id <> '', t0.event_ts, toDateTime64(0, 3))) as user_id_argmax,
argMaxState(t0.session_id, if(t0.session_id <> '', t0.event_ts, toDateTime64(0, 3))) as session_id_argmax,
argMaxState(t0.environment, if(t0.environment <> '', t0.event_ts, toDateTime64(0, 3))) as environment_argmax,
anyLast(t0.user_id) as user_id,
anyLast(t0.session_id) as session_id,
anyLast(t0.environment) as environment,
groupUniqArrayArray(t0.tags) as tags,
groupUniqArrayArray(t0.tags) as tags,
argMaxState(t0.version, if(t0.version <> '', t0.event_ts, toDateTime64(0, 3))) as version,
argMaxState(t0.release, if(t0.release <> '', t0.event_ts, toDateTime64(0, 3))) as release,
@@ -342,6 +351,8 @@ FROM traces_mt t0
GROUP BY project_id, id;
```
## Query AggregatingMergeTrees
We can query the properties of the resulting AggregatingMergeTree via (make sure to pick the right timeframe:
```sql
SELECT
@@ -386,3 +397,129 @@ SELECT
FROM traces_all_amt
LIMIT 100;
```
## Find discrepancies
We can identify discrepancies in the original and the new data using
```sql
-- Query to compare traces_all_amt with traces table and identify discrepancies
WITH amt_data AS (
-- First get the finalized values from the AMT table
SELECT project_id,
id,
start_time,
end_time,
finalizeAggregation(name_argmax) AS name_argmax_value,
name AS name_anylast_value,
finalizeAggregation(metadata_argmax) AS metadata_argmax_value,
metadata AS metadata_anylast_value,
finalizeAggregation(user_id_argmax) AS user_id_argmax_value,
user_id AS user_id_anylast_value,
finalizeAggregation(session_id_argmax) AS session_id_argmax_value,
session_id AS session_id_anylast_value,
finalizeAggregation(environment_argmax) AS environment_argmax_value,
environment AS environment_anylast_value,
tags,
finalizeAggregation(version) AS version_value,
finalizeAggregation(release) AS release_value,
finalizeAggregation(bookmarked) AS bookmarked_value,
finalizeAggregation(public) AS public_value,
finalizeAggregation(input_argmax) AS input_argmax_value,
input AS input_anylast_value,
finalizeAggregation(output_argmax) AS output_argmax_value,
output AS output_anylast_value,
created_at,
updated_at
FROM traces_all_amt
)
-- Main query to compare AMT with original traces table
SELECT t.project_id,
t.id,
-- Identify differences between tables
t.timestamp != a.start_time AS timestamp_diff,
t.name != a.name_anylast_value AS name_diff,
t.user_id != a.user_id_anylast_value AS user_id_diff,
t.session_id != a.session_id_anylast_value AS session_id_diff,
t.release != a.release_value AS release_diff,
t.version != a.version_value AS version_diff,
t.public != a.public_value AS public_diff,
t.bookmarked != a.bookmarked_value AS bookmarked_diff,
arraySort(t.tags) != arraySort(a.tags) AS tags_diff,
t.input != a.input_anylast_value AS input_diff,
t.output != a.output_anylast_value AS output_diff,
t.metadata != a.metadata_anylast_value AS metadata_diff,
a.name_argmax_value != a.name_anylast_value AS amt_name_diff,
a.user_id_argmax_value != a.user_id_anylast_value AS amt_user_id_diff,
a.session_id_argmax_value != a.session_id_anylast_value AS amt_session_id_diff,
a.environment_argmax_value != a.environment_anylast_value AS amt_environment_diff,
a.input_argmax_value != a.input_anylast_value AS amt_input_diff,
a.output_argmax_value != a.output_anylast_value AS amt_output_diff,
a.metadata_argmax_value != a.metadata_anylast_value AS amt_metadata_diff,
-- Include original values for comparison
t.timestamp AS traces_timestamp,
a.start_time AS amt_start_time,
t.name AS traces_name,
a.name_anylast_value AS amt_name_anylast,
a.name_argmax_value AS amt_name_argmax,
t.user_id AS traces_user_id,
a.user_id_anylast_value AS amt_user_id_anylast,
a.user_id_argmax_value AS amt_user_id_argmax,
t.session_id AS traces_session_id,
a.session_id_anylast_value AS amt_session_id_anylast,
a.session_id_argmax_value AS amt_session_id_argmax,
t.environment AS traces_environment,
a.environment_anylast_value AS amt_environment_anylast,
a.environment_argmax_value AS amt_environment_argmax,
t.metadata AS traces_metadata,
a.metadata_anylast_value AS amt_metadata_anylast,
a.metadata_argmax_value AS amt_metadata_argmax,
arraySort(t.tags) AS traces_tags,
arraySort(a.tags) AS amt_tags,
t.bookmarked AS traces_bookmarked,
a.bookmarked_value AS amt_bookmarked,
t.public AS traces_public,
a.public_value AS amt_public,
t.release AS traces_release,
a.release_value AS amt_release,
t.version AS traces_version,
a.version_value AS amt_version,
t.input AS traces_input,
a.input_anylast_value AS amt_input_anylast,
a.input_argmax_value AS amt_input_argmax,
t.output AS traces_output,
a.output_anylast_value AS amt_output_anylast,
a.output_argmax_value AS amt_output_argmax,
timestamp_diff +
name_diff +
user_id_diff +
session_id_diff +
release_diff +
version_diff +
public_diff +
bookmarked_diff +
tags_diff +
input_diff +
output_diff +
metadata_diff
AS total_diff
FROM traces t FINAL
LEFT JOIN amt_data a ON t.project_id = a.project_id AND t.id = a.id
where (t.timestamp >= '2025-07-01'
or a.start_time >= '2025-07-01')
and t.project_id in (
'some-project-id'
)
ORDER BY total_diff desc
LIMIT 1000;
```
+111 -11
View File
@@ -93,8 +93,8 @@ export class IngestionService {
constructor(
private redis: Redis | Cluster,
private prisma: PrismaClient,
private clickHouseWriter: ClickhouseWriter,
private clickhouseClient: ClickhouseClientType,
private clickHouseWriter: ClickhouseWriter, // eslint-disable-line no-unused-vars
private clickhouseClient: ClickhouseClientType, // eslint-disable-line no-unused-vars
) {
this.promptService = new PromptService(prisma, redis);
}
@@ -220,6 +220,14 @@ export class IngestionService {
clickhouseScoreRecord?.created_at ?? createdAtTimestamp.getTime();
this.clickHouseWriter.addToQueue(TableName.Scores, finalScoreRecord);
if (
env.LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES === "true" &&
finalScoreRecord.trace_id
) {
const traceMtRecord = this.convertScoreToTraceMt(finalScoreRecord);
this.clickHouseWriter.addToQueue(TableName.TracesMt, traceMtRecord);
}
}
private async processTraceEventList(params: {
@@ -453,6 +461,14 @@ export class IngestionService {
TableName.Observations,
finalObservationRecord,
);
if (
env.LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES === "true" &&
finalObservationRecord.trace_id
) {
const traceMtRecord = this.convertObservationToTraceMt(finalObservationRecord);
this.clickHouseWriter.addToQueue(TableName.TracesMt, traceMtRecord);
}
}
private async mergeScoreRecords(params: {
@@ -543,6 +559,90 @@ export class IngestionService {
};
}
private convertObservationToTraceMt(
observationRecord: ObservationRecordInsertType,
): TraceMtRecordInsertType {
return {
// Identifiers
project_id: observationRecord.project_id,
// Use trace_id as the id in traces_mt. Always set given the conditions around calling the function
id: observationRecord.trace_id || "",
start_time: observationRecord.start_time,
end_time: observationRecord.end_time || null,
name: "",
// Metadata properties
metadata: {},
user_id: '',
session_id: '',
environment: observationRecord.environment,
tags: [],
version: null,
release: null,
// UI properties - nullable to prevent absent values being interpreted as overwrites
bookmarked: null,
public: null,
// Aggregations - include this observation ID
observation_ids: [observationRecord.id],
score_ids: [],
// We can fill the cost details here, but we shouldn't trust them.
// Only used for verification to estimate how big the double-counting is.
cost_details: observationRecord.cost_details || {},
usage_details: observationRecord.usage_details || {},
// Input/Output
input: "",
output: "",
created_at: observationRecord.created_at,
updated_at: observationRecord.updated_at,
event_ts: observationRecord.event_ts,
};
}
private convertScoreToTraceMt(
scoreRecord: ScoreRecordInsertType,
): TraceMtRecordInsertType {
return {
// Identifiers
project_id: scoreRecord.project_id,
// Use trace_id as the id in traces_mt. Always set given the conditions around calling the function
id: scoreRecord.trace_id || "",
start_time: scoreRecord.timestamp,
end_time: null, // scores don't have end_time
name: "",
// Metadata properties
metadata: {},
user_id: '',
session_id: '',
environment: scoreRecord.environment,
tags: [], // scores don't have tags
version: null, // scores don't have version
release: null, // scores don't have release
// UI properties - nullable to prevent absent values being interpreted as overwrites
bookmarked: null,
public: null,
// Aggregations - include this score ID
observation_ids: [],
score_ids: [scoreRecord.id],
cost_details: {},
usage_details: {},
// Input/Output
input: "", // scores don't have input
output: "", // scores don't have output
created_at: scoreRecord.created_at,
updated_at: scoreRecord.updated_at,
event_ts: scoreRecord.event_ts,
};
}
private async mergeObservationRecords(params: {
projectId: string;
observationRecords: ObservationRecordInsertType[];
@@ -713,7 +813,7 @@ export class IngestionService {
> {
const providedUsageDetails = Object.fromEntries(
Object.entries(observationRecord.provided_usage_details).filter(
([k, v]) => v != null && v >= 0,
([k, v]) => v != null && v >= 0, // eslint-disable-line no-unused-vars
),
);
@@ -771,7 +871,7 @@ export class IngestionService {
const { provided_cost_details } = observationRecord;
const providedCostKeys = Object.entries(provided_cost_details ?? {})
.filter(([_, value]) => value != null)
.filter(([_, value]) => value != null) // eslint-disable-line no-unused-vars
.map(([key]) => key);
// If user has provided any cost point, do not calculate any other cost points
@@ -818,7 +918,7 @@ export class IngestionService {
finalTotalCost = finalCostDetails.total;
} else if (finalCostEntries.length > 0) {
finalTotalCost = finalCostEntries.reduce(
(acc, [_, cost]) => acc + cost,
(acc, [_, cost]) => acc + cost, // eslint-disable-line no-unused-vars
0,
);
@@ -882,7 +982,7 @@ export class IngestionService {
return result;
}
private async getClickhouseRecord(params: {
private async getClickhouseRecord(params: { // eslint-disable-line no-unused-vars
projectId: string;
entityId: string;
table: TableName.Traces;
@@ -891,7 +991,7 @@ export class IngestionService {
params: Record<string, unknown>;
};
}): Promise<TraceRecordInsertType | null>;
private async getClickhouseRecord(params: {
private async getClickhouseRecord(params: { // eslint-disable-line no-unused-vars, no-dupe-class-members
projectId: string;
entityId: string;
table: TableName.Scores;
@@ -900,7 +1000,7 @@ export class IngestionService {
params: Record<string, unknown>;
};
}): Promise<ScoreRecordInsertType | null>;
private async getClickhouseRecord(params: {
private async getClickhouseRecord(params: { // eslint-disable-line no-unused-vars, no-dupe-class-members
projectId: string;
entityId: string;
table: TableName.Observations;
@@ -909,7 +1009,7 @@ export class IngestionService {
params: Record<string, unknown>;
};
}): Promise<ObservationRecordInsertType | null>;
private async getClickhouseRecord(params: {
private async getClickhouseRecord(params: { // eslint-disable-line no-dupe-class-members
projectId: string;
entityId: string;
table: TableName;
@@ -1107,7 +1207,7 @@ export class IngestionService {
...("usageDetails" in obs.body
? (Object.fromEntries(
Object.entries(obs.body.usageDetails ?? {}).filter(
([_, val]) => val != null,
([_, val]) => val != null, // eslint-disable-line no-unused-vars
),
) as Record<string, number>)
: {}),
@@ -1128,7 +1228,7 @@ export class IngestionService {
...("costDetails" in obs.body
? (Object.fromEntries(
Object.entries(obs.body.costDetails ?? {}).filter(
([_, val]) => val != null,
([_, val]) => val != null, // eslint-disable-line no-unused-vars
),
) as Record<string, number>)
: {}),
+1 -1
View File
@@ -9,7 +9,7 @@ import { WorkerManager } from "../queues/workerManager";
import { prisma } from "@langfuse/shared/src/db";
import { BackgroundMigrationManager } from "../backgroundMigrations/backgroundMigrationManager";
export const onShutdown: NodeJS.SignalsListener = async (signal) => {
export const onShutdown: NodeJS.SignalsListener = async (signal) => { // eslint-disable-line no-undef
logger.info(`Received ${signal}, closing server...`);
setSigtermReceived();