Compare commits

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

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

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

* prettier

---------

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

* push

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

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

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

* chore: update declarations for experimental traces table

* chore: change anyLast pattern to working condition

* chore: revert anyLast setup

* chore: revert

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

* add missing file

* fix experiements

* run outside of loop

* add tests

* fix tests

* make tests clean again

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

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

* drop md

* prettier

---------

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

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

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

* prettier

* fix

---------

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

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

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

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

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

* Checkpoint before follow-up message

* Fix GitHub brand name and typos across documentation and UI

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

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

---------

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

* tests for variable placeholder name collision

* fix test spelling

* also show conflict in playhground
2025-07-01 13:39:35 +00:00
70 changed files with 1651 additions and 566 deletions
+12 -5
View File
@@ -12,8 +12,15 @@ RUN apt-get update && \
postgresql-client \
redis-tools \
less nano \
sudo \
&& rm -rf /var/lib/apt/lists/*
# ---------- Docker -----------------------------------------------------------
# Install Docker for background agents that need container capabilities
RUN curl -fsSL https://get.docker.com -o get-docker.sh && \
sh get-docker.sh && \
rm get-docker.sh
# ---------- pnpm -------------------------------------------------------------
# Langfuse monorepo relies on pnpm 9.5.0 (see CONTRIBUTING.md)
ENV PNPM_HOME="/pnpm"
@@ -29,11 +36,11 @@ RUN wget -qO- "https://github.com/golang-migrate/migrate/releases/download/v${MI
chmod +x /usr/local/bin/migrate
# ---------- Non-root user -----------------------------------------------------
# Use root for convenience in development containers
WORKDIR /workspace
# Create non-root user
RUN useradd -ms /bin/bash ubuntu
# Create non-root user with sudo privileges and docker group access
RUN useradd -ms /bin/bash ubuntu && \
usermod -aG sudo ubuntu && \
usermod -aG docker ubuntu && \
echo "ubuntu ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
# Pre-create pnpm store and set correct ownership to avoid first-run cost & permission issues
RUN pnpm store path > /dev/null && \
+7 -1
View File
@@ -4,5 +4,11 @@
"context": ".",
"dockerfile": "Dockerfile"
},
"start": "pnpm dx-f"
"start": "sudo service docker start",
"terminals": [
{
"name": "dev server",
"command": "pnpm run dx-f"
}
]
}
+1 -1
View File
@@ -11,7 +11,7 @@ alwaysApply: true
## File Structure
- We generally put all code related to a net-new feature into a folder within web/src/features.
- Checkout other features to learn about the common structure.
- Check out other features to learn about the common structure.
## API for frontend features
+2 -1
View File
@@ -5,4 +5,5 @@ alwaysApply: true
---
# General rules
- Linting in this repo only works if the development server is running
- Linting in this repo only works if the development server is running
- Always run the full mono-repo via `pnpm run dx` (use `pnpm dx-f` when you run this in a background agent). Thereby the database will also be seeded.
+1 -1
View File
@@ -26,7 +26,7 @@
<a href="https://langfuse.com/roadmap"><strong>路线图</strong></a> ·
</div>
<br/>
<span>Langfuse 使用 <a href="https://github.com/orgs/langfuse/discussions"><strong>Github Discussions</strong></a> 作为支持和功能请求的平台。</span>
<span>Langfuse 使用 <a href="https://github.com/orgs/langfuse/discussions"><strong>GitHub Discussions</strong></a> 作为支持和功能请求的平台。</span>
<br/>
<span><b>我们正在招聘。</b> <a href="https://langfuse.com/careers"><strong>加入我们</strong></a>,从事产品工程和技术市场职位。</span>
<br/>
+1 -1
View File
@@ -26,7 +26,7 @@
<a href="https://langfuse.com/roadmap"><strong>ロードマップ</strong></a> ·
</div>
<br/>
<span>Langfuseは、サポートと機能リクエストのために <a href="https://github.com/orgs/langfuse/discussions"><strong>Github Discussions</strong></a> を利用しています。</span>
<span>Langfuseは、サポートと機能リクエストのために <a href="https://github.com/orgs/langfuse/discussions"><strong>GitHub Discussions</strong></a> を利用しています。</span>
<br/>
<span><b>We're hiring.</b> <a href="https://langfuse.com/careers"><strong>チームに加わる</strong></a> (製品エンジニアリングおよびテクニカルGTMのポジション)への応募をお待ちしています。</span>
<br/>
+1 -1
View File
@@ -26,7 +26,7 @@
<a href="https://langfuse.com/roadmap"><strong>Roadmap</strong></a> ·
</div>
<br/>
<span>Langfuse uses <a href="https://github.com/orgs/langfuse/discussions"><strong>Github Discussions</strong></a> for Support and Feature Requests.</span>
<span>Langfuse uses <a href="https://github.com/orgs/langfuse/discussions"><strong>GitHub Discussions</strong></a> for Support and Feature Requests.</span>
<br/>
<span><b>We're hiring.</b> <a href="https://langfuse.com/careers"><strong>Join us</strong></a> in product engineering and technical go-to-market roles.</span>
<br/>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.77.0",
"version": "3.78.2",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
+2 -2
View File
@@ -33,7 +33,7 @@
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 72",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 0",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
"db:migrate": "DISABLE_ERD=false dotenv -e ../../.env -- npx prisma migrate dev",
"db:push": "DISABLE_ERD=false dotenv -e ../../.env -- npx prisma db push",
@@ -84,7 +84,7 @@
"jsonpath-plus": "10.3.0",
"kysely": "^0.27.4",
"langchain": "^0.3.28",
"langfuse-langchain": "3.37.4",
"langfuse-langchain": "3.38.1",
"lodash": "^4.17.21",
"lossless-json": "^4.1.1",
"next-auth": "^4.24.11",
+2 -2
View File
@@ -871,7 +871,7 @@ model JobExecution {
jobConfiguration JobConfiguration @relation(fields: [jobConfigurationId], references: [id], onDelete: Cascade)
jobTemplateId String? @map("job_template_id")
jobTemplate EvalTemplate? @relation(fields: [jobTemplateId], references: [id], onDelete: SetNull)
jobTemplate EvalTemplate? @relation(fields: [jobTemplateId], references: [id], onDelete: SetNull, onUpdate: NoAction)
status JobExecutionStatus
startTime DateTime? @map("start_time")
@@ -902,7 +902,7 @@ model DefaultLlmModel {
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
projectId String @map("project_id")
Project Project @relation(fields: [projectId], references: [id])
Project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
llmApiKeyId String @map("llm_api_key_id")
LlmApiKey LlmApiKeys @relation("LlmApiKeyId", fields: [llmApiKeyId], references: [id], onDelete: Cascade)
@@ -178,7 +178,7 @@ async function main() {
const seedApiKey = {
id: "seed-api-key",
secret: process.env.SEED_SECRET_KEY ?? "sk-lf-1234567890",
secret: process.env.SEED_SECRET_KEY ?? "sk-lf-1234567890", // eslint-disable-line turbo/no-undeclared-env-vars
public: "pk-lf-1234567890",
note: "seeded key",
};
@@ -241,7 +241,7 @@ async function main() {
const secondKey = {
id: "seed-api-key-2",
secret: process.env.SEED_SECRET_KEY ?? "sk-lf-asdfghjkl",
secret: process.env.SEED_SECRET_KEY ?? "sk-lf-asdfghjkl", // eslint-disable-line turbo/no-undeclared-env-vars
public: "pk-lf-asdfghjkl",
note: "seeded key 2",
};
@@ -274,7 +274,7 @@ async function main() {
await createTraceSessions(project1, project2);
// If openai key is in environment, add it to the projects LLM API keys
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const OPENAI_API_KEY = process.env.OPENAI_API_KEY; // eslint-disable-line turbo/no-undeclared-env-vars
if (OPENAI_API_KEY) {
await prisma.llmApiKeys.create({
+1 -1
View File
@@ -88,7 +88,7 @@ declare const globalThis: {
kyselyPrismaGlobal: { $kysely: Kysely<DB> } | undefined;
} & typeof global;
if (process.env.NODE_ENV === "development") {
if (process.env.NODE_ENV === "development") { // eslint-disable-line turbo/no-undeclared-env-vars
globalThis.prismaGlobal ??= createPrismaInstance(); // regular instantiation
globalThis.kyselyPrismaGlobal ??= globalThis.prismaGlobal.$extends(
kyselyExtension({
@@ -3,11 +3,11 @@ import { orderBy } from "../interfaces/orderBy";
import z from "zod/v4";
export enum TableViewPresetTableName {
Traces = "traces",
Observations = "observations",
Scores = "scores",
Sessions = "sessions",
Datasets = "datasets",
Traces = "traces", // eslint-disable-line no-unused-vars
Observations = "observations", // eslint-disable-line no-unused-vars
Scores = "scores", // eslint-disable-line no-unused-vars
Sessions = "sessions", // eslint-disable-line no-unused-vars
Datasets = "datasets", // eslint-disable-line no-unused-vars
}
const TableViewPresetDomainSchema = z.object({
+2 -1
View File
@@ -105,10 +105,11 @@ const EnvSchema = z.object({
.number()
.default(80e6), // 80MB
LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS: z.coerce.number().default(240_000), // 4 minutes
LANGFUSE_CLICKHOUSE_QUERY_MAX_ATTEMPTS: z.coerce.number().default(3), // Maximum attempts for socket hang up errors
LANGFUSE_SKIP_S3_LIST_FOR_OBSERVATIONS_PROJECT_IDS: z.string().optional(),
});
export const env: z.infer<typeof EnvSchema> =
process.env.DOCKER_BUILD === "1"
process.env.DOCKER_BUILD === "1" // eslint-disable-line turbo/no-undeclared-env-vars
? (process.env as any)
: EnvSchema.parse(removeEmptyEnvVariables(process.env));
+1
View File
@@ -7,3 +7,4 @@ export { MethodNotAllowedError } from "./MethodNotAllowedError";
export { ApiError } from "./ApiError";
export { InternalServerError } from "./InternalServerError";
export { LangfuseConflictError } from "./ConflictError";
export { QUEUE_ERROR_MESSAGES } from "./utils/constants";
@@ -0,0 +1,10 @@
export const QUEUE_ERROR_MESSAGES = {
API_KEY_ERROR: "API key for provider",
NO_DEFAULT_MODEL_ERROR: "No default model or custom model found for project",
MAPPED_DATA_ERROR:
"Please ensure the mapped data exists and consider extending the job delay.",
INVALID_JSON_ERROR: "is not valid JSON",
TOO_LOW_MAX_TOKENS_ERROR: "Error: Unterminated string in JSON at position",
OUTPUT_TOKENS_TOO_LONG_ERROR:
"Could not parse response content as the length limit was reached",
};
@@ -8,16 +8,16 @@ import { BatchTableNames } from "../../interfaces/tableNames";
import { TracingSearchType } from "../../interfaces/search";
export enum BatchExportStatus {
QUEUED = "QUEUED",
PROCESSING = "PROCESSING",
COMPLETED = "COMPLETED",
FAILED = "FAILED",
QUEUED = "QUEUED", // eslint-disable-line no-unused-vars
PROCESSING = "PROCESSING", // eslint-disable-line no-unused-vars
COMPLETED = "COMPLETED", // eslint-disable-line no-unused-vars
FAILED = "FAILED", // eslint-disable-line no-unused-vars
}
export enum BatchExportFileFormat {
JSON = "JSON",
CSV = "CSV",
JSONL = "JSONL",
JSON = "JSON", // eslint-disable-line no-unused-vars
CSV = "CSV", // eslint-disable-line no-unused-vars
JSONL = "JSONL", // eslint-disable-line no-unused-vars
}
// Use shared BatchTableNames enum for consistency across batch operations
@@ -42,7 +42,7 @@ function parseJsonDefault(selectedColumn: unknown, jsonSelector: string) {
export function extractValueFromObject(
obj: Record<string, unknown>,
mapping: z.infer<typeof variableMapping>,
parseJson?: (selectedColumn: unknown, jsonSelector: string) => unknown,
parseJson?: (selectedColumn: unknown, jsonSelector: string) => unknown, // eslint-disable-line no-unused-vars
): { value: string; error: Error | null } {
const selectedColumn = obj[mapping.selectedColumnId];
const jsonParser = parseJson || parseJsonDefault;
@@ -37,13 +37,13 @@ const ScorePropsAgainstConfigNumeric = z
dataType: z.literal("NUMERIC"),
})
.superRefine((data, ctx) => {
if (isPresent(data.maxValue) && data.value >= data.maxValue) {
if (isPresent(data.maxValue) && data.value > data.maxValue) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Value exceeds maximum value of ${data.maxValue} defined in config`,
});
}
if (isPresent(data.minValue) && data.value <= data.minValue) {
if (isPresent(data.minValue) && data.value < data.minValue) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Value is below minimum value of ${data.minValue} defined in config`,
@@ -150,7 +150,7 @@ const ValidatedScoreConfigSchema = z
*/
export const filterAndValidateDbScoreConfigList = (
scoreConfigs: ScoreConfigDbType[],
onParseError?: (error: z.ZodError) => void,
onParseError?: (error: z.ZodError) => void, // eslint-disable-line no-unused-vars
): ValidatedScoreConfig[] =>
scoreConfigs.reduce((acc, ts) => {
const result = ValidatedScoreConfigSchema.safeParse(ts);
+2 -1
View File
@@ -13,6 +13,7 @@ export * from "./utils/json";
export * from "./utils/stringChecks";
export * from "./utils/objects";
export * from "./utils/typeChecks";
export * from "./utils/prompts";
export * from "./features/entitlements/plans";
export * from "./interfaces/rate-limits";
export * from "./tableDefinitions/typeHelpers";
@@ -45,7 +46,7 @@ export * from "./features/prompts/parsePromptDependencyTags";
export * from "./features/prompts/validation";
export * from "./features/prompts/types";
export * from "./features/prompts/constants";
export * from "./server/llm/compileChatMessages";
export { compileChatMessages, compileChatMessagesWithIds, isPlaceholder, type MessagePlaceholderValues, type PromptMessage as ServerPromptMessage } from "./server/llm/compileChatMessages";
// export db types only
export * from "@prisma/client";
+7 -7
View File
@@ -4,11 +4,11 @@
* to avoid coupling between different batch operation types.
*/
export enum BatchTableNames {
Scores = "scores",
Sessions = "sessions",
Traces = "traces",
Observations = "observations",
DatasetRunItems = "dataset_run_items",
DatasetItems = "dataset_items",
AuditLogs = "audit_logs",
Scores = "scores", // eslint-disable-line no-unused-vars
Sessions = "sessions", // eslint-disable-line no-unused-vars
Traces = "traces", // eslint-disable-line no-unused-vars
Observations = "observations", // eslint-disable-line no-unused-vars
DatasetRunItems = "dataset_run_items", // eslint-disable-line no-unused-vars
DatasetItems = "dataset_items", // eslint-disable-line no-unused-vars
AuditLogs = "audit_logs", // eslint-disable-line no-unused-vars
}
@@ -45,7 +45,7 @@ const getS3StorageServiceClient = (bucketName: string): StorageService => {
return s3StorageServiceClient;
};
export type TokenCountDelegate = (p: {
export type TokenCountDelegate = (p: { // eslint-disable-line no-unused-vars
model: Model;
text: unknown;
}) => number | undefined;
@@ -22,7 +22,7 @@ export type SpanCtx = {
traceContext?: TCarrier;
};
type AsyncCallbackFn<T> = (span: opentelemetry.Span) => Promise<T>;
type AsyncCallbackFn<T> = (span: opentelemetry.Span) => Promise<T>; // eslint-disable-line no-unused-vars
export async function instrumentAsync<T>(
ctx: SpanCtx,
@@ -64,7 +64,7 @@ export async function instrumentAsync<T>(
);
}
type SyncCallbackFn<T> = (span: opentelemetry.Span) => T;
type SyncCallbackFn<T> = (span: opentelemetry.Span) => T; // eslint-disable-line no-unused-vars
export function instrumentSync<T>(
ctx: SpanCtx,
@@ -63,7 +63,7 @@ type FetchLLMCompletionParams = LLMCompletionParams & {
};
export async function fetchLLMCompletion(
params: LLMCompletionParams & {
params: LLMCompletionParams & { // eslint-disable-line no-unused-vars
streaming: true;
},
): Promise<{
@@ -72,7 +72,7 @@ export async function fetchLLMCompletion(
}>;
export async function fetchLLMCompletion(
params: LLMCompletionParams & {
params: LLMCompletionParams & { // eslint-disable-line no-unused-vars
streaming: false;
},
): Promise<{
@@ -81,7 +81,7 @@ export async function fetchLLMCompletion(
}>;
export async function fetchLLMCompletion(
params: LLMCompletionParams & {
params: LLMCompletionParams & { // eslint-disable-line no-unused-vars
streaming: false;
structuredOutputSchema: ZodSchema;
},
@@ -91,7 +91,7 @@ export async function fetchLLMCompletion(
}>;
export async function fetchLLMCompletion(
params: LLMCompletionParams & {
params: LLMCompletionParams & { // eslint-disable-line no-unused-vars
tools: LLMToolDefinition[];
streaming: false;
},
@@ -432,19 +432,19 @@ export class FilterList {
this.filters.push(...filter);
}
find(predicate: (filter: Filter) => boolean) {
find(predicate: (filter: Filter) => boolean) { // eslint-disable-line no-unused-vars
return this.filters.find(predicate);
}
filter(predicate: (filter: Filter) => boolean) {
filter(predicate: (filter: Filter) => boolean) { // eslint-disable-line no-unused-vars
return new FilterList(this.filters.filter(predicate));
}
some(predicate: (filter: Filter) => boolean) {
some(predicate: (filter: Filter) => boolean) { // eslint-disable-line no-unused-vars
return this.filters.some(predicate);
}
forEach(callback: (filter: Filter) => void) {
forEach(callback: (filter: Filter) => void) { // eslint-disable-line no-unused-vars
this.filters.forEach(callback);
}
+3 -2
View File
@@ -70,8 +70,9 @@ export function getQueue(
return ScoreDeleteQueue.getInstance();
case QueueName.DeadLetterRetryQueue:
return DeadLetterRetryQueue.getInstance();
default:
const exhaustiveCheckDefault: never = queueName;
default: {
const exhaustiveCheckDefault: never = queueName; // eslint-disable-line no-case-declarations, no-unused-vars
throw new Error(`Queue ${queueName} not found`);
}
}
}
+3 -3
View File
@@ -167,9 +167,9 @@ const createRedisClient = () => {
declare global {
// eslint-disable-next-line no-var
var redis: undefined | ReturnType<typeof createRedisClient>;
var redis: undefined | ReturnType<typeof createRedisClient>; // eslint-disable-line no-unused-vars
}
export const redis = globalThis.redis ?? createRedisClient();
export const redis = globalThis.redis ?? createRedisClient(); // eslint-disable-line no-undef
if (env.NODE_ENV !== "production") globalThis.redis = redis;
if (env.NODE_ENV !== "production") globalThis.redis = redis; // eslint-disable-line no-undef
@@ -9,6 +9,7 @@ import { randomUUID } from "crypto";
import { getClickhouseEntityType } from "../clickhouse/schemaUtils";
import { NodeClickHouseClientConfigOptions } from "@clickhouse/client/dist/config";
import { context, SpanKind, trace } from "@opentelemetry/api";
import { backOff } from "exponential-backoff";
import {
StorageService,
StorageServiceFactory,
@@ -203,6 +204,18 @@ export async function* queryClickhouseStream<T>(opts: {
}
}
/**
* Determines if an error is retryable (socket hang up, connection reset, etc.)
*/
function isRetryableError(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const errorMessage = (error as Error).message?.toLowerCase() || "";
// Check for socket hang up and other network-related errors
return errorMessage.includes("socket hang up");
}
export async function queryClickhouse<T>(opts: {
query: string;
params?: Record<string, unknown> | undefined;
@@ -218,40 +231,78 @@ export async function queryClickhouse<T>(opts: {
span.setAttribute("db.query.text", opts.query);
span.setAttribute("db.operation.name", "SELECT");
const res = await clickhouseClient(opts.clickhouseConfigs).query({
query: opts.query,
format: "JSONEachRow",
query_params: opts.params,
clickhouse_settings: {
log_comment: JSON.stringify(opts.tags ?? {}),
},
});
// same logic as for prisma. we want to see queries in development
if (env.NODE_ENV === "development") {
logger.info(`clickhouse:query ${res.query_id} ${opts.query}`);
}
// Retry logic for socket hang up and other network errors
return await backOff(
async () => {
const res = await clickhouseClient(opts.clickhouseConfigs).query({
query: opts.query,
format: "JSONEachRow",
query_params: opts.params,
clickhouse_settings: {
log_comment: JSON.stringify(opts.tags ?? {}),
},
});
span.setAttribute("ch.queryId", res.query_id);
// add summary headers to the span. Helps to tune performance
const summaryHeader = res.response_headers["x-clickhouse-summary"];
if (summaryHeader) {
try {
const summary = Array.isArray(summaryHeader)
? JSON.parse(summaryHeader[0])
: JSON.parse(summaryHeader);
for (const key in summary) {
span.setAttribute(`ch.${key}`, summary[key]);
// same logic as for prisma. we want to see queries in development
if (env.NODE_ENV === "development") {
logger.info(`clickhouse:query ${res.query_id} ${opts.query}`);
}
} catch (error) {
logger.debug(
`Failed to parse clickhouse summary header ${summaryHeader}`,
error,
);
}
}
return await res.json<T>();
span.setAttribute("ch.queryId", res.query_id);
// add summary headers to the span. Helps to tune performance
const summaryHeader = res.response_headers["x-clickhouse-summary"];
if (summaryHeader) {
try {
const summary = Array.isArray(summaryHeader)
? JSON.parse(summaryHeader[0])
: JSON.parse(summaryHeader);
for (const key in summary) {
span.setAttribute(`ch.${key}`, summary[key]);
}
} catch (error) {
logger.debug(
`Failed to parse clickhouse summary header ${summaryHeader}`,
error,
);
}
}
return await res.json<T>();
},
{
numOfAttempts: env.LANGFUSE_CLICKHOUSE_QUERY_MAX_ATTEMPTS,
retry: (error: Error, attemptNumber: number) => {
const shouldRetry = isRetryableError(error);
if (shouldRetry) {
logger.warn(
`ClickHouse query failed with retryable error (attempt ${attemptNumber}/${env.LANGFUSE_CLICKHOUSE_QUERY_MAX_ATTEMPTS}): ${error.message}`,
{
error: error.message,
attemptNumber,
tags: opts.tags,
},
);
span.addEvent("clickhouse-query-retry", {
"retry.attempt": attemptNumber,
"retry.error": error.message,
});
} else {
logger.error(
`ClickHouse query failed with non-retryable error: ${error.message}`,
{
error: error.message,
tags: opts.tags,
},
);
}
return shouldRetry;
},
startingDelay: 100,
timeMultiple: 1,
maxDelay: 100,
},
);
},
);
}
@@ -1,6 +1,6 @@
import z from "zod/v4";
import { prisma } from "../../../db";
import { LangfuseNotFoundError } from "../../../errors";
import { LangfuseNotFoundError, QUEUE_ERROR_MESSAGES } from "../../../errors";
import { LLMApiKeySchema, ZodModelConfig } from "../../llm/types";
type ValidConfig = {
@@ -156,7 +156,7 @@ export class DefaultEvalModelService {
if (!selectedModel) {
return {
valid: false,
error: `No default model or custom model found for project ${projectId}.`,
error: `${QUEUE_ERROR_MESSAGES.NO_DEFAULT_MODEL_ERROR} ${projectId}.`,
};
}
@@ -173,7 +173,7 @@ export class DefaultEvalModelService {
if (!parsedKey.success) {
return {
valid: false,
error: `API key for provider "${selectedModel.provider}" not found in project ${projectId}.`,
error: `${QUEUE_ERROR_MESSAGES.API_KEY_ERROR} "${selectedModel.provider}" not found in project ${projectId}.`,
};
}
@@ -13,7 +13,7 @@ export class InMemoryFilterService {
static evaluateFilter<T>(
data: T,
filter: FilterState,
fieldMapper: (data: T, column: string) => unknown,
fieldMapper: (data: T, column: string) => unknown, // eslint-disable-line no-unused-vars
): boolean {
try {
// If no filters, data matches
@@ -45,7 +45,7 @@ export class InMemoryFilterService {
private static evaluateFilterCondition<T>(
data: T,
condition: FilterCondition,
fieldMapper: (data: T, column: string) => unknown,
fieldMapper: (data: T, column: string) => unknown, // eslint-disable-line no-unused-vars
): boolean {
const { column, type, operator } = condition;
@@ -13,8 +13,8 @@ export type PromptParams = {
);
export enum PromptServiceMetrics {
PromptCacheHit = "prompt_cache_hit",
PromptCacheMiss = "prompt_cache_miss",
PromptCacheHit = "prompt_cache_hit", // eslint-disable-line no-unused-vars
PromptCacheMiss = "prompt_cache_miss", // eslint-disable-line no-unused-vars
}
export type PartialPrompt = Pick<
@@ -28,21 +28,21 @@ type UploadFile = {
};
export interface StorageService {
uploadFile(params: UploadFile): Promise<{ signedUrl: string }>;
uploadFile(params: UploadFile): Promise<{ signedUrl: string }>; // eslint-disable-line no-unused-vars
uploadJson(path: string, body: Record<string, unknown>[]): Promise<void>;
uploadJson(path: string, body: Record<string, unknown>[]): Promise<void>; // eslint-disable-line no-unused-vars
download(path: string): Promise<string>;
download(path: string): Promise<string>; // eslint-disable-line no-unused-vars
listFiles(prefix: string): Promise<{ file: string; createdAt: Date }[]>;
listFiles(prefix: string): Promise<{ file: string; createdAt: Date }[]>; // eslint-disable-line no-unused-vars
getSignedUrl(
fileName: string,
ttlSeconds: number,
asAttachment?: boolean,
fileName: string, // eslint-disable-line no-unused-vars
ttlSeconds: number, // eslint-disable-line no-unused-vars
asAttachment?: boolean, // eslint-disable-line no-unused-vars
): Promise<string>;
getSignedUploadUrl(params: {
getSignedUploadUrl(params: { // eslint-disable-line no-unused-vars
path: string;
ttlSeconds: number;
sha256Hash: string;
@@ -50,7 +50,7 @@ export interface StorageService {
contentLength: number;
}): Promise<string>;
deleteFiles(paths: string[]): Promise<void>;
deleteFiles(paths: string[]): Promise<void>; // eslint-disable-line no-unused-vars
}
export class StorageServiceFactory {
@@ -213,7 +213,7 @@ class AzureBlobStorageService implements StorageService {
}
private async streamToString(
readableStream: NodeJS.ReadableStream,
readableStream: NodeJS.ReadableStream, // eslint-disable-line no-undef
): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: string[] = [];
@@ -24,12 +24,12 @@ export class DatabaseReadStream<EntityType> extends Readable {
constructor(
// the delegate function takes care of querying the database in a paginated manner
private queryDelegate: (
pageSize: number,
offset: number
private queryDelegate: ( // eslint-disable-line no-unused-vars
pageSize: number, // eslint-disable-line no-unused-vars
offset: number // eslint-disable-line no-unused-vars
) => Promise<Array<EntityType>>,
private pageSize: number,
private maxRecords?: number
private pageSize: number, // eslint-disable-line no-unused-vars
private maxRecords?: number // eslint-disable-line no-unused-vars
) {
super({ objectMode: true }); // Set object mode to true to allow pushing objects to the stream rather than strings or buffers
@@ -11,7 +11,7 @@ export function transformStreamToCsv(): Transform {
objectMode: true,
transform(
row: Record<string, any>,
encoding: BufferEncoding,
encoding: BufferEncoding, // eslint-disable-line no-undef
callback: TransformCallback,
): void {
if (isFirstChunk) {
@@ -9,8 +9,8 @@ export function transformStreamToJson(): Transform {
transform(
row: any,
encoding: BufferEncoding,
callback: TransformCallback
encoding: BufferEncoding, // eslint-disable-line no-undef, no-unused-vars
callback: TransformCallback,
): void {
if (isFirstElement) {
this.push("["); // Push the opening bracket for the first element
@@ -7,7 +7,7 @@ export function transformStreamToJsonl(): Transform {
transform(
row: Record<string, any>,
encoding: BufferEncoding,
encoding: BufferEncoding, // eslint-disable-line no-undef, no-unused-vars
callback: TransformCallback,
): void {
this.push(stringify(row) + "\n");
+24
View File
@@ -0,0 +1,24 @@
/**
* Client-safe utility functions for prompt handling
*/
export interface PromptMessage {
type?: string;
name?: string;
role?: string;
content?: string;
}
/**
* Extracts placeholder names from prompt messages.
* This is a client-safe version that doesn't depend on server-side types.
* @param messages Array of prompt messages
* @returns Array of placeholder names
*/
export function extractPlaceholderNames(messages: PromptMessage[]): string[] {
return messages
.filter((msg): msg is PromptMessage & { name: string } =>
msg.type === "placeholder" && typeof msg.name === "string"
)
.map(msg => msg.name);
}
+214 -132
View File
@@ -112,7 +112,7 @@ importers:
version: 7.12.0(eslint@8.57.0)(typescript@5.4.5)
'@vercel/style-guide':
specifier: ^6.0.0
version: 6.0.0(@next/eslint-plugin-next@14.2.15)(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8))(prettier@3.5.3)(typescript@5.4.5)(vitest@2.1.2(@types/node@20.14.8))
version: 6.0.0(@next/eslint-plugin-next@14.2.15)(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0))(prettier@3.5.3)(typescript@5.4.5)(vitest@2.1.2(@types/node@20.14.8)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5))(terser@5.43.1))
eslint-config-next:
specifier: ^14.2.15
version: 14.2.15(eslint@8.57.0)(typescript@5.4.5)
@@ -224,8 +224,8 @@ importers:
specifier: ^0.3.28
version: 0.3.28(@langchain/anthropic@0.3.22(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/aws@0.1.11(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(@langchain/google-genai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/google-vertexai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(zod@3.25.62))(axios@1.8.2)(cheerio@1.0.0)(handlebars@4.7.8)(openai@4.104.0(zod@3.25.62))
langfuse-langchain:
specifier: 3.37.4
version: 3.37.4(langchain@0.3.28(@langchain/anthropic@0.3.22(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/aws@0.1.11(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(@langchain/google-genai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/google-vertexai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(zod@3.25.62))(axios@1.8.2)(cheerio@1.0.0)(handlebars@4.7.8)(openai@4.104.0(zod@3.25.62)))
specifier: 3.38.1
version: 3.38.1(langchain@0.3.28(@langchain/anthropic@0.3.22(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/aws@0.1.11(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(@langchain/google-genai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/google-vertexai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(zod@3.25.62))(axios@1.8.2)(cheerio@1.0.0)(handlebars@4.7.8)(openai@4.104.0(zod@3.25.62)))
lodash:
specifier: ^4.17.21
version: 4.17.21
@@ -328,7 +328,7 @@ importers:
version: 5.4.5
vitest:
specifier: ^2.1.2
version: 2.1.2(@types/node@20.11.29)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.11.29)(typescript@5.4.5))(terser@5.43.0)
version: 2.1.2(@types/node@20.11.29)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.11.29)(typescript@5.4.5))(terser@5.43.1)
web:
dependencies:
@@ -758,7 +758,7 @@ importers:
version: 0.5.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5)))
'@testing-library/jest-dom':
specifier: ^6.4.6
version: 6.4.6(@jest/globals@29.7.0)(@types/jest@29.5.12)(jest@29.7.0(@types/node@20.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5)))(vitest@2.1.2(@types/node@20.10.5))
version: 6.4.6(@jest/globals@29.7.0)(@types/jest@29.5.12)(jest@29.7.0(@types/node@20.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5)))(vitest@2.1.2(@types/node@20.10.5)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.10.5)(typescript@5.4.5))(terser@5.43.1))
'@testing-library/react':
specifier: ^15.0.7
version: 15.0.7(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -1038,7 +1038,7 @@ importers:
version: 5.4.5
vitest:
specifier: ^2.1.2
version: 2.1.2(@types/node@20.11.29)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.11.29)(typescript@5.4.5))(terser@5.43.0)
version: 2.1.2(@types/node@20.11.29)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.11.29)(typescript@5.4.5))(terser@5.43.1)
wait-for-expect:
specifier: ^3.0.2
version: 3.0.2
@@ -8936,18 +8936,18 @@ packages:
typeorm:
optional: true
langfuse-core@3.37.4:
resolution: {integrity: sha512-yqXkQ1/8V9TxF8q6oULWPQ4cYZlUs1egN6JfGuhJSWb5x2Ymgt4Qh9of/Kz//CTM7IZgzZqvrlnx0VXhNKDITg==}
langfuse-core@3.38.1:
resolution: {integrity: sha512-qZsHZZ05eHv+EQoPmfrm6bWUflzvlCSAfT8EP1PP1WeHSwEcwT4p7qAs9H0EnWcUtm0dAdyd6U75HGWu3/ETUA==}
engines: {node: '>=18'}
langfuse-langchain@3.37.4:
resolution: {integrity: sha512-yh2+Rg+NqN6k+E29VW9lnLp9qpldBE3Ldhh/qsvInyk0hdhF64I/z0hWPFY4tYNvbzBXDHgApJ+66nSnXBf4Tg==}
langfuse-langchain@3.38.1:
resolution: {integrity: sha512-lkgEwUuGEtkmtxcMhfFn90FRAlGOSGVWczyBFMXck+1YcqnYLIuE/MNg3ufN54yPRVJFfEXj0kesZSLERGQ8eQ==}
engines: {node: '>=18'}
peerDependencies:
langchain: '>=0.0.157 <0.4.0'
langfuse@3.37.4:
resolution: {integrity: sha512-MxnJXkS3xqlydMKWouanghkWZb8OqoDns+NIAZoD+y/WQh1mtx40n8c0dOaT7OahzSfB9ZbWj+j+yqJww/PJYQ==}
langfuse@3.38.1:
resolution: {integrity: sha512-rv0x+NWi4JDN8KUyGcl8JQ7xmM/0UBXEcYmtBR+msLLImsM3I07CPE6imp3N2I4AbByPNKF4IU8Kq/LFS3D34A==}
engines: {node: '>=18'}
langium@3.0.0:
@@ -11310,11 +11310,6 @@ packages:
uglify-js:
optional: true
terser@5.43.0:
resolution: {integrity: sha512-CqNNxKSGKSZCunSvwKLTs8u8sGGlp27sxNZ4quGh0QeNuyHM0JSEM/clM9Mf4zUp6J+tO2gUXhgXT2YMMkwfKQ==}
engines: {node: '>=10'}
hasBin: true
terser@5.43.1:
resolution: {integrity: sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==}
engines: {node: '>=10'}
@@ -14359,12 +14354,26 @@ snapshots:
optionalDependencies:
'@types/node': 20.14.8
'@inquirer/confirm@5.0.2(@types/node@20.10.5)':
dependencies:
'@inquirer/core': 10.1.0(@types/node@20.10.5)
'@inquirer/type': 3.0.1(@types/node@20.10.5)
'@types/node': 20.10.5
optional: true
'@inquirer/confirm@5.0.2(@types/node@20.11.29)':
dependencies:
'@inquirer/core': 10.1.0(@types/node@20.11.29)
'@inquirer/type': 3.0.1(@types/node@20.11.29)
'@types/node': 20.11.29
'@inquirer/confirm@5.0.2(@types/node@20.14.8)':
dependencies:
'@inquirer/core': 10.1.0(@types/node@20.14.8)
'@inquirer/type': 3.0.1(@types/node@20.14.8)
'@types/node': 20.14.8
optional: true
'@inquirer/confirm@5.1.12(@types/node@20.14.8)':
dependencies:
'@inquirer/core': 10.1.13(@types/node@20.14.8)
@@ -14372,6 +14381,21 @@ snapshots:
optionalDependencies:
'@types/node': 20.14.8
'@inquirer/core@10.1.0(@types/node@20.10.5)':
dependencies:
'@inquirer/figures': 1.0.8
'@inquirer/type': 3.0.4(@types/node@20.10.5)
ansi-escapes: 4.3.2
cli-width: 4.1.0
mute-stream: 2.0.0
signal-exit: 4.1.0
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
yoctocolors-cjs: 2.1.2
transitivePeerDependencies:
- '@types/node'
optional: true
'@inquirer/core@10.1.0(@types/node@20.11.29)':
dependencies:
'@inquirer/figures': 1.0.8
@@ -14386,6 +14410,21 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
'@inquirer/core@10.1.0(@types/node@20.14.8)':
dependencies:
'@inquirer/figures': 1.0.8
'@inquirer/type': 3.0.4(@types/node@20.14.8)
ansi-escapes: 4.3.2
cli-width: 4.1.0
mute-stream: 2.0.0
signal-exit: 4.1.0
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
yoctocolors-cjs: 2.1.2
transitivePeerDependencies:
- '@types/node'
optional: true
'@inquirer/core@10.1.13(@types/node@20.14.8)':
dependencies:
'@inquirer/figures': 1.0.12
@@ -14483,14 +14522,34 @@ snapshots:
optionalDependencies:
'@types/node': 20.14.8
'@inquirer/type@3.0.1(@types/node@20.10.5)':
dependencies:
'@types/node': 20.10.5
optional: true
'@inquirer/type@3.0.1(@types/node@20.11.29)':
dependencies:
'@types/node': 20.11.29
'@inquirer/type@3.0.1(@types/node@20.14.8)':
dependencies:
'@types/node': 20.14.8
optional: true
'@inquirer/type@3.0.4(@types/node@20.10.5)':
optionalDependencies:
'@types/node': 20.10.5
optional: true
'@inquirer/type@3.0.4(@types/node@20.11.29)':
optionalDependencies:
'@types/node': 20.11.29
'@inquirer/type@3.0.4(@types/node@20.14.8)':
optionalDependencies:
'@types/node': 20.14.8
optional: true
'@inquirer/type@3.0.7(@types/node@20.14.8)':
optionalDependencies:
'@types/node': 20.14.8
@@ -17685,7 +17744,7 @@ snapshots:
lz-string: 1.5.0
pretty-format: 27.5.1
'@testing-library/jest-dom@6.4.6(@jest/globals@29.7.0)(@types/jest@29.5.12)(jest@29.7.0(@types/node@20.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5)))(vitest@2.1.2(@types/node@20.10.5))':
'@testing-library/jest-dom@6.4.6(@jest/globals@29.7.0)(@types/jest@29.5.12)(jest@29.7.0(@types/node@20.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5)))(vitest@2.1.2(@types/node@20.10.5)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.10.5)(typescript@5.4.5))(terser@5.43.1))':
dependencies:
'@adobe/css-tools': 4.4.0
'@babel/runtime': 7.24.7
@@ -17699,7 +17758,7 @@ snapshots:
'@jest/globals': 29.7.0
'@types/jest': 29.5.12
jest: 29.7.0(@types/node@20.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5))
vitest: 2.1.2(@types/node@20.10.5)
vitest: 2.1.2(@types/node@20.10.5)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.10.5)(typescript@5.4.5))(terser@5.43.1)
'@testing-library/react@15.0.7(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
@@ -18358,7 +18417,7 @@ snapshots:
'@ungap/structured-clone@1.2.0': {}
'@vercel/style-guide@6.0.0(@next/eslint-plugin-next@14.2.15)(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8))(prettier@3.5.3)(typescript@5.4.5)(vitest@2.1.2(@types/node@20.14.8))':
'@vercel/style-guide@6.0.0(@next/eslint-plugin-next@14.2.15)(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0))(prettier@3.5.3)(typescript@5.4.5)(vitest@2.1.2(@types/node@20.14.8)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5))(terser@5.43.1))':
dependencies:
'@babel/core': 7.24.3
'@babel/eslint-parser': 7.24.1(@babel/core@7.24.3)(eslint@8.57.0)
@@ -18366,19 +18425,19 @@ snapshots:
'@typescript-eslint/eslint-plugin': 7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)
'@typescript-eslint/parser': 7.12.0(eslint@8.57.0)(typescript@5.4.5)
eslint-config-prettier: 9.1.0(eslint@8.57.0)
eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0))
eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.29.1)
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0)
eslint-plugin-eslint-comments: 3.2.0(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-jest: 27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8))(typescript@5.4.5)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-jest: 27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0))(typescript@5.4.5)
eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.0)
eslint-plugin-playwright: 1.5.4(eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8))(typescript@5.4.5))(eslint@8.57.0)
eslint-plugin-playwright: 1.5.4(eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0))(typescript@5.4.5))(eslint@8.57.0)
eslint-plugin-react: 7.34.1(eslint@8.57.0)
eslint-plugin-react-hooks: 4.6.0(eslint@8.57.0)
eslint-plugin-testing-library: 6.2.0(eslint@8.57.0)(typescript@5.4.5)
eslint-plugin-tsdoc: 0.2.17
eslint-plugin-unicorn: 51.0.1(eslint@8.57.0)
eslint-plugin-vitest: 0.3.26(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)(vitest@2.1.2(@types/node@20.14.8))
eslint-plugin-vitest: 0.3.26(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)(vitest@2.1.2(@types/node@20.14.8)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5))(terser@5.43.1))
prettier-plugin-packagejson: 2.4.12(prettier@3.5.3)
optionalDependencies:
'@next/eslint-plugin-next': 14.2.15
@@ -18399,31 +18458,33 @@ snapshots:
chai: 5.1.1
tinyrainbow: 1.2.0
'@vitest/mocker@2.1.2(@vitest/spy@2.1.2)(msw@2.6.5(@types/node@20.11.29)(typescript@5.4.5))(vite@5.2.14(@types/node@20.11.29)(terser@5.43.0))':
'@vitest/mocker@2.1.2(@vitest/spy@2.1.2)(msw@2.6.5(@types/node@20.10.5)(typescript@5.4.5))(vite@5.2.14(@types/node@20.10.5)(terser@5.43.1))':
dependencies:
'@vitest/spy': 2.1.2
estree-walker: 3.0.3
magic-string: 0.30.11
optionalDependencies:
msw: 2.6.5(@types/node@20.10.5)(typescript@5.4.5)
vite: 5.2.14(@types/node@20.10.5)(terser@5.43.1)
optional: true
'@vitest/mocker@2.1.2(@vitest/spy@2.1.2)(msw@2.6.5(@types/node@20.11.29)(typescript@5.4.5))(vite@5.2.14(@types/node@20.11.29)(terser@5.43.1))':
dependencies:
'@vitest/spy': 2.1.2
estree-walker: 3.0.3
magic-string: 0.30.11
optionalDependencies:
msw: 2.6.5(@types/node@20.11.29)(typescript@5.4.5)
vite: 5.2.14(@types/node@20.11.29)(terser@5.43.0)
vite: 5.2.14(@types/node@20.11.29)(terser@5.43.1)
'@vitest/mocker@2.1.2(@vitest/spy@2.1.2)(vite@5.2.14(@types/node@20.10.5))':
'@vitest/mocker@2.1.2(@vitest/spy@2.1.2)(msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5))(vite@5.2.14(@types/node@20.14.8)(terser@5.43.1))':
dependencies:
'@vitest/spy': 2.1.2
estree-walker: 3.0.3
magic-string: 0.30.11
optionalDependencies:
vite: 5.2.14(@types/node@20.10.5)
optional: true
'@vitest/mocker@2.1.2(@vitest/spy@2.1.2)(vite@5.2.14(@types/node@20.14.8))':
dependencies:
'@vitest/spy': 2.1.2
estree-walker: 3.0.3
magic-string: 0.30.11
optionalDependencies:
vite: 5.2.14(@types/node@20.14.8)
msw: 2.6.5(@types/node@20.14.8)(typescript@5.4.5)
vite: 5.2.14(@types/node@20.14.8)(terser@5.43.1)
optional: true
'@vitest/pretty-format@2.1.2':
@@ -19505,7 +19566,7 @@ snapshots:
- supports-color
- ts-node
create-jest@29.7.0(@types/node@20.14.8):
create-jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0):
dependencies:
'@jest/types': 29.6.3
chalk: 4.1.2
@@ -20319,7 +20380,7 @@ snapshots:
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.0)
eslint-plugin-react: 7.34.1(eslint@8.57.0)
eslint-plugin-react-hooks: 4.6.0(eslint@8.57.0)
@@ -20336,7 +20397,7 @@ snapshots:
eslint-config-standard@17.1.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint-plugin-n@16.6.2(eslint@8.57.0))(eslint-plugin-promise@6.4.0(eslint@8.57.0))(eslint@8.57.0):
dependencies:
eslint: 8.57.0
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-n: 16.6.2(eslint@8.57.0)
eslint-plugin-promise: 6.4.0(eslint@8.57.0)
@@ -20346,9 +20407,9 @@ snapshots:
eslint-plugin-turbo: 2.5.4(eslint@8.57.0)(turbo@2.5.4)
turbo: 2.5.4
eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)):
eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.29.1):
dependencies:
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
eslint-import-resolver-node@0.3.9:
dependencies:
@@ -20363,8 +20424,8 @@ snapshots:
debug: 4.3.7(supports-color@5.5.0)
enhanced-resolve: 5.17.1
eslint: 8.57.0
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
fast-glob: 3.3.3
get-tsconfig: 4.8.1
is-core-module: 2.15.1
@@ -20381,7 +20442,7 @@ snapshots:
enhanced-resolve: 5.17.1
eslint: 8.57.0
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
fast-glob: 3.3.3
get-tsconfig: 4.8.1
is-core-module: 2.15.1
@@ -20392,17 +20453,6 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
eslint-module-utils@2.8.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 7.12.0(eslint@8.57.0)(typescript@5.4.5)
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0)
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.8.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0):
dependencies:
debug: 3.2.7
@@ -20414,6 +20464,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.8.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 7.12.0(eslint@8.57.0)(typescript@5.4.5)
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0)
transitivePeerDependencies:
- supports-color
eslint-plugin-es-x@7.8.0(eslint@8.57.0):
dependencies:
'@eslint-community/eslint-utils': 4.7.0(eslint@8.57.0)
@@ -20427,7 +20488,7 @@ snapshots:
eslint: 8.57.0
ignore: 5.3.2
eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0):
eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0):
dependencies:
array-includes: 3.1.7
array.prototype.findlastindex: 1.2.4
@@ -20454,40 +20515,13 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0):
dependencies:
array-includes: 3.1.7
array.prototype.findlastindex: 1.2.4
array.prototype.flat: 1.3.2
array.prototype.flatmap: 1.3.2
debug: 3.2.7
doctrine: 2.1.0
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
hasown: 2.0.2
is-core-module: 2.15.1
is-glob: 4.0.3
minimatch: 3.1.2
object.fromentries: 2.0.8
object.groupby: 1.0.3
object.values: 1.2.0
semver: 6.3.1
tsconfig-paths: 3.15.0
optionalDependencies:
'@typescript-eslint/parser': 7.12.0(eslint@8.57.0)(typescript@5.4.5)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
- supports-color
eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8))(typescript@5.4.5):
eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0))(typescript@5.4.5):
dependencies:
'@typescript-eslint/utils': 5.62.0(eslint@8.57.0)(typescript@5.4.5)
eslint: 8.57.0
optionalDependencies:
'@typescript-eslint/eslint-plugin': 7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)
jest: 29.7.0(@types/node@20.14.8)
jest: 29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0)
transitivePeerDependencies:
- supports-color
- typescript
@@ -20529,12 +20563,12 @@ snapshots:
eslint-plugin-only-warn@1.1.0: {}
eslint-plugin-playwright@1.5.4(eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8))(typescript@5.4.5))(eslint@8.57.0):
eslint-plugin-playwright@1.5.4(eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0))(typescript@5.4.5))(eslint@8.57.0):
dependencies:
eslint: 8.57.0
globals: 13.24.0
optionalDependencies:
eslint-plugin-jest: 27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8))(typescript@5.4.5)
eslint-plugin-jest: 27.9.0(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0))(typescript@5.4.5)
eslint-plugin-prettier@5.1.3(@types/eslint@8.56.12)(eslint-config-prettier@9.1.0(eslint@8.57.0))(eslint@8.57.0)(prettier@3.3.3):
dependencies:
@@ -20617,13 +20651,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-vitest@0.3.26(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)(vitest@2.1.2(@types/node@20.14.8)):
eslint-plugin-vitest@0.3.26(@typescript-eslint/eslint-plugin@7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)(vitest@2.1.2(@types/node@20.14.8)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5))(terser@5.43.1)):
dependencies:
'@typescript-eslint/utils': 7.3.1(eslint@8.57.0)(typescript@5.4.5)
eslint: 8.57.0
optionalDependencies:
'@typescript-eslint/eslint-plugin': 7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)
vitest: 2.1.2(@types/node@20.14.8)
vitest: 2.1.2(@types/node@20.14.8)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5))(terser@5.43.1)
transitivePeerDependencies:
- supports-color
- typescript
@@ -21884,13 +21918,13 @@ snapshots:
- supports-color
- ts-node
jest-cli@29.7.0(@types/node@20.14.8):
jest-cli@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0):
dependencies:
'@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5))
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
create-jest: 29.7.0(@types/node@20.14.8)
create-jest: 29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0)
exit: 0.1.2
import-local: 3.1.0
jest-config: 29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5))
@@ -22191,7 +22225,7 @@ snapshots:
jest-worker@27.5.1:
dependencies:
'@types/node': 20.10.5
'@types/node': 20.14.8
merge-stream: 2.0.0
supports-color: 8.1.1
@@ -22214,12 +22248,12 @@ snapshots:
- supports-color
- ts-node
jest@29.7.0(@types/node@20.14.8):
jest@29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0):
dependencies:
'@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.10.5)(typescript@5.4.5))
'@jest/types': 29.6.3
import-local: 3.1.0
jest-cli: 29.7.0(@types/node@20.14.8)
jest-cli: 29.7.0(@types/node@20.14.8)(babel-plugin-macros@3.1.0)
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@@ -22417,19 +22451,19 @@ snapshots:
- openai
- ws
langfuse-core@3.37.4:
langfuse-core@3.38.1:
dependencies:
mustache: 4.2.0
langfuse-langchain@3.37.4(langchain@0.3.28(@langchain/anthropic@0.3.22(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/aws@0.1.11(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(@langchain/google-genai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/google-vertexai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(zod@3.25.62))(axios@1.8.2)(cheerio@1.0.0)(handlebars@4.7.8)(openai@4.104.0(zod@3.25.62))):
langfuse-langchain@3.38.1(langchain@0.3.28(@langchain/anthropic@0.3.22(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/aws@0.1.11(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(@langchain/google-genai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/google-vertexai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(zod@3.25.62))(axios@1.8.2)(cheerio@1.0.0)(handlebars@4.7.8)(openai@4.104.0(zod@3.25.62))):
dependencies:
langchain: 0.3.28(@langchain/anthropic@0.3.22(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/aws@0.1.11(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(@langchain/google-genai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62))))(@langchain/google-vertexai@0.2.12(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))(zod@3.25.62))(axios@1.8.2)(cheerio@1.0.0)(handlebars@4.7.8)(openai@4.104.0(zod@3.25.62))
langfuse: 3.37.4
langfuse-core: 3.37.4
langfuse: 3.38.1
langfuse-core: 3.38.1
langfuse@3.37.4:
langfuse@3.38.1:
dependencies:
langfuse-core: 3.37.4
langfuse-core: 3.38.1
langium@3.0.0:
dependencies:
@@ -23127,6 +23161,32 @@ snapshots:
optionalDependencies:
msgpackr-extract: 3.0.3
msw@2.6.5(@types/node@20.10.5)(typescript@5.4.5):
dependencies:
'@bundled-es-modules/cookie': 2.0.1
'@bundled-es-modules/statuses': 1.0.1
'@bundled-es-modules/tough-cookie': 0.1.6
'@inquirer/confirm': 5.0.2(@types/node@20.10.5)
'@mswjs/interceptors': 0.37.1
'@open-draft/deferred-promise': 2.2.0
'@open-draft/until': 2.1.0
'@types/cookie': 0.6.0
'@types/statuses': 2.0.5
chalk: 4.1.2
graphql: 16.9.0
headers-polyfill: 4.0.3
is-node-process: 1.2.0
outvariant: 1.4.3
path-to-regexp: 6.3.0
strict-event-emitter: 0.5.1
type-fest: 4.27.0
yargs: 17.7.2
optionalDependencies:
typescript: 5.4.5
transitivePeerDependencies:
- '@types/node'
optional: true
msw@2.6.5(@types/node@20.11.29)(typescript@5.4.5):
dependencies:
'@bundled-es-modules/cookie': 2.0.1
@@ -23152,6 +23212,32 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5):
dependencies:
'@bundled-es-modules/cookie': 2.0.1
'@bundled-es-modules/statuses': 1.0.1
'@bundled-es-modules/tough-cookie': 0.1.6
'@inquirer/confirm': 5.0.2(@types/node@20.14.8)
'@mswjs/interceptors': 0.37.1
'@open-draft/deferred-promise': 2.2.0
'@open-draft/until': 2.1.0
'@types/cookie': 0.6.0
'@types/statuses': 2.0.5
chalk: 4.1.2
graphql: 16.9.0
headers-polyfill: 4.0.3
is-node-process: 1.2.0
outvariant: 1.4.3
path-to-regexp: 6.3.0
strict-event-emitter: 0.5.1
type-fest: 4.27.0
yargs: 17.7.2
optionalDependencies:
typescript: 5.4.5
transitivePeerDependencies:
- '@types/node'
optional: true
mustache@4.2.0: {}
mute-stream@2.0.0: {}
@@ -25180,14 +25266,6 @@ snapshots:
terser: 5.43.1
webpack: 5.97.1
terser@5.43.0:
dependencies:
'@jridgewell/source-map': 0.3.6
acorn: 8.15.0
commander: 2.20.3
source-map-support: 0.5.21
optional: true
terser@5.43.1:
dependencies:
'@jridgewell/source-map': 0.3.6
@@ -25681,12 +25759,12 @@ snapshots:
'@egjs/hammerjs': 2.0.17
component-emitter: 1.3.1
vite-node@2.1.2(@types/node@20.10.5):
vite-node@2.1.2(@types/node@20.10.5)(terser@5.43.1):
dependencies:
cac: 6.7.14
debug: 4.3.7(supports-color@5.5.0)
pathe: 1.1.2
vite: 5.2.14(@types/node@20.10.5)
vite: 5.2.14(@types/node@20.10.5)(terser@5.43.1)
transitivePeerDependencies:
- '@types/node'
- less
@@ -25698,12 +25776,12 @@ snapshots:
- terser
optional: true
vite-node@2.1.2(@types/node@20.11.29)(terser@5.43.0):
vite-node@2.1.2(@types/node@20.11.29)(terser@5.43.1):
dependencies:
cac: 6.7.14
debug: 4.3.7(supports-color@5.5.0)
pathe: 1.1.2
vite: 5.2.14(@types/node@20.11.29)(terser@5.43.0)
vite: 5.2.14(@types/node@20.11.29)(terser@5.43.1)
transitivePeerDependencies:
- '@types/node'
- less
@@ -25714,12 +25792,12 @@ snapshots:
- supports-color
- terser
vite-node@2.1.2(@types/node@20.14.8):
vite-node@2.1.2(@types/node@20.14.8)(terser@5.43.1):
dependencies:
cac: 6.7.14
debug: 4.3.7(supports-color@5.5.0)
pathe: 1.1.2
vite: 5.2.14(@types/node@20.14.8)
vite: 5.2.14(@types/node@20.14.8)(terser@5.43.1)
transitivePeerDependencies:
- '@types/node'
- less
@@ -25731,7 +25809,7 @@ snapshots:
- terser
optional: true
vite@5.2.14(@types/node@20.10.5):
vite@5.2.14(@types/node@20.10.5)(terser@5.43.1):
dependencies:
esbuild: 0.20.2
postcss: 8.5.3
@@ -25739,9 +25817,10 @@ snapshots:
optionalDependencies:
'@types/node': 20.10.5
fsevents: 2.3.3
terser: 5.43.1
optional: true
vite@5.2.14(@types/node@20.11.29)(terser@5.43.0):
vite@5.2.14(@types/node@20.11.29)(terser@5.43.1):
dependencies:
esbuild: 0.20.2
postcss: 8.5.3
@@ -25749,9 +25828,9 @@ snapshots:
optionalDependencies:
'@types/node': 20.11.29
fsevents: 2.3.3
terser: 5.43.0
terser: 5.43.1
vite@5.2.14(@types/node@20.14.8):
vite@5.2.14(@types/node@20.14.8)(terser@5.43.1):
dependencies:
esbuild: 0.20.2
postcss: 8.5.3
@@ -25759,12 +25838,13 @@ snapshots:
optionalDependencies:
'@types/node': 20.14.8
fsevents: 2.3.3
terser: 5.43.1
optional: true
vitest@2.1.2(@types/node@20.10.5):
vitest@2.1.2(@types/node@20.10.5)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.10.5)(typescript@5.4.5))(terser@5.43.1):
dependencies:
'@vitest/expect': 2.1.2
'@vitest/mocker': 2.1.2(@vitest/spy@2.1.2)(vite@5.2.14(@types/node@20.10.5))
'@vitest/mocker': 2.1.2(@vitest/spy@2.1.2)(msw@2.6.5(@types/node@20.10.5)(typescript@5.4.5))(vite@5.2.14(@types/node@20.10.5)(terser@5.43.1))
'@vitest/pretty-format': 2.1.2
'@vitest/runner': 2.1.2
'@vitest/snapshot': 2.1.2
@@ -25779,11 +25859,12 @@ snapshots:
tinyexec: 0.3.0
tinypool: 1.0.1
tinyrainbow: 1.2.0
vite: 5.2.14(@types/node@20.10.5)
vite-node: 2.1.2(@types/node@20.10.5)
vite: 5.2.14(@types/node@20.10.5)(terser@5.43.1)
vite-node: 2.1.2(@types/node@20.10.5)(terser@5.43.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 20.10.5
jsdom: 20.0.3
transitivePeerDependencies:
- less
- lightningcss
@@ -25795,10 +25876,10 @@ snapshots:
- terser
optional: true
vitest@2.1.2(@types/node@20.11.29)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.11.29)(typescript@5.4.5))(terser@5.43.0):
vitest@2.1.2(@types/node@20.11.29)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.11.29)(typescript@5.4.5))(terser@5.43.1):
dependencies:
'@vitest/expect': 2.1.2
'@vitest/mocker': 2.1.2(@vitest/spy@2.1.2)(msw@2.6.5(@types/node@20.11.29)(typescript@5.4.5))(vite@5.2.14(@types/node@20.11.29)(terser@5.43.0))
'@vitest/mocker': 2.1.2(@vitest/spy@2.1.2)(msw@2.6.5(@types/node@20.11.29)(typescript@5.4.5))(vite@5.2.14(@types/node@20.11.29)(terser@5.43.1))
'@vitest/pretty-format': 2.1.2
'@vitest/runner': 2.1.2
'@vitest/snapshot': 2.1.2
@@ -25813,8 +25894,8 @@ snapshots:
tinyexec: 0.3.0
tinypool: 1.0.1
tinyrainbow: 1.2.0
vite: 5.2.14(@types/node@20.11.29)(terser@5.43.0)
vite-node: 2.1.2(@types/node@20.11.29)(terser@5.43.0)
vite: 5.2.14(@types/node@20.11.29)(terser@5.43.1)
vite-node: 2.1.2(@types/node@20.11.29)(terser@5.43.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 20.11.29
@@ -25829,10 +25910,10 @@ snapshots:
- supports-color
- terser
vitest@2.1.2(@types/node@20.14.8):
vitest@2.1.2(@types/node@20.14.8)(jsdom@20.0.3)(msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5))(terser@5.43.1):
dependencies:
'@vitest/expect': 2.1.2
'@vitest/mocker': 2.1.2(@vitest/spy@2.1.2)(vite@5.2.14(@types/node@20.14.8))
'@vitest/mocker': 2.1.2(@vitest/spy@2.1.2)(msw@2.6.5(@types/node@20.14.8)(typescript@5.4.5))(vite@5.2.14(@types/node@20.14.8)(terser@5.43.1))
'@vitest/pretty-format': 2.1.2
'@vitest/runner': 2.1.2
'@vitest/snapshot': 2.1.2
@@ -25847,11 +25928,12 @@ snapshots:
tinyexec: 0.3.0
tinypool: 1.0.1
tinyrainbow: 1.2.0
vite: 5.2.14(@types/node@20.14.8)
vite-node: 2.1.2(@types/node@20.14.8)
vite: 5.2.14(@types/node@20.14.8)(terser@5.43.1)
vite-node: 2.1.2(@types/node@20.14.8)(terser@5.43.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 20.14.8
jsdom: 20.0.3
transitivePeerDependencies:
- less
- lightningcss
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.77.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", () => {
@@ -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", () => {
+1
View File
@@ -338,6 +338,7 @@ export function DeleteEvalConfigButton(props: DeleteButtonProps) {
source: isTableAction ? "table-single-row" : "eval config detail",
})
}
customDeletePrompt="This action cannot be undone and removes all logs associated with this running evaluator. Scores produced by this evaluator will not be deleted."
entityToDeleteName="running evaluator"
executeDeleteMutation={executeDeleteMutation}
isDeleteMutationLoading={evaluatorMutation.isLoading}
@@ -111,7 +111,7 @@ const notifications: SidebarNotification[] = [
linkContent: (
// eslint-disable-next-line @next/next/no-img-element
<img
alt="Langfuse Github stars"
alt="Langfuse GitHub stars"
src="https://img.shields.io/github/stars/langfuse/langfuse?label=langfuse&amp;style=social"
/>
),
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.77.0";
export const VERSION = "v3.78.2";
@@ -456,13 +456,15 @@ export function PreviewCsvImport({
</Button>
<Button
disabled={
selectedInputColumn.size === 0 &&
selectedExpectedColumn.size === 0 &&
selectedMetadataColumn.size === 0
(selectedInputColumn.size === 0 &&
selectedExpectedColumn.size === 0 &&
selectedMetadataColumn.size === 0) ||
progress.status === "processing"
}
loading={progress.status === "processing"}
onClick={handleImport}
>
Import
{progress.status === "processing" ? "Importing..." : "Import"}
</Button>
{progress.status === "processing" && (
<div className="mt-2">
@@ -638,7 +638,7 @@ export const CreateExperimentsForm = ({
<span>
Given current prompt, dataset item input must
contain at least one of these first-level JSON
keys, mapped to a string value:
keys:
</span>
<ul className="my-2 ml-2 list-inside list-disc">
{expectedColumns.map((col) => (
@@ -646,8 +646,9 @@ export const CreateExperimentsForm = ({
))}
</ul>
<span>
These will be used as the input to your
prompt.
Variables (like {"{{variable}}"}) should be mapped to string values.
Placeholders should be mapped to arrays of message objects.
These will be used as the input to your prompt.
</span>
</div>
) : (
@@ -721,7 +722,7 @@ export const CreateExperimentsForm = ({
<Loader2 className="h-3 w-3 animate-spin" />
</CardTitle>
<CardDescription className="text-foreground">
Checking dataset items against prompt variables
Checking dataset items against prompt variables and placeholders
</CardDescription>
</CardHeader>
</Card>
@@ -747,7 +748,7 @@ export const CreateExperimentsForm = ({
<CircleCheck className="h-4 w-4" />
</CardTitle>
<div className="text-sm">
Matches between dataset items and prompt variables
Matches between dataset items and prompt variables/placeholders
<ul className="my-2 ml-2 list-inside list-disc">
{Object.entries(
validationResult.data.variablesMap ?? {},
@@ -760,7 +761,7 @@ export const CreateExperimentsForm = ({
</li>
))}
</ul>
Items missing all prompt variables will be excluded from
Items missing all required variables and placeholders will be excluded from
the experiment.
</div>
</CardHeader>
@@ -1,7 +1,7 @@
import { useMemo } from "react";
import { api } from "@/src/utils/api";
import { type UseFormReturn } from "react-hook-form";
import { extractVariables, PromptType } from "@langfuse/shared";
import { extractVariables, PromptType, extractPlaceholderNames, type PromptMessage } from "@langfuse/shared";
type ExperimentPromptDataProps = {
projectId: string;
@@ -22,11 +22,18 @@ export function useExperimentPromptData({
const prompt = promptMeta.data?.find((p) => p.id === promptId);
if (!prompt) return [];
return extractVariables(
const extractedVariables = extractVariables(
prompt.type === PromptType.Text
? (prompt?.prompt?.toString() ?? "")
: JSON.stringify(prompt?.prompt),
);
const promptMessages = prompt?.type === PromptType.Chat && Array.isArray(prompt.prompt)
? prompt.prompt
: [];
const placeholderNames = extractPlaceholderNames(promptMessages as PromptMessage[]);
return [...extractedVariables, ...placeholderNames];
}, [promptId, promptMeta.data]);
const promptsByName = useMemo(
+9 -15
View File
@@ -2,12 +2,11 @@ import { z } from "zod/v4";
import { randomUUID } from "crypto";
import {
type ExperimentMetadata,
ExperimentCreateQueue,
QueueJobs,
QueueName,
redis,
ZodModelConfig,
ExperimentCreateQueue,
type PlaceholderMessage,
} from "@langfuse/shared/src/server";
import {
createTRPCRouter,
@@ -20,6 +19,8 @@ import {
datasetItemMatchesVariable,
UnauthorizedError,
PromptType,
extractPlaceholderNames,
type PromptMessage,
} from "@langfuse/shared";
import { throwIfNoProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
@@ -98,24 +99,17 @@ export const experimentsRouter = createTRPCRouter({
: JSON.stringify(prompt.prompt),
);
if (!Boolean(extractedVariables.length)) {
return {
isValid: false,
message: "Selected prompt has no variables.",
};
}
const promptMessages = prompt?.type === PromptType.Chat && Array.isArray(prompt.prompt)
? prompt.prompt
: [];
const hasPlaceholders = promptMessages.some((msg): msg is PlaceholderMessage =>
(msg as PlaceholderMessage).type === "placeholder"
);
const placeholderNames = extractPlaceholderNames(promptMessages as PromptMessage[]);
if (hasPlaceholders) {
const allVariables = [...extractedVariables, ...placeholderNames];
if (!Boolean(allVariables.length)) {
return {
isValid: false,
message: "Selected prompt has placeholders, those are not yet supported for experiments.",
message: "Selected prompt has no variables or placeholders.",
};
}
@@ -136,7 +130,7 @@ export const experimentsRouter = createTRPCRouter({
const variablesMap = validateDatasetItems(
datasetItems,
extractedVariables,
allVariables,
);
if (!Boolean(Object.keys(variablesMap).length)) {
@@ -619,7 +619,7 @@ export class OtelIngestionProcessor {
}
private hasTraceUpdates(attributes: Record<string, unknown>): boolean {
return [
const hasExactMatchingAttributeName = [
LangfuseOtelSpanAttributes.TRACE_NAME,
LangfuseOtelSpanAttributes.TRACE_INPUT,
LangfuseOtelSpanAttributes.TRACE_OUTPUT,
@@ -637,6 +637,13 @@ export class OtelIngestionProcessor {
`${LangfuseOtelSpanAttributes.TRACE_METADATA}.langfuse_user_id`,
`${LangfuseOtelSpanAttributes.TRACE_METADATA}.langfuse_tags`,
].some((traceAttribute) => Boolean(attributes[traceAttribute]));
const attributeKeys = Object.keys(attributes);
const hasTraceMetadataKey = attributeKeys.some((key) =>
key.startsWith(LangfuseOtelSpanAttributes.TRACE_METADATA),
);
return hasExactMatchingAttributeName || hasTraceMetadataKey;
}
private extractResourceAttributes(
@@ -122,7 +122,7 @@ export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
title={
isAvailable
? "Test in LLM playground"
: "Test in LLM playground is not available since messages are not in valid ChatML format or tool calls have been used. If you think this is not correct, please open a Github issue."
: "Test in LLM playground is not available since messages are not in valid ChatML format or tool calls have been used. If you think this is not correct, please open a GitHub issue."
}
onClick={handleClick}
asChild
@@ -6,14 +6,17 @@ import { type ChatMessage, PromptChatMessageListSchema } from "@langfuse/shared"
import { usePlaygroundContext } from "../context";
import { type PlaceholderMessageFillIn } from "../types";
import { useNamingConflicts } from "../hooks/useNamingConflicts";
export const MessagePlaceholderComponent: React.FC<{
messagePlaceholder: PlaceholderMessageFillIn;
}> = ({ messagePlaceholder }) => {
const { updateMessagePlaceholderValue, deleteMessagePlaceholder } =
const { updateMessagePlaceholderValue, deleteMessagePlaceholder, promptVariables, messagePlaceholders } =
usePlaygroundContext();
const { name, value, isUsed } = messagePlaceholder;
const [error, setError] = useState<string | null>(null);
const { isPlaceholderConflicting } = useNamingConflicts(promptVariables, messagePlaceholders);
const hasConflict = isPlaceholderConflicting(name);
const handleInputChange = useCallback((jsonString: string) => {
try {
@@ -39,7 +42,7 @@ export const MessagePlaceholderComponent: React.FC<{
<div className="mb-1 flex flex-row items-center">
<span className="flex flex-1 flex-row space-x-2 text-xs">
<UsedIcon size={16} color={iconColor} />
<p className="min-w-[90px] truncate font-mono" title={name}>
<p className={`min-w-[90px] truncate font-mono ${hasConflict ? 'text-red-500' : ''}`} title={name}>
{name ? name : "Unnamed placeholder"}
</p>
</span>
@@ -60,12 +63,17 @@ export const MessagePlaceholderComponent: React.FC<{
onChange={handleInputChange}
mode="json"
minHeight="none"
className="max-h-[15rem] w-full resize-y p-1 font-mono text-xs focus:outline-none"
className={`max-h-[15rem] w-full resize-y p-1 font-mono text-xs focus:outline-none ${hasConflict ? 'border border-red-500' : ''}`}
editable={true}
lineNumbers={false}
placeholder={`[\n {\n "role": "user",\n "content": "Hello!"\n },\n {\n "role": "assistant",\n "content": "Hi there!"\n }\n]`}
/>
{hasConflict && (
<p className="mt-1 text-xs text-red-500">
Placeholder name conflicts with variable. Names must be unique.
</p>
)}
{error && (
<p className="mt-1 text-xs text-red-500">{error}</p>
)}
@@ -4,13 +4,16 @@ import { type PromptVariable } from "@langfuse/shared";
import { CodeMirrorEditor } from "@/src/components/editor";
import { usePlaygroundContext } from "../context";
import { useNamingConflicts } from "../hooks/useNamingConflicts";
export const PromptVariableComponent: React.FC<{
promptVariable: PromptVariable;
}> = ({ promptVariable }) => {
const { updatePromptVariableValue, deletePromptVariable } =
const { updatePromptVariableValue, deletePromptVariable, promptVariables, messagePlaceholders } =
usePlaygroundContext();
const { name, value, isUsed } = promptVariable;
const { isVariableConflicting } = useNamingConflicts(promptVariables, messagePlaceholders);
const hasConflict = isVariableConflicting(name);
const handleInputChange = (value: string) => {
updatePromptVariableValue(name, value);
@@ -32,7 +35,7 @@ export const PromptVariableComponent: React.FC<{
<div className="mb-1 flex flex-row items-center">
<span className="flex flex-1 flex-row space-x-2 text-xs">
<p title={isUsedTooltip}>{isUsedIcon}</p>
<p className="min-w-[90px] truncate font-mono" title={name}>
<p className={`min-w-[90px] truncate font-mono ${hasConflict ? 'text-red-500' : ''}`} title={name}>
{name}
</p>
</span>
@@ -53,11 +56,17 @@ export const PromptVariableComponent: React.FC<{
onChange={(e) => handleInputChange(e)}
mode="prompt"
minHeight="none"
className="max-h-[10rem] w-full resize-y p-1 font-mono text-xs focus:outline-none"
className={`max-h-[10rem] w-full resize-y p-1 font-mono text-xs focus:outline-none ${hasConflict ? 'border border-red-500' : ''}`}
editable={true}
lineNumbers={false}
placeholder={name}
/>
{hasConflict && (
<p className="mt-1 text-xs text-red-500">
Variable name conflicts with placeholder. Names must be unique.
</p>
)}
</div>
);
};
@@ -0,0 +1,43 @@
import { useMemo } from "react";
import { type PromptVariable } from "@langfuse/shared";
import { type PlaceholderMessageFillIn } from "../types";
export interface NamingConflictInfo {
hasConflicts: boolean;
conflictingNames: string[];
isVariableConflicting: (variableName: string) => boolean;
isPlaceholderConflicting: (placeholderName: string) => boolean;
}
/**
* Detects naming conflicts between variables and placeholders in the playground.
* Returns names of conflicting variables and placeholders.
*/
export const useNamingConflicts = (
variables: PromptVariable[],
placeholders: PlaceholderMessageFillIn[]
): NamingConflictInfo => {
return useMemo(() => {
const variableNames = variables.map(v => v.name);
const placeholderNames = placeholders.map(p => p.name);
const conflictingNames = variableNames.filter(name =>
placeholderNames.includes(name)
);
const hasConflicts = conflictingNames.length > 0;
const isVariableConflicting = (variableName: string) =>
conflictingNames.includes(variableName);
const isPlaceholderConflicting = (placeholderName: string) =>
conflictingNames.includes(placeholderName);
return {
hasConflicts,
conflictingNames,
isVariableConflicting,
isPlaceholderConflicting,
};
}, [variables, placeholders]);
};
@@ -12,7 +12,13 @@ import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
import { useOrderByState } from "@/src/features/orderBy/hooks/useOrderByState";
import { promptsTableColsWithOptions } from "@/src/server/api/definitions/promptsTable";
import { NumberParam, StringParam, useQueryParams, withDefault } from "use-query-params";
import {
NumberParam,
StringParam,
useQueryParams,
withDefault,
useQueryParam,
} from "use-query-params";
import { createColumnHelper } from "@tanstack/react-table";
import { joinTableCoreAndMetrics } from "@/src/components/table/utils/joinTableCoreAndMetrics";
import { Skeleton } from "@/src/components/ui/skeleton";
@@ -40,7 +46,11 @@ type PromptTableRow = {
};
function createRow(
data: Partial<PromptTableRow> & { id: string; name: string; type: "folder" | "text" | "chat" }
data: Partial<PromptTableRow> & {
id: string;
name: string;
type: "folder" | "text" | "chat";
},
): PromptTableRow {
return {
version: undefined,
@@ -52,12 +62,14 @@ function createRow(
};
}
function isFolder(row: PromptTableRow): row is PromptTableRow & { type: "folder" } {
function isFolder(
row: PromptTableRow,
): row is PromptTableRow & { type: "folder" } {
return row.type === "folder";
}
function getDisplayName(fullPath: string, currentFolderPath: string): string {
return currentFolderPath === ''
return currentFolderPath === ""
? fullPath
: fullPath.substring(currentFolderPath.length + 1);
}
@@ -65,9 +77,9 @@ function getDisplayName(fullPath: string, currentFolderPath: string): string {
function createBreadcrumbItems(currentFolderPath: string) {
if (!currentFolderPath) return [];
const segments = currentFolderPath.split('/');
const segments = currentFolderPath.split("/");
return segments.map((name, i) => {
const folderPath = segments.slice(0, i + 1).join('/');
const folderPath = segments.slice(0, i + 1).join("/");
return {
name,
folderPath,
@@ -95,12 +107,27 @@ export function PromptTable() {
folder: StringParam,
});
const [searchQuery, setSearchQuery] = useQueryParam(
"search",
withDefault(StringParam, null),
);
// Reset pagination when search query changes
useEffect(() => {
setQueryParams({
pageIndex: 0,
pageSize: queryParams.pageSize,
folder: queryParams.folder,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
const paginationState = {
pageIndex: queryParams.pageIndex,
pageSize: queryParams.pageSize,
};
const currentFolderPath = queryParams.folder || '';
const currentFolderPath = queryParams.folder || "";
const prompts = api.prompts.all.useQuery(
{
@@ -110,6 +137,7 @@ export function PromptTable() {
filter: filterState,
orderBy: orderByState,
pathPrefix: currentFolderPath,
searchQuery: searchQuery || undefined,
},
{
enabled: Boolean(projectId),
@@ -167,7 +195,7 @@ export function PromptTable() {
const prefix = `${currentFolderPath}/`;
if (promptName.startsWith(prefix)) {
const remainingPath = promptName.substring(prefix.length);
const slashIndex = remainingPath.indexOf('/');
const slashIndex = remainingPath.indexOf("/");
if (slashIndex > 0) {
// Subfolder
@@ -181,7 +209,7 @@ export function PromptTable() {
}
} else {
// Root level
const slashIndex = promptName.indexOf('/');
const slashIndex = promptName.indexOf("/");
if (slashIndex > 0) {
const folderName = promptName.substring(0, slashIndex);
uniqueFolders.add(folderName);
@@ -197,11 +225,13 @@ export function PromptTable() {
// Add folder rows
for (const folderPath of uniqueFolders) {
const folderName = getDisplayName(folderPath, currentFolderPath);
combinedRows.push(createRow({
id: folderPath,
name: folderName,
type: "folder",
}));
combinedRows.push(
createRow({
id: folderPath,
name: folderName,
type: "folder",
}),
);
}
// Add matching prompts
@@ -216,7 +246,7 @@ export function PromptTable() {
labels: prompt.labels,
tags: prompt.tags,
numberOfObservations: Number(prompt.observationCount ?? 0),
})
}),
);
}
@@ -284,7 +314,7 @@ export function PromptTable() {
setQueryParams({
folder: rowData.id,
pageIndex: 0,
pageSize: queryParams.pageSize
pageSize: queryParams.pageSize,
});
}}
title={displayName || ""}
@@ -397,7 +427,7 @@ export function PromptTable() {
return (
<>
{currentFolderPath && (
<div className="pt-2 ml-2">
<div className="ml-2 pt-2">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
@@ -407,38 +437,40 @@ export function PromptTable() {
setQueryParams({
folder: undefined,
pageIndex: 0,
pageSize: queryParams.pageSize
pageSize: queryParams.pageSize,
});
}}
>
<Home className="h-4 w-4" />
</BreadcrumbLink>
</BreadcrumbItem>
{createBreadcrumbItems(currentFolderPath).flatMap((item, index, array) => [
index > 0 && (
<BreadcrumbSeparator key={`sep-${item.folderPath}`}>
<Slash />
</BreadcrumbSeparator>
),
<BreadcrumbItem key={item.folderPath}>
{index === array.length - 1 ? (
<BreadcrumbPage>{item.name}</BreadcrumbPage>
) : (
<BreadcrumbLink
className="cursor-pointer hover:underline"
onClick={() => {
setQueryParams({
folder: item.folderPath,
pageIndex: 0,
pageSize: queryParams.pageSize
});
}}
>
{item.name}
</BreadcrumbLink>
)}
</BreadcrumbItem>
])}
{createBreadcrumbItems(currentFolderPath).flatMap(
(item, index, array) => [
index > 0 && (
<BreadcrumbSeparator key={`sep-${item.folderPath}`}>
<Slash />
</BreadcrumbSeparator>
),
<BreadcrumbItem key={item.folderPath}>
{index === array.length - 1 ? (
<BreadcrumbPage>{item.name}</BreadcrumbPage>
) : (
<BreadcrumbLink
className="cursor-pointer hover:underline"
onClick={() => {
setQueryParams({
folder: item.folderPath,
pageIndex: 0,
pageSize: queryParams.pageSize,
});
}}
>
{item.name}
</BreadcrumbLink>
)}
</BreadcrumbItem>,
],
)}
</BreadcrumbList>
</Breadcrumb>
</div>
@@ -451,6 +483,14 @@ export function PromptTable() {
filterState={filterState}
setFilterState={useDebounce(setFilterState)}
columnsWithCustomSelect={["labels", "tags"]}
searchConfig={{
metadataSearchFields: ["Name", "Tags"],
updateQuery: useDebounce(setSearchQuery, 300),
currentQuery: searchQuery ?? undefined,
tableAllowsFullTextSearch: false,
setSearchType: undefined,
searchType: undefined,
}}
/>
<DataTable
columns={promptColumns}
@@ -468,11 +508,12 @@ export function PromptTable() {
isError: false,
data: processedRowData.rows?.map((item) => ({
id: item.id,
name: item.type === 'folder'
? item.name
: currentFolderPath
? item.name.substring(currentFolderPath.length + 1)
: item.name,
name:
item.type === "folder"
? item.name
: currentFolderPath
? item.name.substring(currentFolderPath.length + 1)
: item.name,
version: item.version,
createdAt: item.createdAt,
type: item.type,
@@ -8,6 +8,7 @@ import {
type CreatePromptTRPCType,
LATEST_PROMPT_LABEL,
PromptType,
extractVariables,
} from "@langfuse/shared";
import { type PrismaClient } from "@langfuse/shared/src/db";
import { removeLabelsFromPreviousPromptVersions } from "@/src/features/prompts/server/utils/updatePromptLabels";
@@ -16,6 +17,7 @@ import {
PromptContentSchema,
PromptService,
redis,
extractPlaceholderNames,
} from "@langfuse/shared/src/server";
export type CreatePromptParams = CreatePromptTRPCType & {
@@ -32,6 +34,22 @@ type DuplicatePromptParams = {
prisma: PrismaClient;
};
const extractChatVariableAndPlaceholderNames = (
chatPrompt: Array<any>,
): { variables: string[], placeholders: string[] } => {
const placeholders = extractPlaceholderNames(chatPrompt);
const variables: string[] = [];
for (const message of chatPrompt) {
if (message && 'content' in message && typeof message.content === 'string') {
variables.push(...extractVariables(message.content));
}
}
return { variables: [...new Set(variables)], placeholders: [...new Set(placeholders)] };
};
export const createPrompt = async ({
projectId,
name,
@@ -55,6 +73,17 @@ export const createPrompt = async ({
);
}
// Prevent naming collisions between variables and placeholders
if (type === PromptType.Chat && Array.isArray(prompt)) {
const { variables, placeholders } = extractChatVariableAndPlaceholderNames(prompt);
const conflictingNames = variables.filter(v => placeholders.includes(v));
if (conflictingNames.length > 0) {
throw new InvalidRequestError(
`Cannot create prompt, variables and placeholders must be unique, the following are not: ${conflictingNames.join(", ")}`
);
}
}
const finalLabels = [...labels, LATEST_PROMPT_LABEL]; // Newly created prompts are always labeled as 'latest'
// If tags are undefined, use the tags from the latest prompt version
@@ -185,7 +214,7 @@ export const duplicatePrompt = async ({
});
if (!existingPrompt) {
throw new Error(`Existing prompt not found: ${promptId}`);
throw new InvalidRequestError(`Existing prompt not found: ${promptId}`);
}
// if defined as single version, duplicate current prompt as new prompt v1
@@ -253,7 +282,7 @@ export const duplicatePrompt = async ({
return promptResult;
});
// If you need the created prompt, fetch it separately since createMany doesn't return created records
// Fetch the created prompt to return
const createdPrompt = await prisma.prompt.findFirst({
where: {
name,
@@ -40,6 +40,7 @@ const PromptFilterOptions = z.object({
orderBy: orderBy,
...paginationZod,
pathPrefix: z.string().optional(),
searchQuery: z.string().optional(),
});
export const promptRouter = createTRPCRouter({
@@ -90,6 +91,10 @@ export const promptRouter = createTRPCRouter({
? Prisma.sql` AND (p.name LIKE ${input.pathPrefix + "/%"} OR p.name = ${input.pathPrefix})`
: Prisma.empty;
const searchFilter = input.searchQuery
? Prisma.sql` AND (p.name ILIKE ${`%${input.searchQuery}%`} OR EXISTS (SELECT 1 FROM UNNEST(p.tags) AS tag WHERE tag ILIKE ${`%${input.searchQuery}%`}))`
: Prisma.empty;
const [prompts, promptCount] = await Promise.all([
// prompts
ctx.prisma.$queryRaw<Array<Prompt>>(
@@ -111,6 +116,7 @@ export const promptRouter = createTRPCRouter({
input.limit,
input.page,
pathFilter,
searchFilter,
),
),
// promptCount
@@ -123,6 +129,7 @@ export const promptRouter = createTRPCRouter({
1, // limit
0, // page,
pathFilter,
searchFilter,
),
),
]);
@@ -1233,6 +1240,7 @@ const generatePromptQuery = (
limit: number,
page: number,
pathFilter: Prisma.Sql = Prisma.empty,
searchFilter: Prisma.Sql = Prisma.empty,
) => {
return Prisma.sql`
SELECT
@@ -1244,11 +1252,13 @@ const generatePromptQuery = (
WHERE "project_id" = ${projectId}
${filterCondition}
${pathFilter}
${searchFilter}
GROUP BY name
)
AND "project_id" = ${projectId}
${filterCondition}
${pathFilter}
${searchFilter}
${orderCondition}
LIMIT ${limit} OFFSET ${page * limit};
`;
+348 -152
View File
@@ -48,15 +48,14 @@ import {
Hash,
BarChart3,
Table,
X,
Plus,
X,
} from "lucide-react";
import {
buildWidgetName,
buildWidgetDescription,
formatMetricName,
} from "@/src/features/widgets/utils";
import { Badge } from "@/src/components/ui/badge";
import {
MAX_PIVOT_TABLE_DIMENSIONS,
MAX_PIVOT_TABLE_METRICS,
@@ -284,40 +283,74 @@ export function WidgetForm({
setPivotDimensions(newDimensions);
};
// Helper functions for multiple metrics management
const addMetric = () => {
// Check if we've reached the maximum metrics limit
if (selectedMetrics.length >= MAX_PIVOT_TABLE_METRICS) {
showErrorToast(
"Error",
`Maximum ${MAX_PIVOT_TABLE_METRICS} metrics allowed for pivot tables`,
);
return;
// Helper function for updating pivot table metrics
const updatePivotMetric = (
index: number,
measure: string,
aggregation?: z.infer<typeof metricAggregations>,
) => {
const newMetrics = [...selectedMetrics];
if (measure && measure !== "none") {
let finalAggregation: z.infer<typeof metricAggregations>;
if (measure === "count") {
finalAggregation = "count";
} else {
// Get available aggregations for this measure at this index
const availableAggregations = getAvailableAggregations(index, measure);
if (aggregation && availableAggregations.includes(aggregation)) {
// Use provided aggregation if it's available
finalAggregation = aggregation as z.infer<typeof metricAggregations>;
} else {
// Use the first available aggregation as default
finalAggregation =
availableAggregations.length > 0
? availableAggregations[0]
: ("sum" as z.infer<typeof metricAggregations>);
}
}
const newMetric: SelectedMetric = {
id: `${finalAggregation}_${measure}`,
measure: measure,
aggregation: finalAggregation as z.infer<typeof metricAggregations>,
label: `${startCase(finalAggregation)} ${startCase(measure)}`,
};
// Set the metric at the specified index
newMetrics[index] = newMetric;
} else {
// Clear this metric and all subsequent ones
newMetrics.splice(index);
}
const newMetric: SelectedMetric = {
id: `${selectedAggregation}_${selectedMeasure}`,
measure: selectedMeasure,
aggregation: selectedAggregation,
label: `${startCase(selectedAggregation)} ${startCase(selectedMeasure)}`,
};
setSelectedMetrics(newMetrics);
};
// Check if this metric combination already exists
const existingMetric = selectedMetrics.find((m) => m.id === newMetric.id);
if (existingMetric) {
showErrorToast("Error", "This metric combination is already selected");
return;
// Add a new empty metric slot
const addNewMetricSlot = () => {
if (selectedMetrics.length < MAX_PIVOT_TABLE_METRICS) {
const newMetrics = [...selectedMetrics];
newMetrics.push({
id: `temp_${selectedMetrics.length}`,
measure: "",
aggregation: "sum" as z.infer<typeof metricAggregations>,
label: "",
});
setSelectedMetrics(newMetrics);
}
setSelectedMetrics([...selectedMetrics, newMetric]);
};
const removeMetric = (metricId: string) => {
setSelectedMetrics(selectedMetrics.filter((m) => m.id !== metricId));
};
const clearAllMetrics = () => {
setSelectedMetrics([]);
// Remove a metric slot and roll up subsequent ones
const removeMetricSlot = (index: number) => {
if (index > 0) {
// Can't remove the first metric (it's required)
const newMetrics = [...selectedMetrics];
newMetrics.splice(index, 1); // Remove only the metric at this index
setSelectedMetrics(newMetrics);
}
};
const traceFilterOptions = api.traces.filterOptions.useQuery(
@@ -542,6 +575,40 @@ export function WidgetForm({
// Get available metrics for the selected view
const availableMetrics = useMemo(() => {
const viewDeclaration = viewDeclarations[selectedView];
// For pivot tables, only show measures that still have available aggregations
if (selectedChartType === "PIVOT_TABLE") {
return Object.entries(viewDeclaration.measures)
.filter(([measureKey]) => {
// For count, there's only one aggregation option
if (measureKey === "count") {
return !selectedMetrics.some((m) => m.measure === "count");
}
// For other measures, check if there are any aggregations left
const selectedAggregationsForMeasure = selectedMetrics
.filter((m) => m.measure === measureKey)
.map((m) => m.aggregation);
const availableAggregationsForMeasure =
metricAggregations.options.filter(
(agg) =>
agg !== "histogram" &&
!selectedAggregationsForMeasure.includes(agg),
);
return availableAggregationsForMeasure.length > 0;
})
.map(([key]) => ({
value: key,
label: startCase(key),
}))
.sort((a, b) =>
a.label.localeCompare(b.label, "en", { sensitivity: "base" }),
);
}
// For regular charts, show all metrics
return Object.entries(viewDeclaration.measures)
.map(([key]) => ({
value: key,
@@ -550,7 +617,64 @@ export function WidgetForm({
.sort((a, b) =>
a.label.localeCompare(b.label, "en", { sensitivity: "base" }),
);
}, [selectedView]);
}, [selectedView, selectedChartType, selectedMetrics]);
// Get available aggregations for a specific metric index in pivot tables
const getAvailableAggregations = (
metricIndex: number,
measureKey: string,
): z.infer<typeof metricAggregations>[] => {
if (selectedChartType === "PIVOT_TABLE" && measureKey) {
return metricAggregations.options.filter(
(agg) =>
!selectedMetrics.some(
(m, idx) =>
idx !== metricIndex &&
m.measure === measureKey &&
m.aggregation === agg,
),
) as z.infer<typeof metricAggregations>[];
}
return metricAggregations.options as z.infer<typeof metricAggregations>[];
};
// Get available metrics for a specific metric index in pivot tables
const getAvailableMetrics = (metricIndex: number) => {
if (selectedChartType === "PIVOT_TABLE") {
const viewDeclaration = viewDeclarations[selectedView];
return Object.entries(viewDeclaration.measures)
.filter(([measureKey]) => {
// For count, there's only one aggregation option
if (measureKey === "count") {
return !selectedMetrics.some(
(m, idx) => idx !== metricIndex && m.measure === "count",
);
}
// For other measures, check if there are any aggregations left
const selectedAggregationsForMeasure = selectedMetrics
.filter((m, idx) => idx !== metricIndex && m.measure === measureKey)
.map((m) => m.aggregation);
const availableAggregationsForMeasure =
metricAggregations.options.filter(
(agg) =>
agg !== "histogram" &&
!selectedAggregationsForMeasure.includes(agg),
);
return availableAggregationsForMeasure.length > 0;
})
.map(([key]) => ({
value: key,
label: startCase(key),
}))
.sort((a, b) =>
a.label.localeCompare(b.label, "en", { sensitivity: "base" }),
);
}
return availableMetrics;
};
// Get available dimensions for the selected view
const availableDimensions = useMemo(() => {
@@ -584,10 +708,12 @@ export function WidgetForm({
// Determine metrics based on chart type
const queryMetrics =
selectedChartType === "PIVOT_TABLE"
? selectedMetrics.map((metric) => ({
measure: metric.measure,
aggregation: metric.aggregation,
}))
? selectedMetrics
.filter((metric) => metric.measure && metric.measure !== "")
.map((metric) => ({
measure: metric.measure,
aggregation: metric.aggregation,
}))
: [
{
measure: selectedMeasure,
@@ -701,7 +827,10 @@ export function WidgetForm({
}
// Validate pivot table requirements
if (selectedChartType === "PIVOT_TABLE" && selectedMetrics.length === 0) {
const validMetrics = selectedMetrics.filter(
(m) => m.measure && m.measure !== "",
);
if (selectedChartType === "PIVOT_TABLE" && validMetrics.length === 0) {
showErrorToast(
"Error",
"At least one metric is required for pivot tables",
@@ -721,7 +850,7 @@ export function WidgetForm({
: [],
metrics:
selectedChartType === "PIVOT_TABLE"
? selectedMetrics.map((metric) => ({
? validMetrics.map((metric) => ({
measure: metric.measure,
agg: metric.aggregation,
}))
@@ -766,9 +895,13 @@ export function WidgetForm({
// For pivot tables, extract actual metric names for the new formatting
const isPivotTable = selectedChartType === "PIVOT_TABLE";
const validMetricsForNaming = selectedMetrics.filter(
(m) => m.measure && m.measure !== "",
);
const metricNames =
isPivotTable && selectedMetrics.length > 0
? selectedMetrics.map((m) => m.id) // Use the ID which is "${aggregation}_${measure}"
isPivotTable && validMetricsForNaming.length > 0
? validMetricsForNaming.map((m) => m.id) // Use the ID which is "${aggregation}_${measure}"
: undefined;
const suggested = buildWidgetName({
@@ -777,7 +910,7 @@ export function WidgetForm({
dimension: dimensionForNaming,
view: selectedView,
metrics: metricNames,
isMultiMetric: isPivotTable && selectedMetrics.length > 0,
isMultiMetric: isPivotTable && validMetricsForNaming.length > 0,
});
setWidgetName(suggested);
@@ -804,9 +937,12 @@ export function WidgetForm({
// For pivot tables, extract actual metric names for the new formatting
const isPivotTable = selectedChartType === "PIVOT_TABLE";
const validMetricsForDescription = selectedMetrics.filter(
(m) => m.measure && m.measure !== "",
);
const metricNames =
isPivotTable && selectedMetrics.length > 0
? selectedMetrics.map((m) => m.id) // Use the ID which is "${aggregation}_${measure}"
isPivotTable && validMetricsForDescription.length > 0
? validMetricsForDescription.map((m) => m.id) // Use the ID which is "${aggregation}_${measure}"
: undefined;
const suggested = buildWidgetDescription({
@@ -816,7 +952,7 @@ export function WidgetForm({
view: selectedView,
filters: userFilterState,
metrics: metricNames,
isMultiMetric: isPivotTable && selectedMetrics.length > 0,
isMultiMetric: isPivotTable && validMetricsForDescription.length > 0,
});
setWidgetDescription(suggested);
@@ -855,9 +991,42 @@ export function WidgetForm({
value={selectedView}
onValueChange={(value) => {
if (value !== selectedView) {
const newView = value as z.infer<typeof views>;
const newViewDeclaration = viewDeclarations[newView];
// Reset regular chart fields
setSelectedMeasure("count");
setSelectedAggregation("count");
setSelectedDimension("none");
// Handle pivot table metrics - filter out invalid measures for the new view
if (selectedChartType === "PIVOT_TABLE") {
const validMetrics = selectedMetrics.filter(
(metric) =>
metric.measure in newViewDeclaration.measures,
);
// Ensure we have at least one valid metric (count is always available)
if (validMetrics.length === 0) {
validMetrics.push({
id: "count_count",
measure: "count",
aggregation: "count" as z.infer<
typeof metricAggregations
>,
label: "Count Count",
});
}
setSelectedMetrics(validMetrics);
// Handle pivot table dimensions - filter out invalid dimensions for the new view
const validDimensions = pivotDimensions.filter(
(dimension) =>
dimension in newViewDeclaration.dimensions,
);
setPivotDimensions(validDimensions);
}
}
setSelectedView(value as z.infer<typeof views>);
}}
@@ -887,118 +1056,145 @@ export function WidgetForm({
{/* For pivot tables: multiple metrics selection */}
{selectedChartType === "PIVOT_TABLE" ? (
<div className="space-y-3">
{/* Selected metrics display */}
{selectedMetrics.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
Selected Metrics ({selectedMetrics.length})
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={clearAllMetrics}
className="h-auto p-1 text-xs"
>
Clear All
</Button>
</div>
<div className="flex flex-wrap gap-1">
{selectedMetrics.map((metric) => (
<Badge
key={metric.id}
variant="secondary"
className="flex items-center gap-1"
>
<span className="text-xs">{metric.label}</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeMetric(metric.id)}
className="h-auto p-0 hover:bg-transparent"
>
<X className="h-3 w-3" />
</Button>
</Badge>
))}
</div>
</div>
{/* Metric selection dropdowns */}
{Array.from(
{ length: Math.max(1, selectedMetrics.length) },
(_, index) => {
const isEnabled =
index === 0 ||
(selectedMetrics[index - 1] &&
selectedMetrics[index - 1].measure);
const currentMetric = selectedMetrics[index];
const currentMeasure = currentMetric?.measure || "";
const currentAggregation =
currentMetric?.aggregation || "sum";
const metricsForIndex = getAvailableMetrics(index);
const aggregationsForIndex = getAvailableAggregations(
index,
currentMeasure,
);
const canEdit = metricsForIndex.length > 0;
return (
<div key={index} className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor={`pivot-metric-${index}`}>
Metric {index + 1}{" "}
{index === 0 ? "(Required)" : "(Optional)"}
</Label>
{index > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeMetricSlot(index)}
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex-1">
<Select
value={currentMeasure}
onValueChange={(value) =>
updatePivotMetric(
index,
value,
// Don't pass current aggregation when measure changes
// Let the function determine the best default
undefined,
)
}
disabled={!isEnabled || !canEdit}
>
<SelectTrigger id={`pivot-metric-${index}`}>
<SelectValue
placeholder={
!isEnabled
? "Select previous metric first"
: !canEdit
? "No more measures available"
: "Select measure"
}
/>
</SelectTrigger>
<SelectContent>
{metricsForIndex.map((metric) => {
const meta =
viewDeclarations[selectedView]
?.measures?.[metric.value];
return (
<WidgetPropertySelectItem
key={metric.value}
value={metric.value}
label={metric.label}
description={meta?.description}
unit={meta?.unit}
type={meta?.type}
/>
);
})}
</SelectContent>
</Select>
</div>
{currentMeasure && currentMeasure !== "count" && (
<div className="flex-1">
<Select
value={currentAggregation}
onValueChange={(value) =>
updatePivotMetric(
index,
currentMeasure,
value as z.infer<
typeof metricAggregations
>,
)
}
>
<SelectTrigger>
<SelectValue placeholder="Select aggregation" />
</SelectTrigger>
<SelectContent>
{aggregationsForIndex.map(
(aggregation) => (
<SelectItem
key={aggregation}
value={aggregation}
>
{startCase(aggregation)}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
)}
</div>
</div>
);
},
)}
{/* Add new metric controls */}
<div className="space-y-2 rounded-md border p-3">
<span className="text-sm font-medium">Add Metric</span>
<div className="grid grid-cols-2 gap-2">
<Select
value={selectedMeasure}
onValueChange={(value) => setSelectedMeasure(value)}
{/* Add new metric button */}
{selectedMetrics.length < MAX_PIVOT_TABLE_METRICS &&
getAvailableMetrics(selectedMetrics.length).length >
0 && (
<Button
type="button"
variant="outline"
size="sm"
onClick={addNewMetricSlot}
className="w-full"
>
<SelectTrigger>
<SelectValue placeholder="Select measure" />
</SelectTrigger>
<SelectContent>
{availableMetrics.map((metric) => {
const meta =
viewDeclarations[selectedView]?.measures?.[
metric.value
];
return (
<WidgetPropertySelectItem
key={metric.value}
value={metric.value}
label={metric.label}
description={meta?.description}
unit={meta?.unit}
type={meta?.type}
/>
);
})}
</SelectContent>
</Select>
{selectedMeasure !== "count" && (
<Select
value={selectedAggregation}
onValueChange={(value) =>
setSelectedAggregation(
value as z.infer<typeof metricAggregations>,
)
}
>
<SelectTrigger>
<SelectValue placeholder="Aggregation" />
</SelectTrigger>
<SelectContent>
{metricAggregations.options.map((aggregation) => (
<SelectItem
key={aggregation}
value={aggregation}
>
{startCase(aggregation)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={addMetric}
className="w-full"
disabled={
selectedMetrics.length >= MAX_PIVOT_TABLE_METRICS
}
>
<Plus className="mr-1 h-3 w-3" />
Add Metric
{selectedMetrics.length >= MAX_PIVOT_TABLE_METRICS &&
` (Max ${MAX_PIVOT_TABLE_METRICS})`}
</Button>
</div>
<Plus className="mr-1 h-3 w-3" />
Add Metric {selectedMetrics.length + 1}
</Button>
)}
</div>
) : (
/* For regular charts: single metric selection */
+2 -2
View File
@@ -195,7 +195,7 @@ export function SSOButtons({
loading={providerSigningIn === "github"}
>
<FaGithub className="mr-3" size={18} />
Github
GitHub
</Button>
)}
{authProviders.githubEnterprise && (
@@ -205,7 +205,7 @@ export function SSOButtons({
loading={providerSigningIn === "github-enterprise"}
>
<FaGithub className="mr-3" size={18} />
Github Enterprise
GitHub Enterprise
</Button>
)}
{authProviders.gitlab && (
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "worker",
"version": "3.77.0",
"version": "3.78.2",
"description": "",
"license": "MIT",
"private": true,
@@ -13,7 +13,7 @@
"start": "dotenv -e ../.env -- node dist/index.js",
"build": "tsc",
"dev": "dotenv -e ../.env -- nodemon src/index.ts",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 21",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 0",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
"refill-ingestion-events": "dotenv -e ../.env -- tsx src/scripts/s3-ingestion-event-relpay.ts",
"refill-billing-event": "dotenv -e ../.env -- tsx src/scripts/refill-billing-event.ts"
@@ -313,6 +313,187 @@ describe("create experiment jobs", () => {
}, 10_000);
});
describe("create experiment jobs with placeholders", () => {
const setupPlaceholderTest = async (promptConfig: any, datasetItemInput: any) => {
await pruneDatabase();
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
const datasetId = randomUUID();
const runId = randomUUID();
const promptId = "03f834cc-c089-4bcb-9add-b14cadcdf47c";
// Create prompt
await prisma.prompt.create({
data: {
id: promptId,
projectId,
name: promptConfig.name,
prompt: promptConfig.prompt,
type: "chat",
version: 1,
createdBy: "test-user",
},
});
// Create dataset
await prisma.dataset.create({
data: {
id: datasetId,
projectId,
name: "Test Dataset",
},
});
// Create dataset run with metadata
await kyselyPrisma.$kysely
.insertInto("dataset_runs")
.values({
id: runId,
name: "Test Run",
project_id: projectId,
dataset_id: datasetId,
metadata: {
prompt_id: promptId,
provider: "openai",
model: "gpt-3.5-turbo",
model_params: { temperature: 0 },
},
})
.execute();
// Create dataset item
await prisma.datasetItem.create({
data: {
id: randomUUID(),
projectId,
datasetId,
input: datasetItemInput,
},
});
// Create API key
await prisma.llmApiKeys.create({
data: {
id: randomUUID(),
projectId,
provider: "openai",
adapter: LLMAdapter.OpenAI,
displaySecretKey: "test-key",
secretKey: encrypt("test-key"),
},
});
return { projectId, datasetId, runId };
};
test("creates experiment job with multiple placeholders containing variables", async () => {
const { projectId, datasetId, runId } = await setupPlaceholderTest(
{
name: "Test Multiple Placeholders",
prompt: [
{ role: "system", content: "You are a helpful assistant." },
{ type: "placeholder", name: "conversation_history" },
{ type: "placeholder", name: "user_context" },
{ role: "user", content: "Please help me." }
]
},
{
conversation_history: [
{ role: "user", content: "Hello {{name}}!" },
{ role: "assistant", content: "Hi there!" }
],
user_context: [
{ role: "system", content: "User is a {{role}}" }
],
name: "John",
role: "developer"
}
);
const payload = {
projectId,
datasetId,
runId,
};
await createExperimentJob({ event: payload });
const runItems = await kyselyPrisma.$kysely
.selectFrom("dataset_run_items")
.selectAll()
.where("project_id", "=", projectId)
.execute();
expect(runItems.length).toBe(1);
expect(runItems[0].project_id).toBe(projectId);
expect(runItems[0].dataset_run_id).toBe(runId);
expect(runItems[0].trace_id).toBeDefined();
}, 10_000);
test("handles empty placeholder arrays", async () => {
const { projectId, datasetId, runId } = await setupPlaceholderTest(
{
name: "Test Empty Placeholder",
prompt: [
{ role: "system", content: "You are a helpful assistant." },
{ type: "placeholder", name: "empty_history" },
{ role: "user", content: "Start conversation." }
]
},
{ empty_history: [] }
);
const payload = {
projectId,
datasetId,
runId,
};
await createExperimentJob({ event: payload });
const runItems = await kyselyPrisma.$kysely
.selectFrom("dataset_run_items")
.selectAll()
.where("project_id", "=", projectId)
.execute();
expect(runItems.length).toBe(1);
expect(runItems[0].project_id).toBe(projectId);
expect(runItems[0].dataset_run_id).toBe(runId);
expect(runItems[0].trace_id).toBeDefined();
}, 10_000);
test("fails when placeholder has invalid message format", async () => {
const { projectId, datasetId, runId } = await setupPlaceholderTest(
{
name: "Test Invalid Placeholder",
prompt: [
{ role: "system", content: "You are a helpful assistant." },
{ type: "placeholder", name: "invalid_messages" },
{ role: "user", content: "Help me." }
]
},
{ invalid_messages: "this should be an array or object" }
);
const payload = {
projectId,
datasetId,
runId,
};
await createExperimentJob({ event: payload });
// Should not create run items for invalid placeholder format
const runItems = await kyselyPrisma.$kysely
.selectFrom("dataset_run_items")
.selectAll()
.where("project_id", "=", projectId)
.execute();
expect(runItems.length).toBe(0);
}, 10_000);
});
describe("create experiment job calls with langfuse server side tracing", async () => {
await pruneDatabase();
const mockEvent = {
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.77.0";
export const VERSION = "v3.78.2";
+1 -1
View File
@@ -232,6 +232,6 @@ const EnvSchema = z.object({
});
export const env: z.infer<typeof EnvSchema> =
process.env.DOCKER_BUILD === "1"
process.env.DOCKER_BUILD === "1" // eslint-disable-line turbo/no-undeclared-env-vars
? (process.env as any)
: EnvSchema.parse(removeEmptyEnvVariables(process.env));
@@ -49,6 +49,7 @@ import {
TraceDomain,
Observation,
DatasetItem,
QUEUE_ERROR_MESSAGES,
} from "@langfuse/shared";
import { kyselyPrisma, prisma } from "@langfuse/shared/src/db";
import { backOff } from "exponential-backoff";
@@ -877,11 +878,11 @@ export async function extractVariablesFromTracingData({
// user facing errors
if (!observation) {
logger.warn(
`Observation ${mapping.objectName} for trace ${traceId} not found. Please ensure the mapped data exists and consider extending the job delay.`,
`Observation ${mapping.objectName} for trace ${traceId} not found. ${QUEUE_ERROR_MESSAGES.MAPPED_DATA_ERROR}`,
);
// this should only happen for deleted data or data replication lags across clickhouse nodes.
throw new LangfuseNotFoundError(
`Observation ${mapping.objectName} for trace ${traceId} not found. Please ensure the mapped data exists and consider extending the job delay.`,
`Observation ${mapping.objectName} for trace ${traceId} not found. ${QUEUE_ERROR_MESSAGES.MAPPED_DATA_ERROR}`,
);
}
@@ -18,11 +18,13 @@ import {
import { kyselyPrisma, prisma } from "@langfuse/shared/src/db";
import { type ExperimentCreateEventSchema } from "@langfuse/shared/src/server";
import {
datasetItemMatchesVariable,
extractVariables,
InvalidRequestError,
LangfuseNotFoundError,
type Prisma,
extractVariables,
datasetItemMatchesVariable,
PromptType,
QUEUE_ERROR_MESSAGES,
stringifyValue,
} from "@langfuse/shared";
import { backOff } from "exponential-backoff";
@@ -45,11 +47,14 @@ const replaceVariablesInPrompt = (
prompt: PromptContent,
itemInput: Record<string, any>,
variables: string[],
placeholderNames: string[] = [],
): ChatMessage[] => {
const processContent = (content: string) => {
// Extract only relevant variables from itemInput
// Extract only Handlebars variables from itemInput (exclude message placeholders)
const filteredContext = Object.fromEntries(
Object.entries(itemInput).filter(([key]) => variables.includes(key)),
Object.entries(itemInput).filter(
([key]) => variables.includes(key) && !placeholderNames.includes(key),
),
);
// Apply Handlebars ONLY if the content contains `{{variable}}` pattern
@@ -70,42 +75,47 @@ const replaceVariablesInPrompt = (
];
}
const placeholderNames = extractPlaceholderNames(prompt as PromptMessage[]);
const placeholderValues: MessagePlaceholderValues = {};
// itemInput to placeholderValues
for (const placeholderName of placeholderNames) {
if (!(placeholderName in itemInput)) {
// TODO: handle missing placeholder values
// throw new Error(`Missing placeholder value for '${placeholderName}'`);
continue;
// TODO: should we throw?
throw new Error(`Missing placeholder value for '${placeholderName}'`);
}
const value = itemInput[placeholderName];
// for stringified arrays (e.g. from dataset processing)
let actualValue = value;
if (typeof value === 'string') {
if (typeof value === "string") {
try {
actualValue = JSON.parse(value);
} catch (_e) {
throw new Error(`Invalid placeholder value for '${placeholderName}': unable to parse JSON`);
throw new Error(
`Invalid placeholder value for '${placeholderName}': unable to parse JSON`,
);
}
}
if (!Array.isArray(actualValue)) {
throw new Error(`Placeholder '${placeholderName}' must be an array of messages`);
throw new Error(
`Placeholder '${placeholderName}' must be an array of messages`,
);
}
const validMessages = actualValue.every(msg =>
typeof msg === 'object' &&
msg !== null &&
'role' in msg &&
'content' in msg
const validMessages = actualValue.every(
(msg) =>
typeof msg === "object" &&
msg !== null &&
"role" in msg &&
"content" in msg,
);
if (!validMessages) {
throw new Error(`Invalid placeholder value for '${placeholderName}': messages must have 'role' and 'content' properties`);
throw new Error(
`Invalid placeholder value for '${placeholderName}': messages must have 'role' and 'content' properties`,
);
}
placeholderValues[placeholderName] = actualValue.map(msg => ({
placeholderValues[placeholderName] = actualValue.map((msg) => ({
...msg,
type: ChatMessageType.PublicAPICreated as const,
}));
@@ -114,11 +124,9 @@ const replaceVariablesInPrompt = (
const compiledMessages = compileChatMessages(
prompt as PromptMessage[],
placeholderValues,
{}
{},
);
// TODO: validate correctness
// handlebars variable substitution to all messages
return compiledMessages.map((message) => ({
...message,
content: processContent(message.content),
@@ -227,13 +235,13 @@ export const createExperimentJob = async ({
});
if (!apiKey) {
throw new LangfuseNotFoundError(
`API key for provider ${provider} not found`,
`${QUEUE_ERROR_MESSAGES.API_KEY_ERROR} ${provider} not found`,
);
}
const validatedApiKey = LLMApiKeySchema.safeParse(apiKey);
if (!validatedApiKey.success) {
throw new InvalidRequestError(
`API key for provider ${provider} not found.`,
`${QUEUE_ERROR_MESSAGES.API_KEY_ERROR} ${provider} not found.`,
);
}
@@ -251,15 +259,16 @@ export const createExperimentJob = async ({
// extract variables from prompt
const extractedVariables = extractVariables(
prompt?.type === "text"
prompt?.type === PromptType.Text
? (prompt.prompt?.toString() ?? "")
: JSON.stringify(prompt.prompt),
);
// also extract placeholder names if prompt is an array
const placeholderNames = prompt?.type !== "text" && Array.isArray(validatedPrompt.data)
? extractPlaceholderNames(validatedPrompt.data as PromptMessage[])
: [];
// also extract placeholder names if prompt is a chat prompt
const placeholderNames =
prompt?.type === PromptType.Chat && Array.isArray(validatedPrompt.data)
? extractPlaceholderNames(validatedPrompt.data as PromptMessage[])
: [];
const allVariables = [...extractedVariables, ...placeholderNames];
// validate dataset items against prompt configuration
@@ -275,7 +284,7 @@ export const createExperimentJob = async ({
if (!validatedDatasetItems.length) {
throw new InvalidRequestError(
`No Dataset ${datasetId} item input matches expected prompt variable format`,
`No Dataset ${datasetId} item input matches expected prompt variables or placeholders format`,
);
}
@@ -305,7 +314,8 @@ export const createExperimentJob = async ({
messages = replaceVariablesInPrompt(
validatedPrompt.data,
datasetItem.input, // validated format
extractedVariables,
allVariables,
placeholderNames,
);
} catch (error) {
// skip this dataset item if there is an error replacing variables
+14 -11
View File
@@ -1,5 +1,10 @@
import { Job } from "bullmq";
import { ApiError, BaseError, LangfuseNotFoundError } from "@langfuse/shared";
import {
ApiError,
BaseError,
LangfuseNotFoundError,
QUEUE_ERROR_MESSAGES,
} from "@langfuse/shared";
import { kyselyPrisma } from "@langfuse/shared/src/db";
import { sql } from "kysely";
import {
@@ -150,22 +155,20 @@ export const evalJobExecutorQueueProcessor = async (
e instanceof LangfuseNotFoundError ||
(e instanceof BaseError &&
e.message.includes(
"Could not parse response content as the length limit was reached",
QUEUE_ERROR_MESSAGES.OUTPUT_TOKENS_TOO_LONG_ERROR,
)) || // output tokens too long
(e instanceof BaseError && e.message.includes("API key for provider")) || // api key not provided
(e instanceof BaseError &&
e.message.includes(
"`No default model or custom model found for project",
)) || // api key not provided
e.message.includes(QUEUE_ERROR_MESSAGES.API_KEY_ERROR)) || // api key not provided
(e instanceof BaseError &&
e.message.includes(QUEUE_ERROR_MESSAGES.NO_DEFAULT_MODEL_ERROR)) || // api key not provided
(e instanceof ApiError && e.httpCode >= 400 && e.httpCode < 500) || // do not error and retry on 4xx errors. They are visible to the user in the UI but do not alert us.
(e instanceof ApiError && e.message.includes("TypeError")) || // Zod parsing the response failed. User should update prompt to consistently return expected output structure.
(e instanceof ApiError &&
e.message.includes("Error: Unterminated string in JSON at position")) || // When evaluator model is configured with too low max_tokens, the structured output response is invalid JSON
(e instanceof ApiError && e.message.includes("is not valid JSON")) || // When evaluator model is not consistently returning valid JSON on structured output calls
e.message.includes(QUEUE_ERROR_MESSAGES.TOO_LOW_MAX_TOKENS_ERROR)) || // When evaluator model is configured with too low max_tokens, the structured output response is invalid JSON
(e instanceof ApiError &&
e.message.includes(QUEUE_ERROR_MESSAGES.INVALID_JSON_ERROR)) || // When evaluator model is not consistently returning valid JSON on structured output calls
(e instanceof BaseError &&
e.message.includes(
"Please ensure the mapped data exists and consider extending the job delay.",
)) // Trace not found.
e.message.includes(QUEUE_ERROR_MESSAGES.MAPPED_DATA_ERROR)) // Trace not found.
) {
return;
}
@@ -26,7 +26,7 @@ export class ClickhouseWriter {
queue: ClickhouseQueue;
isIntervalFlushInProgress: boolean;
intervalId: NodeJS.Timeout | null = null;
intervalId: NodeJS.Timeout | null = null; // eslint-disable-line no-undef
private constructor() {
this.batchSize = env.LANGFUSE_INGESTION_CLICKHOUSE_WRITE_BATCH_SIZE;
@@ -239,11 +239,11 @@ export class ClickhouseWriter {
}
export enum TableName {
Traces = "traces",
TracesMt = "traces_mt",
Scores = "scores",
Observations = "observations",
BlobStorageFileLog = "blob_storage_file_log",
Traces = "traces", // eslint-disable-line no-unused-vars
TracesMt = "traces_mt", // eslint-disable-line no-unused-vars
Scores = "scores", // eslint-disable-line no-unused-vars
Observations = "observations", // eslint-disable-line no-unused-vars
BlobStorageFileLog = "blob_storage_file_log", // eslint-disable-line no-unused-vars
}
type RecordInsertType<T extends TableName> = T extends TableName.Scores
+146 -9
View File
@@ -71,7 +71,11 @@ CREATE TABLE traces_all_amt
`name` SimpleAggregateFunction(anyLast, String),
-- Metadata properties
`metadata` AggregateFunction(argMax, Map(LowCardinality(String), String), DateTime64(3)),
-- Metadata takes the last seen value in this form.
-- If we spread it across multiple rows (keys + values) we might be able to allow updates/overwrites.
-- Indexing speed in this case is unclear though.
`metadata_argmax` AggregateFunction(argMax, Map(LowCardinality(String), String), DateTime64(3)),
`metadata` SimpleAggregateFunction(anyLast, Map(String, String)),
`user_id_argmax` AggregateFunction(argMax, String, DateTime64(3)),
`session_id_argmax` AggregateFunction(argMax, String, DateTime64(3)),
`environment_argmax` AggregateFunction(argMax, String, DateTime64(3)),
@@ -121,14 +125,15 @@ SELECT
anyLast(t0.name) as name,
-- Metadata properties
argMaxState(t0.metadata, t0.event_ts) as metadata,
argMaxState(t0.metadata, if(t0.metadata <> '{}', t0.event_ts, toDateTime64(0,3))) as metadata_argmax,
anyLastMap(t0.metadata) as metadata,
argMaxState(t0.user_id, if(t0.user_id <> '', t0.event_ts, toDateTime64(0, 3))) as user_id_argmax,
argMaxState(t0.session_id, if(t0.session_id <> '', t0.event_ts, toDateTime64(0, 3))) as session_id_argmax,
argMaxState(t0.environment, if(t0.environment <> '', t0.event_ts, toDateTime64(0, 3))) as environment_argmax,
anyLast(t0.user_id) as user_id,
anyLast(t0.session_id) as session_id,
anyLast(t0.environment) as environment,
groupUniqArrayArray(t0.tags) as tags,
groupUniqArrayArray(t0.tags) as tags,
argMaxState(t0.version, if(t0.version <> '', t0.event_ts, toDateTime64(0, 3))) as version,
argMaxState(t0.release, if(t0.release <> '', t0.event_ts, toDateTime64(0, 3))) as release,
@@ -165,7 +170,8 @@ CREATE TABLE traces_7d_amt
`name` SimpleAggregateFunction(anyLast, String),
-- Metadata properties
`metadata` AggregateFunction(argMax, Map(LowCardinality(String), String), DateTime64(3)),
`metadata_argmax` AggregateFunction(argMax, Map(LowCardinality(String), String), DateTime64(3)),
`metadata` SimpleAggregateFunction(anyLast, Map(String, String)),
`user_id_argmax` AggregateFunction(argMax, String, DateTime64(3)),
`session_id_argmax` AggregateFunction(argMax, String, DateTime64(3)),
`environment_argmax` AggregateFunction(argMax, String, DateTime64(3)),
@@ -215,14 +221,15 @@ SELECT
anyLast(t0.name) as name,
-- Metadata properties
argMaxState(t0.metadata, t0.event_ts) as metadata,
argMaxState(t0.metadata, if(t0.metadata <> '{}', t0.event_ts, toDateTime64(0,3))) as metadata_argmax,
anyLastMap(t0.metadata) as metadata,
argMaxState(t0.user_id, if(t0.user_id <> '', t0.event_ts, toDateTime64(0, 3))) as user_id_argmax,
argMaxState(t0.session_id, if(t0.session_id <> '', t0.event_ts, toDateTime64(0, 3))) as session_id_argmax,
argMaxState(t0.environment, if(t0.environment <> '', t0.event_ts, toDateTime64(0, 3))) as environment_argmax,
anyLast(t0.user_id) as user_id,
anyLast(t0.session_id) as session_id,
anyLast(t0.environment) as environment,
groupUniqArrayArray(t0.tags) as tags,
groupUniqArrayArray(t0.tags) as tags,
argMaxState(t0.version, if(t0.version <> '', t0.event_ts, toDateTime64(0, 3))) as version,
argMaxState(t0.release, if(t0.release <> '', t0.event_ts, toDateTime64(0, 3))) as release,
@@ -259,7 +266,8 @@ CREATE TABLE traces_30d_amt
`name` SimpleAggregateFunction(anyLast, String),
-- Metadata properties
`metadata` AggregateFunction(argMax, Map(LowCardinality(String), String), DateTime64(3)),
`metadata_argmax` AggregateFunction(argMax, Map(LowCardinality(String), String), DateTime64(3)),
`metadata` SimpleAggregateFunction(anyLast, Map(String, String)),
`user_id_argmax` AggregateFunction(argMax, String, DateTime64(3)),
`session_id_argmax` AggregateFunction(argMax, String, DateTime64(3)),
`environment_argmax` AggregateFunction(argMax, String, DateTime64(3)),
@@ -309,14 +317,15 @@ SELECT
anyLast(t0.name) as name,
-- Metadata properties
argMaxState(t0.metadata, t0.event_ts) as metadata,
argMaxState(t0.metadata, if(t0.metadata <> '{}', t0.event_ts, toDateTime64(0,3))) as metadata_argmax,
anyLastMap(t0.metadata) as metadata,
argMaxState(t0.user_id, if(t0.user_id <> '', t0.event_ts, toDateTime64(0, 3))) as user_id_argmax,
argMaxState(t0.session_id, if(t0.session_id <> '', t0.event_ts, toDateTime64(0, 3))) as session_id_argmax,
argMaxState(t0.environment, if(t0.environment <> '', t0.event_ts, toDateTime64(0, 3))) as environment_argmax,
anyLast(t0.user_id) as user_id,
anyLast(t0.session_id) as session_id,
anyLast(t0.environment) as environment,
groupUniqArrayArray(t0.tags) as tags,
groupUniqArrayArray(t0.tags) as tags,
argMaxState(t0.version, if(t0.version <> '', t0.event_ts, toDateTime64(0, 3))) as version,
argMaxState(t0.release, if(t0.release <> '', t0.event_ts, toDateTime64(0, 3))) as release,
@@ -342,6 +351,8 @@ FROM traces_mt t0
GROUP BY project_id, id;
```
## Query AggregatingMergeTrees
We can query the properties of the resulting AggregatingMergeTree via (make sure to pick the right timeframe:
```sql
SELECT
@@ -386,3 +397,129 @@ SELECT
FROM traces_all_amt
LIMIT 100;
```
## Find discrepancies
We can identify discrepancies in the original and the new data using
```sql
-- Query to compare traces_all_amt with traces table and identify discrepancies
WITH amt_data AS (
-- First get the finalized values from the AMT table
SELECT project_id,
id,
start_time,
end_time,
finalizeAggregation(name_argmax) AS name_argmax_value,
name AS name_anylast_value,
finalizeAggregation(metadata_argmax) AS metadata_argmax_value,
metadata AS metadata_anylast_value,
finalizeAggregation(user_id_argmax) AS user_id_argmax_value,
user_id AS user_id_anylast_value,
finalizeAggregation(session_id_argmax) AS session_id_argmax_value,
session_id AS session_id_anylast_value,
finalizeAggregation(environment_argmax) AS environment_argmax_value,
environment AS environment_anylast_value,
tags,
finalizeAggregation(version) AS version_value,
finalizeAggregation(release) AS release_value,
finalizeAggregation(bookmarked) AS bookmarked_value,
finalizeAggregation(public) AS public_value,
finalizeAggregation(input_argmax) AS input_argmax_value,
input AS input_anylast_value,
finalizeAggregation(output_argmax) AS output_argmax_value,
output AS output_anylast_value,
created_at,
updated_at
FROM traces_all_amt
)
-- Main query to compare AMT with original traces table
SELECT t.project_id,
t.id,
-- Identify differences between tables
t.timestamp != a.start_time AS timestamp_diff,
t.name != a.name_anylast_value AS name_diff,
t.user_id != a.user_id_anylast_value AS user_id_diff,
t.session_id != a.session_id_anylast_value AS session_id_diff,
t.release != a.release_value AS release_diff,
t.version != a.version_value AS version_diff,
t.public != a.public_value AS public_diff,
t.bookmarked != a.bookmarked_value AS bookmarked_diff,
arraySort(t.tags) != arraySort(a.tags) AS tags_diff,
t.input != a.input_anylast_value AS input_diff,
t.output != a.output_anylast_value AS output_diff,
t.metadata != a.metadata_anylast_value AS metadata_diff,
a.name_argmax_value != a.name_anylast_value AS amt_name_diff,
a.user_id_argmax_value != a.user_id_anylast_value AS amt_user_id_diff,
a.session_id_argmax_value != a.session_id_anylast_value AS amt_session_id_diff,
a.environment_argmax_value != a.environment_anylast_value AS amt_environment_diff,
a.input_argmax_value != a.input_anylast_value AS amt_input_diff,
a.output_argmax_value != a.output_anylast_value AS amt_output_diff,
a.metadata_argmax_value != a.metadata_anylast_value AS amt_metadata_diff,
-- Include original values for comparison
t.timestamp AS traces_timestamp,
a.start_time AS amt_start_time,
t.name AS traces_name,
a.name_anylast_value AS amt_name_anylast,
a.name_argmax_value AS amt_name_argmax,
t.user_id AS traces_user_id,
a.user_id_anylast_value AS amt_user_id_anylast,
a.user_id_argmax_value AS amt_user_id_argmax,
t.session_id AS traces_session_id,
a.session_id_anylast_value AS amt_session_id_anylast,
a.session_id_argmax_value AS amt_session_id_argmax,
t.environment AS traces_environment,
a.environment_anylast_value AS amt_environment_anylast,
a.environment_argmax_value AS amt_environment_argmax,
t.metadata AS traces_metadata,
a.metadata_anylast_value AS amt_metadata_anylast,
a.metadata_argmax_value AS amt_metadata_argmax,
arraySort(t.tags) AS traces_tags,
arraySort(a.tags) AS amt_tags,
t.bookmarked AS traces_bookmarked,
a.bookmarked_value AS amt_bookmarked,
t.public AS traces_public,
a.public_value AS amt_public,
t.release AS traces_release,
a.release_value AS amt_release,
t.version AS traces_version,
a.version_value AS amt_version,
t.input AS traces_input,
a.input_anylast_value AS amt_input_anylast,
a.input_argmax_value AS amt_input_argmax,
t.output AS traces_output,
a.output_anylast_value AS amt_output_anylast,
a.output_argmax_value AS amt_output_argmax,
timestamp_diff +
name_diff +
user_id_diff +
session_id_diff +
release_diff +
version_diff +
public_diff +
bookmarked_diff +
tags_diff +
input_diff +
output_diff +
metadata_diff
AS total_diff
FROM traces t FINAL
LEFT JOIN amt_data a ON t.project_id = a.project_id AND t.id = a.id
where (t.timestamp >= '2025-07-01'
or a.start_time >= '2025-07-01')
and t.project_id in (
'some-project-id'
)
ORDER BY total_diff desc
LIMIT 1000;
```
+111 -11
View File
@@ -93,8 +93,8 @@ export class IngestionService {
constructor(
private redis: Redis | Cluster,
private prisma: PrismaClient,
private clickHouseWriter: ClickhouseWriter,
private clickhouseClient: ClickhouseClientType,
private clickHouseWriter: ClickhouseWriter, // eslint-disable-line no-unused-vars
private clickhouseClient: ClickhouseClientType, // eslint-disable-line no-unused-vars
) {
this.promptService = new PromptService(prisma, redis);
}
@@ -220,6 +220,14 @@ export class IngestionService {
clickhouseScoreRecord?.created_at ?? createdAtTimestamp.getTime();
this.clickHouseWriter.addToQueue(TableName.Scores, finalScoreRecord);
if (
env.LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES === "true" &&
finalScoreRecord.trace_id
) {
const traceMtRecord = this.convertScoreToTraceMt(finalScoreRecord);
this.clickHouseWriter.addToQueue(TableName.TracesMt, traceMtRecord);
}
}
private async processTraceEventList(params: {
@@ -453,6 +461,14 @@ export class IngestionService {
TableName.Observations,
finalObservationRecord,
);
if (
env.LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES === "true" &&
finalObservationRecord.trace_id
) {
const traceMtRecord = this.convertObservationToTraceMt(finalObservationRecord);
this.clickHouseWriter.addToQueue(TableName.TracesMt, traceMtRecord);
}
}
private async mergeScoreRecords(params: {
@@ -543,6 +559,90 @@ export class IngestionService {
};
}
private convertObservationToTraceMt(
observationRecord: ObservationRecordInsertType,
): TraceMtRecordInsertType {
return {
// Identifiers
project_id: observationRecord.project_id,
// Use trace_id as the id in traces_mt. Always set given the conditions around calling the function
id: observationRecord.trace_id || "",
start_time: observationRecord.start_time,
end_time: observationRecord.end_time || null,
name: "",
// Metadata properties
metadata: {},
user_id: '',
session_id: '',
environment: observationRecord.environment,
tags: [],
version: null,
release: null,
// UI properties - nullable to prevent absent values being interpreted as overwrites
bookmarked: null,
public: null,
// Aggregations - include this observation ID
observation_ids: [observationRecord.id],
score_ids: [],
// We can fill the cost details here, but we shouldn't trust them.
// Only used for verification to estimate how big the double-counting is.
cost_details: observationRecord.cost_details || {},
usage_details: observationRecord.usage_details || {},
// Input/Output
input: "",
output: "",
created_at: observationRecord.created_at,
updated_at: observationRecord.updated_at,
event_ts: observationRecord.event_ts,
};
}
private convertScoreToTraceMt(
scoreRecord: ScoreRecordInsertType,
): TraceMtRecordInsertType {
return {
// Identifiers
project_id: scoreRecord.project_id,
// Use trace_id as the id in traces_mt. Always set given the conditions around calling the function
id: scoreRecord.trace_id || "",
start_time: scoreRecord.timestamp,
end_time: null, // scores don't have end_time
name: "",
// Metadata properties
metadata: {},
user_id: '',
session_id: '',
environment: scoreRecord.environment,
tags: [], // scores don't have tags
version: null, // scores don't have version
release: null, // scores don't have release
// UI properties - nullable to prevent absent values being interpreted as overwrites
bookmarked: null,
public: null,
// Aggregations - include this score ID
observation_ids: [],
score_ids: [scoreRecord.id],
cost_details: {},
usage_details: {},
// Input/Output
input: "", // scores don't have input
output: "", // scores don't have output
created_at: scoreRecord.created_at,
updated_at: scoreRecord.updated_at,
event_ts: scoreRecord.event_ts,
};
}
private async mergeObservationRecords(params: {
projectId: string;
observationRecords: ObservationRecordInsertType[];
@@ -713,7 +813,7 @@ export class IngestionService {
> {
const providedUsageDetails = Object.fromEntries(
Object.entries(observationRecord.provided_usage_details).filter(
([k, v]) => v != null && v >= 0,
([k, v]) => v != null && v >= 0, // eslint-disable-line no-unused-vars
),
);
@@ -771,7 +871,7 @@ export class IngestionService {
const { provided_cost_details } = observationRecord;
const providedCostKeys = Object.entries(provided_cost_details ?? {})
.filter(([_, value]) => value != null)
.filter(([_, value]) => value != null) // eslint-disable-line no-unused-vars
.map(([key]) => key);
// If user has provided any cost point, do not calculate any other cost points
@@ -818,7 +918,7 @@ export class IngestionService {
finalTotalCost = finalCostDetails.total;
} else if (finalCostEntries.length > 0) {
finalTotalCost = finalCostEntries.reduce(
(acc, [_, cost]) => acc + cost,
(acc, [_, cost]) => acc + cost, // eslint-disable-line no-unused-vars
0,
);
@@ -882,7 +982,7 @@ export class IngestionService {
return result;
}
private async getClickhouseRecord(params: {
private async getClickhouseRecord(params: { // eslint-disable-line no-unused-vars
projectId: string;
entityId: string;
table: TableName.Traces;
@@ -891,7 +991,7 @@ export class IngestionService {
params: Record<string, unknown>;
};
}): Promise<TraceRecordInsertType | null>;
private async getClickhouseRecord(params: {
private async getClickhouseRecord(params: { // eslint-disable-line no-unused-vars, no-dupe-class-members
projectId: string;
entityId: string;
table: TableName.Scores;
@@ -900,7 +1000,7 @@ export class IngestionService {
params: Record<string, unknown>;
};
}): Promise<ScoreRecordInsertType | null>;
private async getClickhouseRecord(params: {
private async getClickhouseRecord(params: { // eslint-disable-line no-unused-vars, no-dupe-class-members
projectId: string;
entityId: string;
table: TableName.Observations;
@@ -909,7 +1009,7 @@ export class IngestionService {
params: Record<string, unknown>;
};
}): Promise<ObservationRecordInsertType | null>;
private async getClickhouseRecord(params: {
private async getClickhouseRecord(params: { // eslint-disable-line no-dupe-class-members
projectId: string;
entityId: string;
table: TableName;
@@ -1107,7 +1207,7 @@ export class IngestionService {
...("usageDetails" in obs.body
? (Object.fromEntries(
Object.entries(obs.body.usageDetails ?? {}).filter(
([_, val]) => val != null,
([_, val]) => val != null, // eslint-disable-line no-unused-vars
),
) as Record<string, number>)
: {}),
@@ -1128,7 +1228,7 @@ export class IngestionService {
...("costDetails" in obs.body
? (Object.fromEntries(
Object.entries(obs.body.costDetails ?? {}).filter(
([_, val]) => val != null,
([_, val]) => val != null, // eslint-disable-line no-unused-vars
),
) as Record<string, number>)
: {}),
+1 -1
View File
@@ -9,7 +9,7 @@ import { WorkerManager } from "../queues/workerManager";
import { prisma } from "@langfuse/shared/src/db";
import { BackgroundMigrationManager } from "../backgroundMigrations/backgroundMigrationManager";
export const onShutdown: NodeJS.SignalsListener = async (signal) => {
export const onShutdown: NodeJS.SignalsListener = async (signal) => { // eslint-disable-line no-undef
logger.info(`Received ${signal}, closing server...`);
setSigtermReceived();