Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e1498a094 | ||
|
|
38f020e482 | ||
|
|
07e1d4e960 | ||
|
|
602d00a5bb | ||
|
|
7395bd8d83 | ||
|
|
63d164ad52 | ||
|
|
f1b915c8b7 | ||
|
|
e3886b94c1 | ||
|
|
44376c2a9f | ||
|
|
2e85c9ac15 | ||
|
|
7cc81deb65 | ||
|
|
a13ddd41ae | ||
|
|
ebe85f2a67 | ||
|
|
c7b62adbab | ||
|
|
66d4926fd0 | ||
|
|
b26f5c512f | ||
|
|
9227eaab9c | ||
|
|
3debac082a | ||
|
|
fb43e03171 | ||
|
|
dd04da64ba | ||
|
|
b94cd9880a | ||
|
|
0f2d2b8850 | ||
|
|
69db85cfe4 | ||
|
|
b445471f98 | ||
|
|
2ce014980b |
+12
-5
@@ -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 && \
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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/>
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "3.76.0",
|
||||
"version": "3.78.2",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
+3
-3
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Generated
+214
-132
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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&style=social"
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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};
|
||||
`;
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 @@
|
||||
export const VERSION = "v3.76.0";
|
||||
export const VERSION = "v3.78.2";
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
```
|
||||
@@ -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>)
|
||||
: {}),
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user