Compare commits

..
26 Commits
Author SHA1 Message Date
Marc Klingen 2dd5c8a8aa chore: release v3.97.2 2025-08-12 17:31:22 +02:00
Marc Klingen d17b21f04d chore: specify development node version 2025-08-12 17:30:31 +02:00
Marc KlingenandGitHub c5a0f44bdd fix: fail silently when /api/latest-releases returns invalid schema (#8476)
* fix: fail silently when /api/latest-releases returns invalid schema

* push
2025-08-12 15:18:27 +00:00
Steffen SchmitzandGitHub 011b4912f2 chore: limit traces to trace AMT migration to only write into traces_all_amt (#8458)
* chore: limit traces to trace AMT migration to only write into traces_all_amt

* chore: revert validation changes

* chore: simplify query

* chore: tune both queries

* chore: avoid full aggregation during migration

* chore: handle IO coalescing to skip aggregations fully

* chore: remove obsolete query parts
2025-08-12 14:44:55 +00:00
Steffen SchmitzandGitHub a4d773066f chore: move health check to new traces AMTs (#8473) 2025-08-12 14:44:09 +00:00
Marlies Mayerhofer 25bdb1690b chore: release v3.97.1 2025-08-12 11:33:34 +02:00
marliessophieandGitHub f8565d7772 fix(evals): update trace deletion logic to remove job executions directly (#8466) 2025-08-12 09:15:34 +00:00
marliessophieandGitHub a912057082 fix(evals): ensure default model supports langfuse evals at set up (#8411)
* fix(evals): ensure default model supports langfuse evals at set up

* fix: await upsertDefaultModel

* chore: provide actionable error messages in case of misconfiguration
2025-08-12 09:11:00 +00:00
marliessophieandGitHub e0d3270f50 fix(evals): json parse preview to properly handle doubleEncoded IO (#8464)
* fixup(evals): json parse preview

* chore: add comment
2025-08-12 07:47:09 +00:00
Hassieb Pakzad 99e5a0b224 chore: release v3.97.0 2025-08-11 19:22:53 +02:00
bccdee5410 feat: add HTTPS proxy support for LLM API calls (#8461)
* feat: Add HTTPS proxy support for LLM API calls (#7932)

* remove tests

---------

Co-authored-by: suhwan <52690419+suhwan-cheon@users.noreply.github.com>
2025-08-11 17:18:54 +00:00
marliessophieandGitHub 738cbbc8cd feat(annotation-assignment): allow removing user assignments in UI (#8370)
* feat(annotation-assignment): allow removing user assignments in UI

* chore: push
2025-08-11 15:57:02 +00:00
marliessophieandGitHub 920c52bb06 chore(evals): cancel job executions in case of underlying trace delete (#8443)
* chore(evals): cancel job executions in case of underlying trace delete

* chore: lint
2025-08-11 15:56:27 +00:00
Leo WeigandandGitHub 4f134790af chore: add edit button to dataset items dot menu (#8444)
* chore: add edit button to dataset items dot menu

* fix: disable button based on access

* fix: DatasetActionButton not forwarding refs

* fix: eslint
2025-08-11 15:33:03 +00:00
Steffen SchmitzandGitHub 21b3ce3c82 Revert "perf: stream new records to clickhouse in writer (#8421)" (#8459)
This reverts commit b35583056b.
2025-08-11 15:30:42 +00:00
Leo WeigandandGitHub 0531b57e1a refactor: update create org form validation (#8455) 2025-08-11 14:26:45 +00:00
Leo WeigandandGitHub 7b857a0dc4 chore: surface required models for Azure/Bedrock (#8453)
- LLM connections: surface required model selection
for Azure/Bedrock; simplify advanced settings and validation
- Move custom model names to main form for Azure and Bedrock; require at least one model
- Remove default models toggle for Azure/Bedrock (they don’t support defaults)
- Keep Azure base URL and extra headers in main form; remove Azure advanced panel entirely
- Show advanced settings only for OpenAI, Anthropic, Vertex AI, and Google AI Studio
- Improve validation order to avoid confusing errors when defaults aren’t supported
- Add adapter-based placeholder for provider name; clarify copy
- Consolidate adapter logic to a single helper used in UI and schema
2025-08-11 14:26:27 +00:00
Steffen SchmitzandGitHub 6c97fe3c04 chore: drop "remove tag" capability from UI (#8454) 2025-08-11 14:24:08 +00:00
Steffen SchmitzandGitHub 1d69cbd41b chore: migrate remaining analytics and export queries to traces AMTs (#8451)
* chore: migrate remaining analytics and export queries to traces AMTs

* chore: patch

* chore: patches

* dummy

* chore: replace start_time with timestamp

* chore: adjust timeshift

* chore: switch to startTime

* chore: updat ereadme
2025-08-11 13:48:48 +00:00
Steffen SchmitzandGitHub ab26692913 fix(dashboards): patch invalid tags filter (#8445)
* fix(dashboards): patch invalid tags filter

* chore: move tests

* chore: remove unnecessary auth overwrite

* chore: test update
2025-08-11 12:14:56 +00:00
Hassieb PakzadandGitHub b791544864 fix(model-prices): gpt-5 prices with model date (#8442)
* fix(model-prices): gpt-5 prices with model date

* add to playground and evals

* add cache clearance
2025-08-11 11:49:32 +00:00
Steffen SchmitzandGitHub b35583056b perf: stream new records to clickhouse in writer (#8421)
* perf: stream new records to clickhouse in writer

* chore: update unit tests
2025-08-11 08:23:16 +00:00
Steffen SchmitzandGitHub 4504530e3d chore: skip unavailable shards in traces AMT background migration (#8438) 2025-08-11 08:21:50 +00:00
marliessophieandGitHub 45eed4b5c0 feat(scores-exports): add author to score exports (#8419) 2025-08-08 16:29:45 +00:00
marliessophieandGitHub 9fa31ba68e chore(datasets-csv-upload): no longer nest column values if single valid json object (#8416) 2025-08-08 16:04:55 +00:00
Hassieb PakzadandGitHub ea35c25269 fix: gemini-2.5-flash name (#8412) 2025-08-08 14:47:05 +00:00
41 changed files with 1362 additions and 544 deletions
+1 -1
View File
@@ -1 +1 @@
v20
v20.19.2
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.96.2",
"version": "3.97.2",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
+1 -1
View File
@@ -190,10 +190,10 @@ const EnvSchema = z.object({
return new Map<string, number>();
}
}),
SLACK_CLIENT_ID: z.string().optional(),
SLACK_CLIENT_SECRET: z.string().optional(),
SLACK_STATE_SECRET: z.string().optional(),
HTTPS_PROXY: z.string().optional(),
LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT: z.coerce
.number()
@@ -27,16 +27,42 @@ export const parseUnknownToString = (value: unknown): string => {
return String(value);
};
/**
* Recursively parses JSON strings that may have been encoded multiple times.
* This handles cases where data has been JSON.stringify'd multiple times.
*
* @param value - The potentially multi-encoded JSON string
* @returns The final parsed object or the original value if parsing fails
*/
function parseMultiEncodedJson(value: unknown): unknown {
if (typeof value !== "string") {
return value;
}
try {
const parsed = JSON.parse(value);
// If result is still a string, it might be double-encoded - recurse
if (typeof parsed === "string") {
return parseMultiEncodedJson(parsed);
}
return parsed;
} catch {
// If parsing fails, return original value
return value;
}
}
function parseJsonDefault(selectedColumn: unknown, jsonSelector: string) {
// selectedColumn should already be preprocessed by preprocessObjectWithJsonFields
// so we can directly use it with JSONPath
const result = JSONPath({
path: jsonSelector,
json:
typeof selectedColumn === "string"
? JSON.parse(selectedColumn)
: selectedColumn,
json: selectedColumn as any, // JSONPath accepts unknown but types are strict
});
return result.length > 0 ? result[0] : undefined;
return Array.isArray(result) && result.length > 0 ? result[0] : undefined;
}
export function extractValueFromObject(
@@ -44,7 +70,13 @@ export function extractValueFromObject(
mapping: z.infer<typeof variableMapping>,
parseJson?: (selectedColumn: unknown, jsonSelector: string) => unknown, // eslint-disable-line no-unused-vars
): { value: string; error: Error | null } {
const selectedColumn = obj[mapping.selectedColumnId];
let selectedColumn = obj[mapping.selectedColumnId];
// Simple preprocessing: attempt to parse to valid JSON object
if (typeof selectedColumn === "string") {
selectedColumn = parseMultiEncodedJson(selectedColumn);
}
const jsonParser = parseJson || parseJsonDefault;
let jsonSelectedColumn;
+1
View File
@@ -14,6 +14,7 @@ export * from "./llm/fetchLLMCompletion";
export * from "./llm/utils";
export * from "./llm/types";
export * from "./llm/compileChatMessages";
export * from "./llm/testModelCall";
export * from "./utils/DatabaseReadStream";
export * from "./utils/transforms";
export * from "./clickhouse/client";
@@ -16,6 +16,8 @@ export type ModelMatchProps = {
model: string;
};
const MODEL_MATCH_CACHE_LOCKED_KEY = "LOCK:model-match-clear";
export async function findModel(p: ModelMatchProps): Promise<Model | null> {
return instrumentAsync(
{
@@ -78,6 +80,14 @@ const getModelFromRedis = async (
}
try {
if (await isModelMatchCacheLocked()) {
logger.info(
"Model match cache is locked. Skipping model lookup from Redis.",
);
return null;
}
const key = getRedisModelKey(p);
const redisModel = await redis?.get(key);
if (redisModel) {
@@ -241,6 +251,70 @@ export async function clearModelCacheForProject(
);
}
} catch (error) {
logger.error(`Error clearing model cache for project ${projectId}`, error);
logger.error(
`Error clearing model cache for project ${projectId}: ${error}`,
);
}
}
export async function isModelMatchCacheLocked() {
try {
return Boolean(await redis?.exists(MODEL_MATCH_CACHE_LOCKED_KEY));
} catch (err) {
logger.error("Failed to check whether model match is locked", err);
return false;
}
}
export async function clearFullModelCache() {
if (env.LANGFUSE_CACHE_MODEL_MATCH_ENABLED === "false" || !redis) {
return;
}
try {
// Use lock to protect for concurrent executions
// This function is called on worker startup, so we want to avoid all workers triggering this delete
if (await isModelMatchCacheLocked()) {
logger.info("Model cache clearing already in progress; skipping.");
return;
}
const startTime = Date.now();
logger.info("Clearing full model cache...");
const tenMinutesInSeconds = 60 * 10;
await redis.setex(
MODEL_MATCH_CACHE_LOCKED_KEY,
tenMinutesInSeconds,
"locked",
);
const pattern = getModelMatchKeyPrefix() + "*";
const keys =
env.REDIS_CLUSTER_ENABLED === "true"
? (
await Promise.all(
(redis as Cluster)
.nodes("master")
.map((node) => node.keys(pattern) || []),
)
).flat()
: await redis.keys(pattern);
if (keys.length > 0) {
await safeMultiDel(redis, keys);
logger.info(
`Cleared full model cache with ${keys.length} keys in ${Date.now() - startTime}ms.`,
);
} else {
logger.info(`No keys found for match pattern '${pattern}'`);
}
} catch (error) {
logger.error(`Error clearing full model cache: ${error}`);
} finally {
await redis?.del(MODEL_MATCH_CACHE_LOCKED_KEY);
}
}
@@ -42,6 +42,7 @@ import {
} from "./types";
import { CallbackHandler } from "langfuse-langchain";
import type { BaseCallbackHandler } from "@langchain/core/callbacks/base";
import { HttpsProxyAgent } from "https-proxy-agent";
const isLangfuseCloud = Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
@@ -217,6 +218,10 @@ export async function fetchLLMCompletion(
(m) => m.content.length > 0 || "tool_calls" in m,
);
// Common proxy configuration for all adapters
const proxyUrl = env.HTTPS_PROXY;
const proxyAgent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
let chatModel:
| ChatOpenAI
| ChatAnthropic
@@ -232,7 +237,11 @@ export async function fetchLLMCompletion(
maxTokens: modelParams.max_tokens,
topP: modelParams.top_p,
callbacks: finalCallbacks,
clientOptions: { maxRetries, timeout: 1000 * 60 * 2 }, // 2 minutes timeout
clientOptions: {
maxRetries,
timeout: 1000 * 60 * 2, // 2 minutes timeout
...(proxyAgent && { httpAgent: proxyAgent }),
},
});
} else if (modelParams.adapter === LLMAdapter.OpenAI) {
chatModel = new ChatOpenAI({
@@ -247,6 +256,7 @@ export async function fetchLLMCompletion(
configuration: {
baseURL,
defaultHeaders: extraHeaders,
...(proxyAgent && { httpAgent: proxyAgent }),
},
timeout: 1000 * 60 * 2, // 2 minutes timeout
});
@@ -264,6 +274,7 @@ export async function fetchLLMCompletion(
timeout: 1000 * 60 * 2, // 2 minutes timeout
configuration: {
defaultHeaders: extraHeaders,
...(proxyAgent && { httpAgent: proxyAgent }),
},
});
} else if (modelParams.adapter === LLMAdapter.Bedrock) {
@@ -376,6 +387,7 @@ export async function fetchLLMCompletion(
},
configuration: {
baseURL,
...(proxyAgent && { httpAgent: proxyAgent }),
},
timeout: 1000 * 60 * 2, // 2 minutes timeout
})
@@ -0,0 +1,52 @@
import { z as zodV3 } from "zod/v3";
import {
ChatMessageRole,
ChatMessageType,
LLMApiKeySchema,
type ModelConfig,
} from "./types";
import { decrypt } from "../../encryption";
import { fetchLLMCompletion } from "./fetchLLMCompletion";
import { decryptAndParseExtraHeaders } from "./utils";
import z from "zod/v4";
export const testModelCall = async ({
provider,
model,
apiKey,
prompt,
modelConfig,
}: {
provider: string;
model: string;
apiKey: z.infer<typeof LLMApiKeySchema>;
prompt?: string;
modelConfig?: ModelConfig | null;
}): Promise<void> => {
(
await fetchLLMCompletion({
streaming: false,
apiKey: decrypt(apiKey.secretKey), // decrypt the secret key
extraHeaders: decryptAndParseExtraHeaders(apiKey.extraHeaders),
baseURL: apiKey.baseURL ?? undefined,
messages: [
{
role: ChatMessageRole.User,
content: prompt ?? "mock content",
type: ChatMessageType.User,
},
],
modelParams: {
provider: provider,
model: model,
adapter: apiKey.adapter,
...modelConfig,
},
structuredOutputSchema: zodV3.object({
score: zodV3.string(),
reasoning: zodV3.string(),
}),
config: apiKey.config,
})
).completion;
};
+6
View File
@@ -294,6 +294,12 @@ export const openAIModels = [
"gpt-4.1-mini-2025-04-14",
"gpt-4.1-nano",
"gpt-4.1-nano-2025-04-14",
"gpt-5",
"gpt-5-2025-08-07",
"gpt-5-mini",
"gpt-5-mini-2025-08-07",
"gpt-5-nano",
"gpt-5-nano-2025-08-07",
"o3",
"o3-2025-04-16",
"o4-mini",
@@ -1508,6 +1508,15 @@ export const getGenerationsForPostHog = async function* (
minTimestamp: Date,
maxTimestamp: Date,
) {
// Determine which trace table to use based on experiment flag
const useAMT = env.LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT === "true";
// Subtract 7d from minTimestamp to account for shift in query
const traceTable = useAMT
? getTimeframesTracesAMT(
new Date(minTimestamp.getTime() - 7 * 24 * 60 * 60 * 1000),
)
: "traces";
const query = `
SELECT
o.name as name,
@@ -1532,7 +1541,7 @@ export const getGenerationsForPostHog = async function* (
t.tags as trace_tags,
t.metadata['$posthog_session_id'] as posthog_session_id
FROM observations o FINAL
LEFT JOIN traces t FINAL ON o.trace_id = t.id AND o.project_id = t.project_id
LEFT JOIN ${traceTable} t FINAL ON o.trace_id = t.id AND o.project_id = t.project_id
WHERE o.project_id = {projectId: String}
AND t.project_id = {projectId: String}
AND o.start_time >= {minTimestamp: DateTime64(3)}
@@ -1554,6 +1563,7 @@ export const getGenerationsForPostHog = async function* (
type: "observation",
kind: "analytic",
projectId,
experiment_amt: useAMT ? "new" : "original",
},
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DATA_EXPORT_REQUEST_TIMEOUT_MS,
@@ -33,6 +33,7 @@ import { ClickHouseClientConfigOptions } from "@clickhouse/client";
import { recordDistribution } from "../instrumentation";
import { prisma } from "../../db";
import { measureAndReturn } from "../clickhouse/measureAndReturn";
import { getTimeframesTracesAMT } from "./traces";
export const searchExistingAnnotationScore = async (
projectId: string,
@@ -1152,7 +1153,7 @@ export const getNumericScoreHistogram = async (
const query = `
select s.value
from scores s
${traceFilter ? `LEFT JOIN traces t ON s.trace_id = t.id AND t.project_id = s.project_id` : ""}
${traceFilter ? `LEFT JOIN __TRACE_TABLE__ t ON s.trace_id = t.id AND t.project_id = s.project_id` : ""}
WHERE s.project_id = {projectId: String}
${traceFilter ? `AND t.project_id = {projectId: String}` : ""}
${chFilterRes?.query ? `AND ${chFilterRes.query}` : ""}
@@ -1161,18 +1162,45 @@ export const getNumericScoreHistogram = async (
${limit !== undefined ? `limit {limit: Int32}` : ""}
`;
return queryClickhouse<{ value: number }>({
query,
params: {
projectId,
limit,
...(chFilterRes ? chFilterRes.params : {}),
// Extract timestamp from filter for AMT table selection
const timestampFilter = chFilter.find(
(f) => f.clickhouseTable === "traces" && f.field === "timestamp",
) as TimeFilter | undefined;
const timestamp = timestampFilter?.value;
return measureAndReturn({
operationName: "getNumericScoreHistogram",
projectId,
minStartTime: timestamp,
input: {
params: {
projectId,
limit,
...(chFilterRes ? chFilterRes.params : {}),
},
tags: {
feature: "tracing",
type: "score",
kind: "analytic",
projectId,
operation_name: "getNumericScoreHistogram",
},
timestamp,
},
tags: {
feature: "tracing",
type: "score",
kind: "analytic",
projectId,
existingExecution: async (input) => {
return queryClickhouse<{ value: number }>({
query: query.replace("__TRACE_TABLE__", "traces"),
params: input.params,
tags: { ...input.tags, experiment_amt: "original" },
});
},
newExecution: async (input) => {
const traceAmt = getTimeframesTracesAMT(input.timestamp);
return queryClickhouse<{ value: number }>({
query: query.replace("__TRACE_TABLE__", traceAmt),
params: input.params,
tags: { ...input.tags, experiment_amt: "new" },
});
},
});
};
@@ -1399,6 +1427,15 @@ export const getScoresForPostHog = async function* (
minTimestamp: Date,
maxTimestamp: Date,
) {
// Determine which trace table to use based on experiment flag
const useAMT = env.LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT === "true";
// Subtract 7d from minTimestamp to account for shift in query
const traceTable = useAMT
? getTimeframesTracesAMT(
new Date(minTimestamp.getTime() - 7 * 24 * 60 * 60 * 1000),
)
: "traces";
const query = ` SELECT
s.id as id,
s.timestamp as timestamp,
@@ -1417,7 +1454,7 @@ export const getScoresForPostHog = async function* (
s.metadata as metadata,
t.metadata['$posthog_session_id'] as posthog_session_id
FROM scores s FINAL
LEFT JOIN traces t FINAL ON s.trace_id = t.id AND s.project_id = t.project_id
LEFT JOIN ${traceTable} t FINAL ON s.trace_id = t.id AND s.project_id = t.project_id
WHERE s.project_id = {projectId: String}
AND t.project_id = {projectId: String}
AND s.timestamp >= {minTimestamp: DateTime64(3)}
@@ -1438,6 +1475,7 @@ export const getScoresForPostHog = async function* (
type: "score",
kind: "analytic",
projectId,
experiment_amt: useAMT ? "new" : "original",
},
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DATA_EXPORT_REQUEST_TIMEOUT_MS,
@@ -1736,6 +1736,10 @@ export const getTracesForBlobStorageExport = function (
minTimestamp: Date,
maxTimestamp: Date,
) {
// Determine which trace table to use based on experiment flag
const useAMT = env.LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT === "true";
const traceTable = useAMT ? getTimeframesTracesAMT(minTimestamp) : "traces";
const query = `
SELECT
id,
@@ -1748,12 +1752,12 @@ export const getTracesForBlobStorageExport = function (
session_id,
release,
version,
public,
bookmarked,
${useAMT ? "finalizeAggregation(public)" : "public"} as public,
${useAMT ? "finalizeAggregation(bookmarked)" : "bookmarked"} as bookmarked,
tags,
input,
output
FROM traces FINAL
${useAMT ? "finalizeAggregation(input)" : "input"} as input,
${useAMT ? "finalizeAggregation(output)" : "output"} as output
FROM ${traceTable} FINAL
WHERE project_id = {projectId: String}
AND timestamp >= {minTimestamp: DateTime64(3)}
AND timestamp <= {maxTimestamp: DateTime64(3)}
@@ -1771,6 +1775,7 @@ export const getTracesForBlobStorageExport = function (
type: "trace",
kind: "analytic",
projectId,
experiment_amt: useAMT ? "new" : "original",
},
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DATA_EXPORT_REQUEST_TIMEOUT_MS,
@@ -1783,6 +1788,10 @@ export const getTracesForPostHog = async function* (
minTimestamp: Date,
maxTimestamp: Date,
) {
// Determine which trace table to use based on experiment flag
const useAMT = env.LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT === "true";
const traceTable = useAMT ? getTimeframesTracesAMT(minTimestamp) : "traces";
const query = `
WITH observations_agg AS (
SELECT o.project_id,
@@ -1810,7 +1819,7 @@ export const getTracesForPostHog = async function* (
o.total_cost as total_cost,
o.latency_milliseconds / 1000 as latency,
o.observation_count as observation_count
FROM traces t FINAL
FROM ${traceTable} t FINAL
LEFT JOIN observations_agg o ON t.id = o.trace_id AND t.project_id = o.project_id
WHERE t.project_id = {projectId: String}
AND t.timestamp >= {minTimestamp: DateTime64(3)}
@@ -1829,6 +1838,7 @@ export const getTracesForPostHog = async function* (
type: "trace",
kind: "analytic",
projectId,
experiment_amt: useAMT ? "new" : "original",
},
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DATA_EXPORT_REQUEST_TIMEOUT_MS,
@@ -1,7 +1,12 @@
import z from "zod/v4";
import { prisma } from "../../../db";
import { LangfuseNotFoundError, QUEUE_ERROR_MESSAGES } from "../../../errors";
import {
ForbiddenError,
LangfuseNotFoundError,
QUEUE_ERROR_MESSAGES,
} from "../../../errors";
import { LLMApiKeySchema, ZodModelConfig } from "../../llm/types";
import { testModelCall } from "../../llm/testModelCall";
type ValidConfig = {
provider: string;
@@ -47,6 +52,23 @@ export class DefaultEvalModelService {
);
}
try {
if (LLMApiKeySchema.safeParse(llmApiKey).success) {
// Make a test structured output call to validate the LLM key
await testModelCall({
provider,
model,
apiKey: llmApiKey as z.infer<typeof LLMApiKeySchema>,
modelConfig: modelParams,
});
}
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
throw new ForbiddenError(
`Model configuration not valid for evaluation. ${message}`,
);
}
// Create or update the default model
return prisma.defaultLlmModel.upsert({
where: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.96.2",
"version": "3.97.2",
"private": true,
"license": "MIT",
"engines": {
@@ -497,4 +497,137 @@ describe("/api/public/metrics API Endpoint", () => {
expect(height).toBeGreaterThanOrEqual(0);
});
});
describe("LFE-6148: Comprehensive filter validation", () => {
it("should return 400 error for invalid array field filters", async () => {
// Test using string type on array field (tags) - should return validation error
const invalidStringTypeQuery = {
view: "traces",
dimensions: [{ field: "name" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [
{
column: "tags",
operator: "contains",
value: "test-tag",
type: "string", // Invalid: array fields require arrayOptions type
},
],
timeDimension: {
granularity: "day",
},
fromTimestamp: yesterday.toISOString(),
toTimestamp: tomorrow.toISOString(),
orderBy: null,
};
// Make API call and expect 400 error
const response = await makeAPICall(
"GET",
`/api/public/metrics?query=${encodeURIComponent(JSON.stringify(invalidStringTypeQuery))}`,
);
expect(response.status).toBe(400);
expect(response.body).toMatchObject({
error: "InvalidRequestError",
message: expect.stringContaining(
"Array fields require type 'arrayOptions', not 'string'",
),
});
});
it("should return 400 error for invalid metadata filters", async () => {
// Test using wrong type for metadata field
const invalidMetadataTypeQuery = {
view: "traces",
dimensions: [{ field: "name" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [
{
column: "metadata",
operator: "contains",
value: "test-value",
type: "string", // Invalid: metadata requires stringObject type
},
],
timeDimension: null,
fromTimestamp: yesterday.toISOString(),
toTimestamp: tomorrow.toISOString(),
orderBy: null,
};
const response = await makeAPICall(
"GET",
`/api/public/metrics?query=${encodeURIComponent(JSON.stringify(invalidMetadataTypeQuery))}`,
);
expect(response.status).toBe(400);
expect(response.body).toMatchObject({
error: "InvalidRequestError",
message: expect.stringContaining(
"Metadata filters require type 'stringObject'",
),
});
});
it("should work correctly with proper array field filter configuration", async () => {
// Setup test data with tags
const taggedTraceId = randomUUID();
await createTracesCh([
createTrace({
id: taggedTraceId,
name: "tagged-trace",
project_id: projectId,
timestamp: now.getTime(),
tags: ["test-tag", "another-tag"],
metadata: { test: testMetadataValue },
}),
]);
// Test with correct arrayOptions filter
const validQuery = {
view: "traces",
dimensions: [{ field: "name" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [
{
column: "tags",
operator: "any of", // Correct operator for array fields
value: ["test-tag"],
type: "arrayOptions", // Correct type for array fields
},
{
column: "metadata",
operator: "contains",
key: "test",
value: testMetadataValue,
type: "stringObject",
},
],
timeDimension: null,
fromTimestamp: yesterday.toISOString(),
toTimestamp: tomorrow.toISOString(),
orderBy: null,
};
// Make the API call
const response = await makeZodVerifiedAPICall(
GetMetricsV1Response,
"GET",
`/api/public/metrics?query=${encodeURIComponent(JSON.stringify(validQuery))}`,
);
// Should succeed and return data
expect(response.status).toBe(200);
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data.length).toBeGreaterThan(0);
// Verify we got the tagged trace
const taggedTraceResult = response.body.data.find(
(row: any) => row.name === "tagged-trace",
);
expect(taggedTraceResult).toBeDefined();
expect(taggedTraceResult.count_count).toBe("1");
});
});
});
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.96.2";
export const VERSION = "v3.97.2";
@@ -5,6 +5,7 @@ import { Button } from "@/src/components/ui/button";
import { MultiSelectCombobox } from "@/src/components/ui/multi-select-combobox";
import { useUserSearch } from "@/src/features/annotation-queues/hooks/useUserSearch";
import { useSelectedUsers } from "@/src/features/annotation-queues/hooks/useSelectedUsers";
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
interface UserAssignmentSectionProps {
projectId: string;
@@ -27,6 +28,7 @@ export const UserAssignmentSection = ({
projectId: projectId,
scope: "annotationQueueAssignments:CUD",
});
const utils = api.useUtils();
// Get current assigned users
const queueAssignmentsQuery =
@@ -35,6 +37,18 @@ export const UserAssignmentSection = ({
{ enabled: !!queueId && hasQueueAssignmentsReadAccess },
);
const deleteQueueAssignmentMutation =
api.annotationQueueAssignments.delete.useMutation({
onSuccess: () => {
utils.annotationQueueAssignments.invalidate();
utils.annotationQueues.invalidate();
showSuccessToast({
title: "Removed assignment",
description: "User removed from queue successfully",
});
},
});
// Combine selected users and assigned users for exclusion
const assignedUserIds =
queueAssignmentsQuery.data?.assignments.map((user: any) => user.id) || [];
@@ -56,6 +70,16 @@ export const UserAssignmentSection = ({
onChange(userIds);
};
// Handle user removal
const handleUserRemove = (userId: string) => {
if (!!queueId)
deleteQueueAssignmentMutation.mutate({
projectId,
queueId,
userId,
});
};
// Check if there are more assigned users than shown
const hasMoreAssignedUsers =
queueAssignmentsQuery.data &&
@@ -133,9 +157,17 @@ export const UserAssignmentSection = ({
</p>
</div>
</div>
{/* <Button variant="ghost" size="icon-sm">
<Button
variant="ghost"
size="icon-sm"
disabled={
!hasQueueAssignmentWriteAccess ||
deleteQueueAssignmentMutation.isLoading
}
onClick={() => handleUserRemove(user.id)}
>
<X className="h-3 w-3" />
</Button> */}
</Button>
</div>
{(index <
queueAssignmentsQuery.data?.assignments.length - 1 ||
@@ -7,7 +7,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/src/components/ui/dialog";
import { useState } from "react";
import { useState, forwardRef } from "react";
import { DialogTrigger } from "@radix-ui/react-dialog";
import { DatasetForm } from "@/src/features/datasets/components/DatasetForm";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
@@ -46,7 +46,10 @@ type DatasetActionButtonProps =
| UpdateDatasetButtonProps
| DeleteDatasetButtonProps;
export const DatasetActionButton = (props: DatasetActionButtonProps) => {
export const DatasetActionButton = forwardRef<
HTMLButtonElement,
DatasetActionButtonProps
>((props, ref) => {
const capture = usePostHogClientCapture();
const [open, setOpen] = useState(false);
const hasAccess = useHasProjectAccess({
@@ -60,6 +63,7 @@ export const DatasetActionButton = (props: DatasetActionButtonProps) => {
{props.mode === "update" ? (
props.icon ? (
<Button
ref={ref}
variant={props.variant || "outline"}
size={props.size || "icon"}
className={props.className}
@@ -74,6 +78,7 @@ export const DatasetActionButton = (props: DatasetActionButtonProps) => {
</Button>
) : (
<Button
ref={ref}
variant={props.variant || "ghost"}
size={props.size || "icon"}
className={props.className}
@@ -95,6 +100,7 @@ export const DatasetActionButton = (props: DatasetActionButtonProps) => {
)
) : props.mode === "delete" ? (
<Button
ref={ref}
variant={props.variant || "ghost"}
size={props.size}
className={props.className}
@@ -115,6 +121,7 @@ export const DatasetActionButton = (props: DatasetActionButtonProps) => {
</Button>
) : (
<Button
ref={ref}
size={props.size}
className={props.className}
disabled={!hasAccess}
@@ -175,4 +182,6 @@ export const DatasetActionButton = (props: DatasetActionButtonProps) => {
</DialogContent>
</Dialog>
);
};
});
DatasetActionButton.displayName = "DatasetActionButton";
@@ -2,6 +2,7 @@ import { DataTable } from "@/src/components/table/data-table";
import TableLink from "@/src/components/table/table-link";
import { api } from "@/src/utils/api";
import { type RouterOutput } from "@/src/utils/types";
import { useRouter } from "next/router";
import {
DropdownMenu,
DropdownMenuContent,
@@ -10,7 +11,7 @@ import {
DropdownMenuTrigger,
} from "@/src/components/ui/dropdown-menu";
import { useQueryParams, withDefault, NumberParam } from "use-query-params";
import { Archive, ListTree, MoreVertical, Trash2 } from "lucide-react";
import { Archive, Edit, ListTree, MoreVertical, Trash2 } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import { type DatasetItem, DatasetStatus, type Prisma } from "@langfuse/shared";
import { type LangfuseColumnDef } from "@/src/components/table/types";
@@ -53,6 +54,7 @@ export function DatasetItemsTable({
datasetId: string;
menuItems?: React.ReactNode;
}) {
const router = useRouter();
const { setDetailPageList } = useDetailPageLists();
const utils = api.useUtils();
const capture = usePostHogClientCapture();
@@ -229,6 +231,17 @@ export function DatasetItemsTable({
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
disabled={!hasAccess}
onClick={() => {
router.push(
`/project/${projectId}/datasets/${datasetId}/items/${id}`,
);
}}
>
<Edit className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
disabled={!hasAccess}
onClick={() => {
@@ -214,6 +214,15 @@ export function parseColumns(
headerMap: Map<string, number>,
): Prisma.JsonValue {
if (columnNames.length === 0) return null;
// Single column: do not nest columns into json objects
if (columnNames.length === 1) {
const col = columnNames[0];
const rawValue = row[headerMap.get(col)!];
return parseValue(rawValue);
}
// Multiple columns: nest columns into json objects
return Object.fromEntries(
columnNames.map((col) => [col, parseValue(row[headerMap.get(col)!])]),
);
@@ -30,6 +30,7 @@ export default function DefaultEvaluationModelPage() {
const projectId = router.query.projectId as string;
const utils = api.useUtils();
const [isEditing, setIsEditing] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const hasWriteAccess = useHasProjectAccess({
projectId,
@@ -56,7 +57,7 @@ export default function DefaultEvaluationModelPage() {
setModelParams,
);
const { mutate: upsertDefaultModel, isLoading } =
const { mutateAsync: upsertDefaultModel, isLoading } =
api.defaultLlmModel.upsertDefaultModel.useMutation({
onSuccess: () => {
showSuccessToast({
@@ -65,26 +66,26 @@ export default function DefaultEvaluationModelPage() {
});
utils.defaultLlmModel.fetchDefaultModel.invalidate({ projectId });
setFormError(null);
setIsEditing(false);
},
onError: (error) => {
setFormError(error.message as string);
},
});
const executeUpsertMutation = () => {
try {
upsertDefaultModel({
projectId,
provider: modelParams.provider.value,
adapter: modelParams.adapter.value,
model: modelParams.model.value,
modelParams: {
max_tokens: modelParams.max_tokens.value,
temperature: modelParams.temperature.value,
top_p: modelParams.top_p.value,
},
});
} catch (error) {
return Promise.reject(error);
}
setIsEditing(false);
const executeUpsertMutation = async () => {
await upsertDefaultModel({
projectId,
provider: modelParams.provider.value,
adapter: modelParams.adapter.value,
model: modelParams.model.value,
modelParams: {
max_tokens: modelParams.max_tokens.value,
temperature: modelParams.temperature.value,
top_p: modelParams.top_p.value,
},
});
};
if (isDefaultModelLoading) {
@@ -135,7 +136,15 @@ export default function DefaultEvaluationModelPage() {
/>
)}
<Dialog open={isEditing} onOpenChange={setIsEditing}>
<Dialog
open={isEditing}
onOpenChange={(open) => {
setIsEditing(open);
if (!open) {
setFormError(null);
}
}}
>
<DialogTrigger asChild>
<Button
disabled={!hasWriteAccess}
@@ -167,24 +176,31 @@ export default function DefaultEvaluationModelPage() {
<div className="my-2 text-xs text-muted-foreground">
Select a model which supports function calling.
</div>
<div className="mt-2 flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsEditing(false)}>
Cancel
</Button>
{selectedModel ? (
<UpdateButton
projectId={projectId}
isLoading={isLoading}
executeUpsertMutation={executeUpsertMutation}
/>
) : (
<Button
disabled={!hasWriteAccess || !modelParams.provider.value}
onClick={executeUpsertMutation}
>
Save
<div className="flex flex-col gap-2">
<div className="mt-2 flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsEditing(false)}>
Cancel
</Button>
)}
{selectedModel ? (
<UpdateButton
projectId={projectId}
isLoading={isLoading}
executeUpsertMutation={executeUpsertMutation}
/>
) : (
<Button
disabled={!hasWriteAccess || !modelParams.provider.value}
onClick={executeUpsertMutation}
>
Save
</Button>
)}
</div>
{formError ? (
<p className="text-red w-full text-center">
<span className="font-bold">Error:</span> {formError}
</p>
) : null}
</div>
</DialogContent>
</Dialog>
@@ -223,7 +239,10 @@ function UpdateButton({
Update
</Button>
</PopoverTrigger>
<PopoverContent onClick={(e) => e.stopPropagation()}>
<PopoverContent
onClick={(e) => e.stopPropagation()}
className="w-fit max-w-[500px]"
>
<h2 className="text-md mb-3 font-semibold">Please confirm</h2>
<p className="mb-3 text-sm">
Updating the default model will impact any currently running
@@ -5,6 +5,7 @@ import {
} from "@/src/server/api/trpc";
import { z } from "zod/v4";
import {
ForbiddenError,
InvalidRequestError,
LangfuseNotFoundError,
ZodModelConfig,
@@ -42,12 +43,14 @@ export const defaultEvalModelRouter = createTRPCRouter({
});
try {
return DefaultEvalModelService.upsertDefaultModel(input);
return await DefaultEvalModelService.upsertDefaultModel(input);
} catch (error) {
if (error instanceof InvalidRequestError) {
throw new TRPCError({ code: "BAD_REQUEST", message: error.message });
} else if (error instanceof LangfuseNotFoundError) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
} else if (error instanceof ForbiddenError) {
throw new TRPCError({ code: "FORBIDDEN", message: error.message });
}
throw error;
}
+11 -41
View File
@@ -1,5 +1,4 @@
import { z } from "zod/v4";
import { z as zodV3 } from "zod/v3";
import {
createTRPCRouter,
protectedProjectProcedure,
@@ -11,7 +10,6 @@ import {
ZodModelConfig,
singleFilter,
variableMapping,
ChatMessageRole,
paginationZod,
type JobConfiguration,
JobType,
@@ -21,19 +19,16 @@ import {
orderBy,
jsonSchema,
} from "@langfuse/shared";
import { decrypt } from "@langfuse/shared/encryption";
import {
decryptAndParseExtraHeaders,
fetchLLMCompletion,
getQueue,
getScoresByIds,
logger,
QueueName,
QueueJobs,
ChatMessageType,
tableColumnsToSqlFilterAndPrefix,
orderByToPrismaSql,
DefaultEvalModelService,
testModelCall,
} from "@langfuse/shared/src/server";
import { TRPCError } from "@trpc/server";
import { EvalReferencedEvaluators } from "@/src/features/evals/types";
@@ -808,45 +803,20 @@ export const evalRouter = createTRPCRouter({
});
}
const matchingLLMKey = modelConfig.config.apiKey;
// Make a test structured output call to validate the LLM key
try {
(
await fetchLLMCompletion({
streaming: false,
apiKey: decrypt(matchingLLMKey.secretKey), // decrypt the secret key
extraHeaders: decryptAndParseExtraHeaders(
matchingLLMKey.extraHeaders,
),
baseURL: matchingLLMKey.baseURL ?? undefined,
messages: [
{
role: ChatMessageRole.User,
content: input.prompt,
type: ChatMessageType.User,
},
],
modelParams: {
provider: modelConfig.config.provider,
model: modelConfig.config.model,
adapter: matchingLLMKey.adapter,
...input.modelParams,
},
structuredOutputSchema: zodV3.object({
score: zodV3.string(),
reasoning: zodV3.string(),
}),
config: matchingLLMKey.config,
})
).completion;
// Make a test structured output call to validate the LLM key
await testModelCall({
provider: modelConfig.config.provider,
model: modelConfig.config.model,
apiKey: modelConfig.config.apiKey,
modelConfig: input.modelParams,
prompt: input.prompt,
});
} catch (err) {
logger.error(err);
const message = err instanceof Error ? err.message : "Unknown error";
throw new TRPCError({
code: "PRECONDITION_FAILED",
message:
"Selected model is not supported for evaluations. Test tool call failed.",
message: `Model configuration not valid for evaluation. ${message}`,
});
}
@@ -22,7 +22,7 @@ import {
} from "@/src/components/ui/select";
import { api } from "@/src/utils/api";
import { useSession } from "next-auth/react";
import { organizationNameSchema } from "@/src/features/organizations/utils/organizationNameSchema";
import { organizationFormSchema } from "@/src/features/organizations/utils/organizationNameSchema";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { SurveyName } from "@prisma/client";
import { env } from "@/src/env.mjs";
@@ -35,7 +35,7 @@ export const NewOrganizationForm = ({
const { update: updateSession } = useSession();
const form = useForm({
resolver: zodResolver(organizationNameSchema),
resolver: zodResolver(organizationFormSchema),
defaultValues: {
name: "",
type: "Personal",
@@ -50,7 +50,7 @@ export const NewOrganizationForm = ({
const watchedType = form.watch("type");
const isCloud = Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
function onSubmit(values: z.infer<typeof organizationNameSchema>) {
function onSubmit(values: z.infer<typeof organizationFormSchema>) {
capture("organizations:new_form_submit");
createOrgMutation
.mutateAsync({
@@ -130,13 +130,10 @@ export const NewOrganizationForm = ({
<FormDescription>
What would best describe your organization?
</FormDescription>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select organization type" />
<SelectTrigger ref={field.ref}>
<SelectValue placeholder="Please choose" />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -162,13 +159,10 @@ export const NewOrganizationForm = ({
<FormDescription>
How many people are in your {watchedType}?
</FormDescription>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select organization size" />
<SelectTrigger ref={field.ref}>
<SelectValue placeholder="Please choose" />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -18,11 +18,29 @@ const organizationSizeOptions = [
"More than 300",
] as const;
// Base schema for org creation, used for server-side validation too
export const organizationNameSchema = z.object({
name: StringNoHTML.min(3, "Must have at least 3 characters").max(
60,
"Must have at most 60 characters",
),
type: z.enum(organizationTypeOptions).optional(),
size: z.enum(organizationSizeOptions).optional(),
});
// Extended schema for client-side form validation including type and size,
// which are posted separately as a survey response.
export const organizationFormSchema = organizationNameSchema
.extend({
type: z.enum(organizationTypeOptions),
size: z.enum(organizationSizeOptions).optional(),
})
.check((ctx) => {
const { type, size } = ctx.value;
if ((type === "Company" || type === "Agency") && !size) {
ctx.issues.push({
code: z.ZodIssueCode.custom,
path: ["size"],
input: ctx.value.size,
message: "Please specify the size of your organization",
});
}
});
@@ -40,6 +40,9 @@ import { env } from "@/src/env.mjs";
const isLangfuseCloud = Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
const isCustomModelsRequired = (adapter: LLMAdapter) =>
adapter === LLMAdapter.Azure || adapter === LLMAdapter.Bedrock;
const createFormSchema = (mode: "create" | "update") =>
z
.object({
@@ -62,11 +65,7 @@ const createFormSchema = (mode: "create" | "update") =>
}),
),
})
.refine((data) => data.withDefaultModels || data.customModels.length > 0, {
message:
"At least one custom model name is required when default models are disabled.",
path: ["withDefaultModels"],
})
// 1) If adapter requires custom models, enforce that first
.refine(
(data) => {
if (data.adapter !== LLMAdapter.Bedrock) return true;
@@ -88,6 +87,32 @@ const createFormSchema = (mode: "create" | "update") =>
path: ["adapter"],
},
)
.refine(
(data) => {
if (isCustomModelsRequired(data.adapter)) {
return data.customModels.length > 0;
}
return true;
},
{
message: "At least one custom model is required for this adapter.",
path: ["customModels"],
},
)
// 2) For adapters that support defaults, require default models or at least one custom model
.refine(
(data) => {
if (isCustomModelsRequired(data.adapter)) {
return true;
}
return data.withDefaultModels || data.customModels.length > 0;
},
{
message:
"At least one custom model name is required when default models are disabled.",
path: ["withDefaultModels"],
},
)
.refine(
(data) =>
data.adapter === LLMAdapter.Bedrock ||
@@ -200,6 +225,12 @@ export function CreateLLMApiKeyForm({
const currentAdapter = form.watch("adapter");
const hasAdvancedSettings = (adapter: LLMAdapter) =>
adapter === LLMAdapter.OpenAI ||
adapter === LLMAdapter.Anthropic ||
adapter === LLMAdapter.VertexAI ||
adapter === LLMAdapter.GoogleAIStudio;
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "customModels",
@@ -214,6 +245,114 @@ export function CreateLLMApiKeyForm({
name: "extraHeaders",
});
const renderCustomModelsField = () => (
<FormField
control={form.control}
name="customModels"
render={() => (
<FormItem>
<FormLabel>Custom models</FormLabel>
<FormDescription>
Custom model names accepted by given endpoint.
</FormDescription>
{currentAdapter === LLMAdapter.Azure && (
<FormDescription className="text-dark-yellow">
{
"For Azure, the model name should be the same as the deployment name in Azure. For evals, choose a model with function calling capabilities."
}
</FormDescription>
)}
{currentAdapter === LLMAdapter.Bedrock && (
<FormDescription className="text-dark-yellow">
{
"For Bedrock, the model name is the Bedrock Inference Profile ID, e.g. 'eu.anthropic.claude-3-5-sonnet-20240620-v1:0'"
}
</FormDescription>
)}
{fields.map((customModel, index) => (
<span key={customModel.id} className="flex flex-row space-x-2">
<Input
{...form.register(`customModels.${index}.value`)}
placeholder={`Custom model name ${index + 1}`}
/>
<Button
type="button"
variant="ghost"
onClick={() => remove(index)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</span>
))}
<Button
type="button"
variant="ghost"
onClick={() => append({ value: "" })}
className="w-full"
>
<PlusIcon className="-ml-0.5 mr-1.5 h-5 w-5" aria-hidden="true" />
Add custom model name
</Button>
</FormItem>
)}
/>
);
const renderExtraHeadersField = () => (
<FormField
control={form.control}
name="extraHeaders"
render={() => (
<FormItem>
<FormLabel>Extra Headers</FormLabel>
<FormDescription>
Optional additional HTTP headers to include with requests towards
LLM provider. All header values stored encrypted{" "}
{isLangfuseCloud ? "on our servers" : "in your database"}.
</FormDescription>
{headerFields.map((header, index) => (
<div key={header.id} className="flex flex-row space-x-2">
<Input
{...form.register(`extraHeaders.${index}.key`)}
placeholder="Header name"
/>
<Input
{...form.register(`extraHeaders.${index}.value`)}
placeholder={
mode === "update" &&
existingKey?.extraHeaderKeys &&
existingKey.extraHeaderKeys[index]
? "***"
: "Header value"
}
/>
<Button
type="button"
variant="ghost"
onClick={() => removeHeader(index)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="ghost"
onClick={() => appendHeader({ key: "", value: "" })}
className="w-full"
>
<PlusIcon className="-ml-0.5 mr-1.5 h-5 w-5" aria-hidden="true" />
Add Header
</Button>
</FormItem>
)}
/>
);
// Disable provider and adapter fields in update mode
const isFieldDisabled = (fieldName: string) => {
if (mode !== "update") return false;
@@ -285,14 +424,16 @@ export function CreateLLMApiKeyForm({
)
: undefined;
const newKey = {
const newLlmApiKey = {
id: existingKey?.id ?? "",
projectId,
secretKey: secretKey ?? "",
provider: values.provider,
adapter: values.adapter,
baseURL: values.baseURL || undefined,
withDefaultModels: values.withDefaultModels,
withDefaultModels: isCustomModelsRequired(currentAdapter)
? false
: values.withDefaultModels,
config,
customModels: values.customModels
.map((m) => m.value.trim())
@@ -303,8 +444,8 @@ export function CreateLLMApiKeyForm({
try {
const testResult =
mode === "create"
? await mutTestLLMApiKey.mutateAsync(newKey)
: await mutTestUpdateLLMApiKey.mutateAsync(newKey);
? await mutTestLLMApiKey.mutateAsync(newLlmApiKey)
: await mutTestUpdateLLMApiKey.mutateAsync(newLlmApiKey);
if (!testResult.success) throw new Error(testResult.error);
} catch (error) {
@@ -320,7 +461,7 @@ export function CreateLLMApiKeyForm({
}
return (mode === "create" ? mutCreateLlmApiKey : mutUpdateLlmApiKey)
.mutateAsync(newKey)
.mutateAsync(newLlmApiKey)
.then(() => {
form.reset();
onSuccess();
@@ -337,24 +478,6 @@ export function CreateLLMApiKeyForm({
onSubmit={form.handleSubmit(onSubmit)}
>
<DialogBody>
{/* Provider name */}
<FormField
control={form.control}
name="provider"
render={({ field }) => (
<FormItem>
<FormLabel>Provider name</FormLabel>
<FormDescription>
Name to identify the key within Langfuse.
</FormDescription>
<FormControl>
<Input {...field} disabled={isFieldDisabled("provider")} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* LLM adapter */}
<FormField
control={form.control}
@@ -393,6 +516,27 @@ export function CreateLLMApiKeyForm({
</FormItem>
)}
/>
{/* Provider name */}
<FormField
control={form.control}
name="provider"
render={({ field }) => (
<FormItem>
<FormLabel>Provider name</FormLabel>
<FormDescription>
Key to identify the connection within Langfuse.
</FormDescription>
<FormControl>
<Input
{...field}
placeholder={`e.g. ${currentAdapter}`}
disabled={isFieldDisabled("provider")}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* API Key or AWS Credentials */}
{currentAdapter === LLMAdapter.Bedrock ? (
@@ -563,61 +707,64 @@ export function CreateLLMApiKeyForm({
/>
)}
<div className="flex items-center">
<Button
type="button"
variant="link"
size="sm"
className="flex items-center pl-0"
onClick={() => setShowAdvancedSettings(!showAdvancedSettings)}
>
<span>
{showAdvancedSettings
? "Hide advanced settings"
: "Show advanced settings"}
</span>
<ChevronDown
className={`ml-1 h-4 w-4 transition-transform ${showAdvancedSettings ? "rotate-180" : "rotate-0"}`}
/>
</Button>
</div>
{/* Custom models: top-level for Azure/Bedrock */}
{isCustomModelsRequired(currentAdapter) && renderCustomModelsField()}
{showAdvancedSettings && (
{/* Extra headers - show for Azure in main section (Azure has no advanced settings) */}
{currentAdapter === LLMAdapter.Azure && renderExtraHeadersField()}
{hasAdvancedSettings(currentAdapter) && (
<div className="flex items-center">
<Button
type="button"
variant="link"
size="sm"
className="flex items-center pl-0"
onClick={() => setShowAdvancedSettings(!showAdvancedSettings)}
>
<span>
{showAdvancedSettings
? "Hide advanced settings"
: "Show advanced settings"}
</span>
<ChevronDown
className={`ml-1 h-4 w-4 transition-transform ${showAdvancedSettings ? "rotate-180" : "rotate-0"}`}
/>
</Button>
</div>
)}
{hasAdvancedSettings(currentAdapter) && showAdvancedSettings && (
<div className="space-y-4 border-t pt-4">
{/* baseURL */}
{currentAdapter !== LLMAdapter.Bedrock &&
currentAdapter !== LLMAdapter.Azure && (
<FormField
control={form.control}
name="baseURL"
render={({ field }) => (
<FormItem>
<FormLabel>API Base URL</FormLabel>
<FormDescription>
Leave blank to use the default base URL for the given
LLM adapter.{" "}
{currentAdapter === LLMAdapter.OpenAI && (
<span>
OpenAI default: https://api.openai.com/v1
</span>
)}
{currentAdapter === LLMAdapter.Anthropic && (
<span>
Anthropic default: https://api.anthropic.com
(excluding /v1/messages)
</span>
)}
</FormDescription>
<FormField
control={form.control}
name="baseURL"
render={({ field }) => (
<FormItem>
<FormLabel>API Base URL</FormLabel>
<FormDescription>
Leave blank to use the default base URL for the given LLM
adapter.{" "}
{currentAdapter === LLMAdapter.OpenAI && (
<span>OpenAI default: https://api.openai.com/v1</span>
)}
{currentAdapter === LLMAdapter.Anthropic && (
<span>
Anthropic default: https://api.anthropic.com
(excluding /v1/messages)
</span>
)}
</FormDescription>
<FormControl>
<Input {...field} placeholder="default" />
</FormControl>
<FormControl>
<Input {...field} placeholder="default" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormMessage />
</FormItem>
)}
/>
{/* VertexAI Location */}
{currentAdapter === LLMAdapter.VertexAI && (
@@ -643,69 +790,8 @@ export function CreateLLMApiKeyForm({
)}
{/* Extra Headers */}
{currentAdapter === LLMAdapter.OpenAI ||
currentAdapter === LLMAdapter.Azure ? (
<FormField
control={form.control}
name="extraHeaders"
render={() => (
<FormItem>
<FormLabel>Extra Headers</FormLabel>
<FormDescription>
Optional additional HTTP headers to include with
requests towards LLM provider. All header values stored
encrypted{" "}
{isLangfuseCloud
? "on our servers"
: "in your database"}
.
</FormDescription>
{headerFields.map((header, index) => (
<div
key={header.id}
className="flex flex-row space-x-2"
>
<Input
{...form.register(`extraHeaders.${index}.key`)}
placeholder="Header name"
/>
<Input
{...form.register(`extraHeaders.${index}.value`)}
placeholder={
mode === "update" &&
existingKey?.extraHeaderKeys &&
existingKey.extraHeaderKeys[index]
? "***"
: "Header value"
}
/>
<Button
type="button"
variant="ghost"
onClick={() => removeHeader(index)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="ghost"
onClick={() => appendHeader({ key: "", value: "" })}
className="w-full"
>
<PlusIcon
className="-ml-0.5 mr-1.5 h-5 w-5"
aria-hidden="true"
/>
Add Header
</Button>
</FormItem>
)}
/>
) : null}
{currentAdapter === LLMAdapter.OpenAI &&
renderExtraHeadersField()}
{/* With default models */}
<FormField
@@ -720,36 +806,11 @@ export function CreateLLMApiKeyForm({
Default models for the selected adapter will be
available in Langfuse features.
</FormDescription>
{currentAdapter === LLMAdapter.Azure && (
<FormDescription className="text-dark-yellow">
Azure LLM adapter does not support default model
names maintained by Langfuse. Instead, please add a
custom model below that is the same as your
deployment name.
</FormDescription>
)}
{currentAdapter === LLMAdapter.Bedrock && (
<FormDescription className="text-dark-yellow">
Bedrock LLM adapter does not support default model
names maintained by Langfuse. Instead, please add
the Bedrock model IDs you have enabled in the AWS
console.
</FormDescription>
)}
</span>
<FormControl>
<Switch
disabled={
currentAdapter === LLMAdapter.Azure ||
currentAdapter === LLMAdapter.Bedrock
}
checked={
currentAdapter === LLMAdapter.Azure ||
currentAdapter === LLMAdapter.Bedrock
? false
: field.value
}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
@@ -761,64 +822,8 @@ export function CreateLLMApiKeyForm({
/>
{/* Custom model names */}
<FormField
control={form.control}
name="customModels"
render={() => (
<FormItem>
<FormLabel>Custom models</FormLabel>
<FormDescription>
Custom model names accepted by given endpoint.
</FormDescription>
{currentAdapter === LLMAdapter.Azure && (
<FormDescription className="text-dark-yellow">
{
"For Azure, the model name should be the same as the deployment name in Azure. For evals, choose a model with function calling capabilities."
}
</FormDescription>
)}
{currentAdapter === LLMAdapter.Bedrock && (
<FormDescription className="text-dark-yellow">
{
"For Bedrock, the model name is the Bedrock Inference Profile ID, e.g. 'eu.anthropic.claude-3-5-sonnet-20240620-v1:0'"
}
</FormDescription>
)}
{fields.map((customModel, index) => (
<span
key={customModel.id}
className="flex flex-row space-x-2"
>
<Input
{...form.register(`customModels.${index}.value`)}
placeholder={`Custom model name ${index + 1}`}
/>
<Button
type="button"
variant="ghost"
onClick={() => remove(index)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</span>
))}
<Button
type="button"
variant="ghost"
onClick={() => append({ value: "" })}
className="w-full"
>
<PlusIcon
className="-ml-0.5 mr-1.5 h-5 w-5"
aria-hidden="true"
/>
Add custom model name
</Button>
</FormItem>
)}
/>
{!isCustomModelsRequired(currentAdapter) &&
renderCustomModelsField()}
</div>
)}
</DialogBody>
@@ -4,6 +4,8 @@ import {
queryClickhouse,
TRACE_TO_OBSERVATIONS_INTERVAL,
type DateTimeFilter,
getTimeframesTracesAMT,
measureAndReturn,
} from "@langfuse/shared/src/server";
type QueryType = {
@@ -45,7 +47,7 @@ export const generateDailyMetrics = async (props: QueryType) => {
sum(arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, o.usage_details)))) as outputUsage,
sumMap(o.usage_details)['total'] as totalUsage,
sum(coalesce(o.total_cost, 0)) as totalCost
FROM traces t FINAL
FROM __TRACE_TABLE__ t FINAL
LEFT JOIN observations o FINAL on o.trace_id = t.id AND o.project_id = t.project_id
WHERE o.project_id = {projectId: String}
AND t.project_id = {projectId: String}
@@ -72,7 +74,7 @@ export const generateDailyMetrics = async (props: QueryType) => {
SELECT
toDate(t.timestamp) as date,
count(t.id) as countTraces
FROM traces t FINAL
FROM __TRACE_TABLE__ t FINAL
WHERE t.project_id = {projectId: String}
${hasTracesFilter ? `AND ${appliedTracesFilter.query}` : ""}
GROUP BY date
@@ -90,48 +92,104 @@ export const generateDailyMetrics = async (props: QueryType) => {
${props.limit !== undefined && props.page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
`;
const result = await queryClickhouse<{
date: string;
countTraces: number;
countObservations: number;
totalCost: number;
usage: (string | null)[][];
}>({
query,
params: {
...appliedTracesFilter.params,
...appliedFilter.params,
projectId: props.projectId,
...(props.limit !== undefined ? { limit: props.limit } : {}),
...(props.page !== undefined
? { offset: (props.page - 1) * props.limit }
: {}),
...(timeFilter
? {
cteTimeFilter: convertDateToClickhouseDateTime(timeFilter.value),
}
: {}),
const timestamp = props.fromTimestamp
? new Date(props.fromTimestamp)
: timeFilter?.value;
return measureAndReturn({
operationName: "generateDailyMetrics",
projectId: props.projectId,
minStartTime: timestamp,
input: {
params: {
...appliedTracesFilter.params,
...appliedFilter.params,
projectId: props.projectId,
...(props.limit !== undefined ? { limit: props.limit } : {}),
...(props.page !== undefined
? { offset: (props.page - 1) * props.limit }
: {}),
...(timeFilter
? {
cteTimeFilter: convertDateToClickhouseDateTime(timeFilter.value),
}
: {}),
},
tags: {
feature: "tracing",
type: "trace",
kind: "daily_metrics",
projectId: props.projectId,
operation_name: "generateDailyMetrics",
},
timestamp,
},
clickhouseConfigs: {
request_timeout: 60_000, // Use 1 minute timeout for daily metrics
existingExecution: async (input) => {
const result = await queryClickhouse<{
date: string;
countTraces: number;
countObservations: number;
totalCost: number;
usage: (string | null)[][];
}>({
query: query.replaceAll("__TRACE_TABLE__", "traces"),
params: input.params,
tags: { ...input.tags, experiment_amt: "original" },
clickhouseConfigs: {
request_timeout: 60_000, // Use 1 minute timeout for daily metrics
},
});
return result.map((record) => ({
date: record.date,
countTraces: Number(record.countTraces),
countObservations: Number(record.countObservations),
totalCost: Number(record.totalCost),
usage: record.usage.map((u) => ({
model: u[0],
inputUsage: Number(u[1]),
outputUsage: Number(u[2]),
totalUsage: Number(u[3]),
totalCost: Number(u[4]),
countObservations: Number(u[5]),
countTraces: Number(u[6]),
})),
}));
},
newExecution: async (input) => {
const traceAmt = getTimeframesTracesAMT(input.timestamp);
const result = await queryClickhouse<{
date: string;
countTraces: number;
countObservations: number;
totalCost: number;
usage: (string | null)[][];
}>({
query: query.replaceAll("__TRACE_TABLE__", traceAmt),
params: input.params,
tags: { ...input.tags, experiment_amt: "new" },
clickhouseConfigs: {
request_timeout: 60_000, // Use 1 minute timeout for daily metrics
},
});
return result.map((record) => ({
date: record.date,
countTraces: Number(record.countTraces),
countObservations: Number(record.countObservations),
totalCost: Number(record.totalCost),
usage: record.usage.map((u) => ({
model: u[0],
inputUsage: Number(u[1]),
outputUsage: Number(u[2]),
totalUsage: Number(u[3]),
totalCost: Number(u[4]),
countObservations: Number(u[5]),
countTraces: Number(u[6]),
})),
}));
},
});
return result.map((record) => ({
date: record.date,
countTraces: Number(record.countTraces),
countObservations: Number(record.countObservations),
totalCost: Number(record.totalCost),
usage: record.usage.map((u) => ({
model: u[0],
inputUsage: Number(u[1]),
outputUsage: Number(u[2]),
totalUsage: Number(u[3]),
totalCost: Number(u[4]),
countObservations: Number(u[5]),
countTraces: Number(u[6]),
})),
}));
};
export const getDailyMetricsCount = async (props: QueryType) => {
@@ -145,16 +203,48 @@ export const getDailyMetricsCount = async (props: QueryType) => {
const query = `
SELECT count(distinct toDate(timestamp)) as count
FROM traces t
FROM __TRACE_TABLE__ t
WHERE project_id = {projectId: String}
${filter.length() > 0 ? `AND ${appliedFilter.query}` : ""}
`;
const records = await queryClickhouse<{ count: string }>({
query,
params: { ...appliedFilter.params, projectId: props.projectId },
const timestamp = props.fromTimestamp
? new Date(props.fromTimestamp)
: undefined;
return measureAndReturn({
operationName: "getDailyMetricsCount",
projectId: props.projectId,
minStartTime: timestamp,
input: {
params: { ...appliedFilter.params, projectId: props.projectId },
tags: {
feature: "tracing",
type: "trace",
kind: "daily_metrics_count",
projectId: props.projectId,
operation_name: "getDailyMetricsCount",
},
timestamp,
},
existingExecution: async (input) => {
const records = await queryClickhouse<{ count: string }>({
query: query.replace("__TRACE_TABLE__", "traces"),
params: input.params,
tags: { ...input.tags, experiment_amt: "original" },
});
return records.map((record) => Number(record.count)).shift();
},
newExecution: async (input) => {
const traceAmt = getTimeframesTracesAMT(input.timestamp);
const records = await queryClickhouse<{ count: string }>({
query: query.replace("__TRACE_TABLE__", traceAmt),
params: input.params,
tags: { ...input.tags, experiment_amt: "new" },
});
return records.map((record) => Number(record.count)).shift();
},
});
return records.map((record) => Number(record.count)).shift();
};
const filterParams = [
+37 -5
View File
@@ -294,16 +294,48 @@ export const getTracesCountForPublicApi = async ({
const query = `
SELECT count() as count
FROM traces t
FROM __TRACE_TABLE__ t
WHERE project_id = {projectId: String}
${filter.length() > 0 ? `AND ${appliedFilter.query}` : ""}
`;
const records = await queryClickhouse<{ count: string }>({
query,
params: { ...appliedFilter.params, projectId: props.projectId },
const timestamp = props.fromTimestamp
? new Date(props.fromTimestamp)
: undefined;
return measureAndReturn({
operationName: "getTracesCountForPublicApi",
projectId: props.projectId,
minStartTime: timestamp,
input: {
params: { ...appliedFilter.params, projectId: props.projectId },
tags: {
feature: "tracing",
type: "trace",
kind: "count",
projectId: props.projectId,
operation_name: "getTracesCountForPublicApi",
},
timestamp,
},
existingExecution: async (input) => {
const records = await queryClickhouse<{ count: string }>({
query: query.replace("__TRACE_TABLE__", "traces"),
params: input.params,
tags: { ...input.tags, experiment_amt: "original" },
});
return records.map((record) => Number(record.count)).shift();
},
newExecution: async (input) => {
const traceAmt = getTimeframesTracesAMT(input.timestamp);
const records = await queryClickhouse<{ count: string }>({
query: query.replace("__TRACE_TABLE__", traceAmt),
params: input.params,
tags: { ...input.tags, experiment_amt: "new" },
});
return records.map((record) => Number(record.count)).shift();
},
});
return records.map((record) => Number(record.count)).shift();
};
const orderByColumns = [
@@ -117,10 +117,71 @@ export class QueryBuilder {
});
}
private validateFilters(
filters: z.infer<typeof queryModel>["filters"],
view: ViewDeclarationType,
) {
for (const filter of filters) {
// Validate filters on dimension fields
if (filter.column in view.dimensions) {
const dimension = view.dimensions[filter.column];
// Array fields (like tags) validation
if (dimension.type === "string[]") {
if (filter.type === "string") {
throw new InvalidRequestError(
`Invalid filter for field '${filter.column}': Array fields require type 'arrayOptions', not 'string'. ` +
`Use operators like 'any of', 'all of', or 'none of' with an array of values.`,
);
}
// Additional validation: ensure value is array for arrayOptions
if (filter.type === "arrayOptions" && !Array.isArray(filter.value)) {
throw new InvalidRequestError(
`Invalid filter for field '${filter.column}': arrayOptions type requires an array of values, not '${typeof filter.value}'.`,
);
}
}
}
// Special validation for metadata filters
else if (filter.column === "metadata") {
if (filter.type !== "stringObject") {
throw new InvalidRequestError(
`Invalid filter for field 'metadata': Metadata filters require type 'stringObject' with a 'key' property, not '${filter.type}'. ` +
`Example: {"column": "metadata", "type": "stringObject", "key": "environment", "operator": "=", "value": "production"}`,
);
}
// Validate stringObject has required key
if (filter.type === "stringObject" && !("key" in filter)) {
throw new InvalidRequestError(
`Invalid filter for field 'metadata': stringObject type requires a 'key' property to specify which metadata field to filter on. ` +
`Example: {"column": "metadata", "type": "stringObject", "key": "environment", "operator": "=", "value": "production"}`,
);
}
// Validate stringObject value type
if (
filter.type === "stringObject" &&
typeof filter.value !== "string"
) {
throw new InvalidRequestError(
// @ts-ignore
`Invalid filter for field 'metadata': stringObject type requires a string value, not '${typeof filter.value}'.`,
);
}
}
}
}
private mapFilters(
filters: z.infer<typeof queryModel>["filters"],
view: ViewDeclarationType,
) {
// Validate all filters before processing
this.validateFilters(filters, view);
// Transform our filters to match the column mapping format expected by createFilterFromFilterState
const columnMappings = filters.map((filter) => {
let clickhouseSelect: string;
+4 -13
View File
@@ -1,22 +1,19 @@
import React from "react";
import { cn } from "@/src/utils/tailwind";
import { X } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import { Command as CommandPrimitive } from "cmdk";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
type TagInputProps = React.ComponentPropsWithoutRef<
typeof CommandPrimitive.Input
> & {
selectedTags: string[];
setSelectedTags: (tags: string[]) => void;
setSelectedTags?: (tags: string[]) => void;
};
export const TagInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
TagInputProps
>(({ className, selectedTags, setSelectedTags, ...props }, ref) => {
const capture = usePostHogClientCapture();
>(({ className, selectedTags, ...props }, ref) => {
return (
<div
className="flex flex-wrap items-center overflow-auto rounded-lg border px-2"
@@ -29,16 +26,10 @@ export const TagInput = React.forwardRef<
key={tag}
variant="tertiary"
size="icon-sm"
onClick={() => {
const newTags = selectedTags.filter((t) => t !== tag);
setSelectedTags(newTags);
capture("tag:remove_tag", {
name: tag,
});
}}
disabled
className="cursor-default"
>
{tag}
<X className="ml-1 h-3 w-3" />
</Button>
))}
</div>
+38 -12
View File
@@ -5,6 +5,7 @@ import { prisma } from "@langfuse/shared/src/db";
import {
convertDateToClickhouseDateTime,
logger,
measureAndReturn,
queryClickhouse,
traceException,
} from "@langfuse/shared/src/server";
@@ -37,20 +38,45 @@ export default async function handler(
try {
if (failIfNoRecentEvents) {
const now = new Date();
const traces = await queryClickhouse({
query: `
SELECT id
FROM traces
WHERE timestamp <= {now: DateTime64(3)}
AND timestamp >= {now: DateTime64(3)} - INTERVAL 3 MINUTE
LIMIT 1
`,
params: {
const traces = await measureAndReturn({
operationName: "healthCheckTraces",
projectId: "__CROSS_PROJECT__",
input: {
now: convertDateToClickhouseDateTime(now),
},
tags: {
feature: "health-check",
type: "trace",
existingExecution: async (input: { now: string }) => {
return queryClickhouse<{ id: string }>({
query: `
SELECT id
FROM traces
WHERE timestamp <= {now: DateTime64(3)}
AND timestamp >= {now: DateTime64(3)} - INTERVAL 3 MINUTE
LIMIT 1
`,
params: input,
tags: {
feature: "health-check",
type: "trace",
experiment_amt: "original",
},
});
},
newExecution: async (input: { now: string }) => {
return queryClickhouse<{ id: string }>({
query: `
SELECT id
FROM traces_7d_amt
WHERE start_time <= {now: DateTime64(3)}
AND start_time >= {now: DateTime64(3)} - INTERVAL 3 MINUTE
LIMIT 1
`,
params: input,
tags: {
feature: "health-check",
type: "trace",
experiment_amt: "new",
},
});
},
});
const observations = await queryClickhouse({
+15 -10
View File
@@ -2,7 +2,6 @@ import { VERSION } from "@/src/constants/VERSION";
import { env } from "@/src/env.mjs";
import { createTRPCRouter, publicProcedure } from "@/src/server/api/trpc";
import { logger } from "@langfuse/shared/src/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
const versionSchema = z.string().regex(/^v\d+\.\d+\.\d+(?:[-+].+)?$/); // e.g. v1.2.3, v1.2.3-rc.1, v1.2.3+build.123
@@ -79,27 +78,33 @@ export const publicRouter = createTRPCRouter({
);
body = await response.json();
} catch (error) {
logger.info(
logger.error(
"[trpc.public.checkUpdate] failed to fetch latest-release api",
{
error,
},
);
return null;
}
const releases = ReleaseApiRes.safeParse(body);
if (!releases.success) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Release API response is invalid",
});
logger.error(
"[trpc.public.checkUpdate] Release API response is invalid, does not match schema",
{
error: releases.error,
},
);
return null;
}
const langfuseRelease = releases.data.find(
(release) => release.repo === "langfuse/langfuse",
);
if (!langfuseRelease) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Release API response is invalid",
});
logger.error(
"[trpc.public.checkUpdate] Release API response is invalid, does not contain langfuse/langfuse",
);
return null;
}
const updateType = compareVersions(VERSION, langfuseRelease.latestRelease);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "worker",
"version": "3.96.2",
"version": "3.97.2",
"description": "",
"license": "MIT",
"private": true,
@@ -16,6 +16,7 @@ type MigrationState = {
maxDate: string | undefined;
minDate: string | undefined;
queryTimeoutMinutes: number | undefined;
targetTracesAllAmtOnly: boolean | undefined;
};
/**
@@ -35,6 +36,7 @@ async function checkQueryExists(
SELECT COUNT(*) > 0 AS exists
FROM ${queryLogTable}
WHERE query_id = '${queryId}'
${env.CLICKHOUSE_CLUSTER_ENABLED === "true" ? " SETTINGS skip_unavailable_shards = 1 " : ""}
`,
format: "JSONEachRow",
});
@@ -60,6 +62,7 @@ async function checkCompletedQuery(
FROM ${queryLogTable}
WHERE query_id = '${queryId}' AND type != 'QueryStart'
LIMIT 1
${env.CLICKHOUSE_CLUSTER_ENABLED === "true" ? " SETTINGS skip_unavailable_shards = 1 " : ""}
`,
format: "JSONEachRow",
});
@@ -89,9 +92,7 @@ async function executeLongRunningQuery(
const abortController = new AbortController();
const timeoutMs = timeoutMinutes * 60 * 1000;
logger.info(
`[Background Migration] Executing traces_null backfill query ${queryId}`,
);
logger.info(`[Background Migration] Executing backfill query ${queryId}`);
// Start the query execution
const queryPromise = client.command({
@@ -206,7 +207,7 @@ export default class MigrateTracesToTracesAMTs implements IBackgroundMigration {
};
}
// Check if new ClickHouse tables exists
// Check if required ClickHouse tables exist
const tables = await clickhouseClient().query({
query: "SHOW TABLES",
});
@@ -247,6 +248,10 @@ export default class MigrateTracesToTracesAMTs implements IBackgroundMigration {
const queryTimeoutMinutes =
initialMigrationState.state?.queryTimeoutMinutes ??
(args.queryTimeoutMinutes as number | undefined);
const targetTracesAllAmtOnly =
initialMigrationState.state?.targetTracesAllAmtOnly ??
(args.targetTracesAllAmtOnly as boolean | undefined) ??
false;
const maxDate = initialMigrationState.state?.maxDate
? new Date(initialMigrationState.state.maxDate)
@@ -262,6 +267,7 @@ export default class MigrateTracesToTracesAMTs implements IBackgroundMigration {
maxDate,
minDate,
queryTimeoutMinutes,
targetTracesAllAmtOnly,
},
},
});
@@ -270,14 +276,21 @@ export default class MigrateTracesToTracesAMTs implements IBackgroundMigration {
const queryStart = Date.now();
// @ts-ignore
const migrationState: { state: { maxDate: string; minDate: string } } =
await prisma.backgroundMigration.findUniqueOrThrow({
where: { id: backgroundMigrationId },
select: { state: true },
});
const migrationState: {
state: {
maxDate: string;
minDate: string;
targetTracesAllAmtOnly?: boolean;
};
} = await prisma.backgroundMigration.findUniqueOrThrow({
where: { id: backgroundMigrationId },
select: { state: true },
});
const maxDate = new Date(migrationState.state.maxDate);
const minDate = new Date(migrationState.state.minDate);
const targetTracesAllAmtOnly =
migrationState.state.targetTracesAllAmtOnly ?? false;
// Get current month in YYYYMM format
const currentMonth = maxDate.toISOString().slice(0, 7).replace("-", "");
@@ -285,45 +298,89 @@ export default class MigrateTracesToTracesAMTs implements IBackgroundMigration {
`[Background Migration] Migrating traces for ${currentMonth}`,
);
const query = `
INSERT INTO traces_null
SELECT
-- Identifiers
project_id,
id,
timestamp as start_time,
null as end_time,
name,
-- Metadata properties
metadata,
user_id,
session_id,
environment,
tags,
version,
release,
-- UI Properties
bookmarked,
public,
-- Aggregations (ignored)
[] as observation_ids,
[] as score_ids,
map() as cost_details,
map() as usage_details,
-- Input/Output
input,
output,
created_at,
updated_at,
event_ts
FROM traces
WHERE toYYYYMM(timestamp) = ${currentMonth}
`;
const targetTable = targetTracesAllAmtOnly
? "traces_all_amt"
: "traces_null";
const query = targetTracesAllAmtOnly
? `
INSERT INTO ${targetTable}
SELECT
-- Identifiers
project_id,
id,
t.timestamp as timestamp,
t.timestamp as start_time,
t.timestamp as end_time,
name,
-- Metadata properties
metadata,
user_id,
session_id,
environment,
tags,
version,
release,
-- UI Properties
arrayReduce('argMaxState', [toNullable(bookmarked)], [event_ts]) as bookmarked,
arrayReduce('argMaxState', [toNullable(public)], [event_ts]) as public,
-- Aggregations
[] as observation_ids,
[] as score_ids,
map() as cost_details,
map() as usage_details,
-- Input/Output
arrayReduce('argMaxState', [coalesce(input, '')], [if(coalesce(input, '') <> '', event_ts, toDateTime64(0, 3))]) as input,
arrayReduce('argMaxState', [coalesce(output, '')], [if(coalesce(output, '') <> '', event_ts, toDateTime64(0, 3))]) as output,
created_at,
updated_at
FROM traces t
WHERE toYYYYMM(t.timestamp) = ${currentMonth}
`
: `
INSERT INTO ${targetTable}
SELECT
-- Identifiers
project_id,
id,
timestamp as start_time,
null as end_time,
name,
-- Metadata properties
metadata,
user_id,
session_id,
environment,
tags,
version,
release,
-- UI Properties
bookmarked,
public,
-- Aggregations (ignored)
[] as observation_ids,
[] as score_ids,
map() as cost_details,
map() as usage_details,
-- Input/Output
input,
output,
created_at,
updated_at,
event_ts
FROM traces
WHERE toYYYYMM(timestamp) = ${currentMonth}
`;
await executeLongRunningQuery(query, queryTimeoutMinutes ?? 90);
@@ -339,7 +396,7 @@ export default class MigrateTracesToTracesAMTs implements IBackgroundMigration {
});
logger.info(
`[Background Migration] Inserted traces into traces_null for ${currentMonth} in ${Date.now() - queryStart}ms`,
`[Background Migration] Inserted traces into ${targetTable} for ${currentMonth} in ${Date.now() - queryStart}ms`,
);
if (maxDate < minDate) {
@@ -410,6 +467,10 @@ async function main() {
short: "t",
default: "90",
},
targetTracesAllAmtOnly: {
type: "boolean",
default: false,
},
},
});
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.96.2";
export const VERSION = "v3.97.2";
+65 -2
View File
@@ -1873,8 +1873,8 @@
},
{
"id": "cmcnjkrfa000207l4fpnh5mnv",
"model_name": "gemini-2.5-flash-lite-preview-06-17",
"match_pattern": "(?i)^(gemini-2.5-flash-lite-preview-06-17)$",
"model_name": "gemini-2.5-flash-lite",
"match_pattern": "(?i)^(gemini-2.5-flash-lite)$",
"created_at": "2025-07-03T13:44:06.964Z",
"updated_at": "2025-07-03T13:44:06.964Z",
"prices": {
@@ -1932,6 +1932,27 @@
},
"tokenizer_id": "openai"
},
{
"id": "12543803-2d5f-4189-addc-821ad71c8b55",
"model_name": "gpt-5-2025-08-07",
"match_pattern": "(?i)^(gpt-5-2025-08-07)$",
"created_at": "2025-08-11T08:00:00.000Z",
"updated_at": "2025-08-11T08:00:00.000Z",
"prices": {
"input": 1.25e-6,
"input_cached_tokens": 0.125e-6,
"output": 10.0e-6,
"input_cache_read": 0.125e-6,
"output_reasoning_tokens": 10e-6,
"output_reasoning": 10e-6
},
"tokenizer_config": {
"tokensPerName": 1,
"tokenizerModel": "gpt-4",
"tokensPerMessage": 3
},
"tokenizer_id": "openai"
},
{
"id": "3d6a975a-a42d-4ea2-a3ec-4ae567d5a364",
"model_name": "gpt-5-mini",
@@ -1953,6 +1974,27 @@
},
"tokenizer_id": "openai"
},
{
"id": "03b83894-7172-4e1e-8e8b-37d792484efd",
"model_name": "gpt-5-mini-2025-08-07",
"match_pattern": "(?i)^(gpt-5-mini-2025-08-07)$",
"created_at": "2025-08-11T08:00:00.000Z",
"updated_at": "2025-08-11T08:00:00.000Z",
"prices": {
"input": 0.25e-6,
"input_cached_tokens": 0.025e-6,
"output": 2.0e-6,
"input_cache_read": 0.025e-6,
"output_reasoning_tokens": 2e-6,
"output_reasoning": 2e-6
},
"tokenizer_config": {
"tokensPerName": 1,
"tokenizerModel": "gpt-4",
"tokensPerMessage": 3
},
"tokenizer_id": "openai"
},
{
"id": "f0b40234-b694-4c40-9494-7b0efd860fb9",
"model_name": "gpt-5-nano",
@@ -1974,6 +2016,27 @@
},
"tokenizer_id": "openai"
},
{
"id": "4489fde4-a594-4011-948b-526989300cd3",
"model_name": "gpt-5-nano-2025-08-07",
"match_pattern": "(?i)^(gpt-5-nano-2025-08-07)$",
"created_at": "2025-08-11T08:00:00.000Z",
"updated_at": "2025-08-11T08:00:00.000Z",
"prices": {
"input": 0.05e-6,
"input_cached_tokens": 0.005e-6,
"output": 0.4e-6,
"input_cache_read": 0.005e-6,
"output_reasoning_tokens": 0.4e-6,
"output_reasoning": 0.4e-6
},
"tokenizer_config": {
"tokensPerName": 1,
"tokenizerModel": "gpt-4",
"tokensPerMessage": 3
},
"tokenizer_id": "openai"
},
{
"id": "8ba72ee3-ebe8-4110-a614-bf81094447e5",
"model_name": "gpt-5-chat-latest",
@@ -135,25 +135,46 @@ export const getDatabaseReadStream = async ({
clickhouseConfigs,
});
return scores.map((score) => ({
id: score.id,
traceId: score.traceId,
sessionId: score.sessionId,
datasetRunId: score.datasetRunId,
timestamp: score.timestamp,
source: score.source,
name: score.name,
dataType: score.dataType,
value: score.value,
stringValue: score.stringValue,
comment: score.comment,
metadata: score.metadata,
observationId: score.observationId,
traceName: score.traceName,
userId: score.traceUserId,
traceTags: score.traceTags,
environment: score.environment,
}));
// Get author user info for scores
// Only users that have valid project write access may write scores
// Only users with at least MEMBER permissions (projectMembers:read) may trigger batch exports
const users = await prisma.user.findMany({
where: {
id: {
in: scores
.map((score) => score.authorUserId)
.filter((s): s is string => Boolean(s)),
},
},
select: {
id: true,
name: true,
},
});
return scores.map((score) => {
const user = users.find((u) => u.id === score.authorUserId);
return {
id: score.id,
traceId: score.traceId,
sessionId: score.sessionId,
datasetRunId: score.datasetRunId,
timestamp: score.timestamp,
source: score.source,
name: score.name,
dataType: score.dataType,
value: score.value,
stringValue: score.stringValue,
comment: score.comment,
metadata: score.metadata,
observationId: score.observationId,
traceName: score.traceName,
userId: score.traceUserId,
traceTags: score.traceTags,
environment: score.environment,
authorUserName: user?.name ?? null,
};
});
},
env.BATCH_EXPORT_PAGE_SIZE,
rowLimit,
@@ -34,7 +34,6 @@ import {
} from "./traceFilterUtils";
import {
ChatMessageRole,
ForbiddenError,
LangfuseNotFoundError,
Prisma,
singleFilter,
@@ -490,13 +489,7 @@ export const evaluate = async ({
return;
}
if (!job?.job_input_trace_id) {
throw new ForbiddenError(
"Jobs can only be executed on traces and dataset runs for now.",
);
}
if (job.status === "CANCELLED") {
if (job.status === "CANCELLED" || !job?.job_input_trace_id) {
logger.debug(`Job ${job.id} for project ${event.projectId} was cancelled.`);
await kyselyPrisma.$kysely
@@ -9,21 +9,13 @@ export const processPostgresTraceDelete = async (
`Deleting traces ${JSON.stringify(traceIds)} in project ${projectId} from Postgres`,
);
try {
await prisma.jobExecution.updateMany({
await prisma.jobExecution.deleteMany({
where: {
jobInputTraceId: {
in: traceIds,
},
projectId: projectId,
},
data: {
jobInputTraceId: {
set: null,
},
jobInputObservationId: {
set: null,
},
},
});
} catch (e) {
logger.error(
+22 -1
View File
@@ -1,7 +1,7 @@
import { z } from "zod/v4";
import { prisma } from "@langfuse/shared/src/db";
import defaultModelPrices from "../constants/default-model-prices.json";
import { logger } from "@langfuse/shared/src/server";
import { clearFullModelCache, logger } from "@langfuse/shared/src/server";
const DefaultModelPriceSchema = z.object({
id: z.string(),
@@ -50,6 +50,7 @@ const ExistingModelPriceSchema = z.object({
export const upsertDefaultModelPrices = async (force = false) => {
const startTime = Date.now();
try {
let hasUpdates = false;
logger.debug(`Starting upsert of default model prices (force = ${force})`);
const parsedDefaultModelPrices = z
@@ -129,6 +130,17 @@ export const upsertDefaultModelPrices = async (force = false) => {
continue;
}
if (
!existingModelUpdateDate &&
Object.keys(defaultModelPrice.prices).length === 0
) {
logger.debug(
`No new and existing prices for ${defaultModelPrice.model_name} (${defaultModelPrice.id}). Skipping.`,
);
continue;
}
// Upsert model and prices in a transaction
promises.push(
prisma
@@ -205,10 +217,19 @@ export const upsertDefaultModelPrices = async (force = false) => {
);
}
if (promises.length > 0) {
hasUpdates = true;
}
await Promise.all(promises);
logger.debug(`Completed batch ${i + 1} of ${numBatches}`);
}
if (hasUpdates) {
await clearFullModelCache();
}
logger.info(
`Finished upserting default model prices in ${Date.now() - startTime}ms`,
);
@@ -502,16 +502,16 @@ This checklist documents all references and invocations to the `traces` table gr
### 4. Aggregation and Analytics Queries
- [ ] **getTracesCountForPublicApi()** - `web/src/features/public-api/server/traces.ts:299`
- [ ] **generateDailyMetrics()** - `web/src/features/public-api/server/dailyMetrics.ts:93`
- [ ] **getDailyMetricsCount()** - `web/src/features/public-api/server/dailyMetrics.ts:153`
- [x] **getTracesCountForPublicApi()** - `web/src/features/public-api/server/traces.ts:299`
- [x] **generateDailyMetrics()** - `web/src/features/public-api/server/dailyMetrics.ts:93`
- [x] **getDailyMetricsCount()** - `web/src/features/public-api/server/dailyMetrics.ts:153`
- [x] **generateObservationsForPublicApi()** - `web/src/features/public-api/server/observations.ts:80`
- [x] **getObservationsCountForPublicApi()** - `web/src/features/public-api/server/observations.ts:108`
- [x] **getObservationsTableInternal()** - `packages/shared/src/server/repositories/observations.ts:565`
- [x] **_handleGenerateScoresForPublicApi()** - `web/src/features/public-api/server/scores.ts:101`
- [x] **_handleGetScoresCountForPublicApi()** - `web/src/features/public-api/server/scores.ts:181`
- [x] **getScoresUiGeneric()** - `packages/shared/src/server/repositories/scores.ts:825`
- [ ] **getNumericScoreHistogram()** - `packages/shared/src/server/repositories/scores.ts:1074`
- [x] **getNumericScoreHistogram()** - `packages/shared/src/server/repositories/scores.ts:1074`
- [x] **getTracesGroupedByName()** - `packages/shared/src/server/repositories/traces.ts:489-535`
- [x] **getTracesGroupedByUsers()** - `packages/shared/src/server/repositories/traces.ts:537-597`
- [x] **getTracesGroupedByTags()** - `packages/shared/src/server/repositories/traces.ts:605-640`
@@ -527,10 +527,10 @@ Note: The measureAndReturn utility does not handle query streams well as it prom
We need to cover these queries manually and cannot run a comparison.
We could use an opt-in on a projectId basis.
- [ ] **getTracesForPostHog()** - `packages/shared/src/server/repositories/traces.ts:1026-1113`
- [ ] **getScoresForPostHog()** - `packages/shared/src/server/repositories/scores.ts:1328`
- [ ] **getGenerationsForPosthog()** - `packages/shared/src/server/repositories/observations.ts:1481`
- [ ] **getTracesForBlobStorageExport()** - `packages/shared/src/server/repositories/traces.ts:980-1024`
- [x] **getTracesForPostHog()** - `packages/shared/src/server/repositories/traces.ts:1026-1113`
- [x] **getScoresForPostHog()** - `packages/shared/src/server/repositories/scores.ts:1328`
- [x] **getGenerationsForPosthog()** - `packages/shared/src/server/repositories/observations.ts:1481`
- [x] **getTracesForBlobStorageExport()** - `packages/shared/src/server/repositories/traces.ts:980-1024`
### 6. Count and Statistics Queries