Compare commits

...
51 Commits
Author SHA1 Message Date
steffen911 daf4e2fe02 chore: release v3.97.3 2025-08-13 11:38:24 +02:00
Steffen SchmitzandGitHub 129693fb69 chore: skip body validation for bullmq GET requests (#8490) 2025-08-13 09:15:33 +00:00
Steffen SchmitzandGitHub 204948db44 perf: skip FINAL for traces all route without metrics (#8489) 2025-08-13 09:15:29 +00:00
marliessophieandGitHub d42ba5fc99 fix(evals): redirect and pull data for latest template version post template edit (#8491) 2025-08-13 09:15:25 +00:00
Steffen SchmitzandGitHub 97a539ced3 chore: allow whitelisting which AMTs can be used (#8488) 2025-08-13 09:12:07 +00:00
Leo WeigandandGitHub 9d8dace197 feat(llm-connections): populate gcp region in update form (#8487)
feat(llm-connections): correctly show gcp region in update form
2025-08-13 08:58:31 +00:00
marliessophieandGitHub bc2dc4d89c feat(annotation): add POST queue API (#8478)
* feat(annotation): add POST queue API

* chore: fix types

* docs: add API ref

* chore: improve score configs check

* chore: add postman collection
2025-08-12 17:25:19 +00:00
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
steffen911 c427791070 chore: release v3.96.2 2025-08-08 16:35:11 +02:00
Steffen SchmitzandGitHub 25220aef55 perf: allow sharding for trace upsert queue (#8396)
* perf: allow sharding for trace upsert queue

* chore: handle metrics for traceupsertqueue

* chore: remove outdated comment

* chore: test util upgrade

* chore: patch typing
2025-08-08 14:16:27 +00:00
Max Deichmann 8c02d5dc22 chore: release v3.96.1 2025-08-08 15:57:05 +02:00
Max DeichmannandGitHub 0b60076871 chore: upgrade form data (#8408) 2025-08-08 13:17:12 +00:00
marliessophieandGitHub b28a83531b fix(evals): display final status in peek view (#8406) 2025-08-08 13:12:38 +00:00
Steffen SchmitzandGitHub aa4d34ae66 chore: skip S3 list and legacy trace insert based on env flag (#8298)
* chore: skip S3 list and legacy trace insert based on env flag

* chore: adjust skip s3 list conditions

* chore; refactor traceUpserQueue entity assoication

* chore: reverse
2025-08-08 12:47:04 +00:00
Steffen SchmitzandGitHub cca352ad13 chore: remove trace null deletions (#8405) 2025-08-08 15:02:37 +02:00
Steffen SchmitzandGitHub 58c96cada7 chore: include boolean scores in the reference of scores-numeric (#8086) 2025-08-08 12:39:56 +00:00
Steffen SchmitzandGitHub 6f93389936 chore: adjust devcontainer setup to install deps via dockerfile (#8399)
* chore: adjust devcontainer setup to install deps via dockerfile

* Update Dockerfile
2025-08-08 12:10:05 +00:00
Max DeichmannandGitHub b8d9586490 chore: fix dev command (#8404) 2025-08-08 14:38:21 +02:00
Leo 534f5696ad chore: release v3.96.0 2025-08-08 14:20:05 +02:00
Leo WeigandandGitHub 4cebe2831c fix(onboarding): only show org onboarding for Cloud and more UX touches (#8392) 2025-08-08 14:17:49 +02:00
45a5f3b8a2 chore: upgrade slack dependency (#8402)
* chore: upgrade slack dependencies

* chore: upgrade slack deps and remove unnecessary deps from web/worker

---------

Co-authored-by: Max Deichmann <m.deichmann@tum.de>
2025-08-08 14:05:41 +02:00
Max DeichmannandGitHub 8dea9b6a3a chore: remove explicit axios dependency (#8397)
remove axios
2025-08-08 11:38:42 +00:00
marliessophieandGitHub c5b781d3f6 fix(llm-connections): ensure dialog remains open when switching tabs (#8391)
* fix(llm-connections): ensure dialog remains open when switching tabs

* chore: lint
2025-08-08 09:52:48 +00:00
Leo WeigandandGitHub 07469c928d feat(cloud): new onboarding survey to better understand our users (#8374) 2025-08-08 10:37:13 +02:00
Marc KlingenandGitHub 5b10110dfe fix(auth): honor NEXT_PUBLIC_BASE_PATH in password reset verification (#8385) 2025-08-07 23:14:32 +00:00
Thorsten SpiekerandGitHub 0eb5d1c3c0 feat: pivot table sorting (#8214) 2025-08-08 00:12:09 +02:00
97 changed files with 3588 additions and 1002 deletions
-6
View File
@@ -1,6 +0,0 @@
{
"name": "langfuse-development",
"forwardPorts": [3000, 5432, 6379, 8123, 9000],
"onCreateCommand": "npm install -g pnpm@9.5.0",
"postCreateCommand": "curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz && git restore LICENSE README.md && chmod +x migrate && sudo mv migrate /usr/bin && cp .env.dev.example .env && npm install -g @anthropic-ai/claude-code && pnpm i"
}
+13
View File
@@ -0,0 +1,13 @@
# Dev container Dockerfile
FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:20-bookworm
# Install golang-migrate for database migrations
RUN curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz && \
chmod +x migrate && \
mv migrate /usr/local/bin/migrate
# Install pnpm globally
RUN npm install -g pnpm@9.5.0
# Install Claude Code CLI
RUN npm install -g @anthropic-ai/claude-code
+8
View File
@@ -0,0 +1,8 @@
{
"name": "langfuse-development",
"build": {
"dockerfile": "Dockerfile"
},
"forwardPorts": [3000, 5432, 6379, 8123, 9000],
"postCreateCommand": "cp .env.dev.example .env && pnpm i"
}
+1
View File
@@ -76,6 +76,7 @@ REDIS_AUTH="bitnami"
REDIS_CLUSTER_ENABLED="true"
REDIS_CLUSTER_NODES="127.0.0.1:6370,127.0.0.1:6371,127.0.0.1:6372,127.0.0.1:6373,127.0.0.1:6374,127.0.0.1:6375"
LANGFUSE_INGESTION_QUEUE_SHARD_COUNT=8
LANGFUSE_TRACE_UPSERT_QUEUE_SHARD_COUNT=4
# openssl rand -hex 32 used only here
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
-2
View File
@@ -57,8 +57,6 @@ yarn-error.log*
# vscode
.devcontainer
node_modules
**/node_modules
**/dist
+1 -1
View File
@@ -1 +1 @@
v20
v20.19.2
-1
View File
@@ -26,7 +26,6 @@
"dependencies": {
"@langfuse/shared": "workspace:*",
"@opentelemetry/api": ">=1.0.0 <1.10.0",
"axios": "^1.8.2",
"https-proxy-agent": "^7.0.6",
"next": "^14.2.30",
"next-auth": "^4.24.11",
@@ -22,6 +22,13 @@ service:
docs: limit of items per page
response: PaginatedAnnotationQueues
createQueue:
docs: Create an annotation queue
method: POST
path: /annotation-queues
request: CreateAnnotationQueueRequest
response: AnnotationQueue
getQueue:
docs: Get an annotation queue by ID
method: GET
@@ -168,6 +175,12 @@ types:
data: list<AnnotationQueueItem>
meta: pagination.MetaResponse
CreateAnnotationQueueRequest:
properties:
name: string
description: optional<string>
scoreConfigIds: list<string>
CreateAnnotationQueueItemRequest:
properties:
objectId: string
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.95.2",
"version": "3.97.3",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
@@ -91,7 +91,8 @@
"nanoid": "^3.3.8",
"katex": "^0.16.21",
"tar-fs": "^2.1.2",
"rollup@^4.0.0": "^4.22.4"
"rollup@^4.0.0": "^4.22.4",
"@types/node-fetch": "^2.6.13"
},
"patchedDependencies": {
"next-auth@4.24.11": "patches/next-auth@4.24.11.patch"
+2 -3
View File
@@ -73,10 +73,9 @@
"@prisma/client": "^6.10.1",
"@react-email/components": "^0.1.0",
"@react-email/render": "^1.1.2",
"@slack/oauth": "^2.6.0",
"@slack/web-api": "^7.0.0",
"@slack/oauth": "^3.0.3",
"@slack/web-api": "^7.9.3",
"@types/bcryptjs": "^2.4.6",
"axios": "^1.8.2",
"bcryptjs": "^2.4.3",
"bullmq": "^5.34.10",
"dd-trace": "^5.36.0",
+15
View File
@@ -150,6 +150,11 @@ export const ActionExecutionStatus = {
} as const;
export type ActionExecutionStatus =
(typeof ActionExecutionStatus)[keyof typeof ActionExecutionStatus];
export const SurveyName = {
ORG_ONBOARDING: "org_onboarding",
USER_ONBOARDING: "user_onboarding",
} as const;
export type SurveyName = (typeof SurveyName)[keyof typeof SurveyName];
export type Account = {
id: string;
user_id: string;
@@ -749,6 +754,15 @@ export type SsoConfig = {
auth_provider: string;
auth_config: unknown | null;
};
export type Survey = {
id: string;
created_at: Generated<Timestamp>;
survey_name: SurveyName;
response: unknown;
user_id: string | null;
user_email: string | null;
org_id: string | null;
};
export type TableViewPreset = {
id: string;
created_at: Generated<Timestamp>;
@@ -858,6 +872,7 @@ export type DB = {
Session: Session;
slack_integrations: SlackIntegration;
sso_configs: SsoConfig;
surveys: Survey;
table_view_presets: TableViewPreset;
trace_media: TraceMedia;
trace_sessions: TraceSession;
@@ -0,0 +1,24 @@
-- CreateEnum
CREATE TYPE "SurveyName" AS ENUM ('org_onboarding', 'user_onboarding');
-- CreateTable
CREATE TABLE "surveys" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"survey_name" "SurveyName" NOT NULL,
"response" JSONB NOT NULL,
"user_id" TEXT,
"user_email" TEXT,
"org_id" TEXT,
CONSTRAINT "surveys_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "surveys" ADD CONSTRAINT "surveys_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "surveys" ADD CONSTRAINT "surveys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- RenameIndex
ALTER INDEX "annotation_queue_assignments_project_id_queue_id_key" RENAME TO "annotation_queue_assignments_project_id_queue_id_user_id_key";
+23
View File
@@ -89,6 +89,7 @@ model User {
tableViewPresetCreated TableViewPreset[] @relation("CreatedByUser")
tableViewPresetUpdated TableViewPreset[] @relation("UpdatedByUser")
annotationQueueAssignment AnnotationQueueAssignment[]
surveys Survey[]
@@map("users")
}
@@ -113,6 +114,7 @@ model Organization {
projects Project[]
MembershipInvitation MembershipInvitation[]
ApiKey ApiKey[]
surveys Survey[]
@@map("organizations")
}
@@ -1397,3 +1399,24 @@ model PendingDeletion {
@@index([objectId, object])
@@map("pending_deletions")
}
model Survey {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map("created_at")
surveyName SurveyName @map("survey_name")
response Json
userId String? @map("user_id")
userEmail String? @map("user_email")
orgId String? @map("org_id")
org Organization? @relation(fields: [orgId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("surveys")
}
enum SurveyName {
ORG_ONBOARDING @map("org_onboarding")
USER_ONBOARDING @map("user_onboarding")
@@map("SurveyName")
}
+11 -1
View File
@@ -50,6 +50,10 @@ const EnvSchema = z.object({
.nonnegative()
.default(15_000),
LANGFUSE_INGESTION_QUEUE_SHARD_COUNT: z.coerce.number().positive().default(1),
LANGFUSE_TRACE_UPSERT_QUEUE_SHARD_COUNT: z.coerce
.number()
.positive()
.default(1),
LANGFUSE_TRACE_DELETE_DELAY_MS: z.coerce
.number()
.nonnegative()
@@ -154,6 +158,12 @@ const EnvSchema = z.object({
LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES: z
.enum(["true", "false"])
.default("false"),
LANGFUSE_EXPERIMENT_WHITELISTED_AMT_TABLES: z
.string()
.optional()
.transform((s) =>
s ? s.split(",").map((s) => s.toLowerCase().trim()) : [],
),
LANGFUSE_INGESTION_PROCESSING_SAMPLED_PROJECTS: z
.string()
.optional()
@@ -186,10 +196,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",
+6 -6
View File
@@ -6,7 +6,6 @@ import { DatasetRunItemUpsertQueue } from "./datasetRunItemUpsert";
import { EvalExecutionQueue } from "./evalExecutionQueue";
import { ExperimentCreateQueue } from "./experimentCreateQueue";
import { SecondaryIngestionQueue } from "./ingestionQueue";
import { TraceUpsertQueue } from "./traceUpsert";
import { TraceDeleteQueue } from "./traceDelete";
import { ProjectDeleteQueue } from "./projectDelete";
import { PostHogIntegrationQueue } from "./postHogIntegrationQueue";
@@ -25,10 +24,13 @@ import { WebhookQueue } from "./webhookQueue";
import { EntityChangeQueue } from "./entityChangeQueue";
import { DatasetDeleteQueue } from "./datasetDelete";
// IngestionQueue is sharded and requires a sharding key
// Use IngestionQueue.getInstance({ shardName: queueName }) directly instead
// IngestionQueue and TraceUpsert are sharded and require a sharding key
// Use IngestionQueue.getInstance({ shardName: queueName }) or TraceUpsertQueue.getInstance({ shardName: queueName }) directly instead
export function getQueue(
queueName: Exclude<QueueName, QueueName.IngestionQueue>,
queueName: Exclude<
QueueName,
QueueName.IngestionQueue | QueueName.TraceUpsert
>,
): Queue | null {
switch (queueName) {
case QueueName.BatchExport:
@@ -43,8 +45,6 @@ export function getQueue(
return EvalExecutionQueue.getInstance();
case QueueName.ExperimentCreate:
return ExperimentCreateQueue.getInstance();
case QueueName.TraceUpsert:
return TraceUpsertQueue.getInstance();
case QueueName.TraceDelete:
return TraceDeleteQueue.getInstance();
case QueueName.ProjectDelete:
+72 -25
View File
@@ -6,45 +6,92 @@ import {
getQueuePrefix,
} from "./redis";
import { logger } from "../logger";
import { getShardIndex } from "./sharding";
import { env } from "../../env";
export class TraceUpsertQueue {
private static instance: Queue<TQueueJobTypes[QueueName.TraceUpsert]> | null =
null;
private static instances: Map<
number,
Queue<TQueueJobTypes[QueueName.TraceUpsert]> | null
> = new Map();
public static getInstance(): Queue<
TQueueJobTypes[QueueName.TraceUpsert]
> | null {
if (TraceUpsertQueue.instance) return TraceUpsertQueue.instance;
public static getShardNames() {
return Array.from(
{ length: env.LANGFUSE_TRACE_UPSERT_QUEUE_SHARD_COUNT },
(_, i) => `${QueueName.TraceUpsert}${i > 0 ? `-${i}` : ""}`,
);
}
static getShardIndexFromShardName(
shardName: string | undefined,
): number | null {
if (!shardName) return null;
// Extract shard index from shard name
const shardIndex =
shardName === QueueName.TraceUpsert
? 0
: parseInt(shardName.replace(`${QueueName.TraceUpsert}-`, ""), 10);
if (isNaN(shardIndex)) return null;
return shardIndex;
}
/**
* Get the trace upsert queue instance for the given sharding key or shard name.
* @param shardingKey - ShardingKey is being hashed and randomly allocated to a shard. Should be `projectId-traceId`.
* @param shardName - Name of the shard. Should be `trace-upsert-queue-${shardIndex}` or plainly `trace-upsert-queue` for the first shard.
*/
public static getInstance({
shardingKey,
shardName,
}: {
shardingKey?: string;
shardName?: string;
} = {}): Queue<TQueueJobTypes[QueueName.TraceUpsert]> | null {
const shardIndex =
TraceUpsertQueue.getShardIndexFromShardName(shardName) ??
(env.REDIS_CLUSTER_ENABLED === "true" && shardingKey
? getShardIndex(
shardingKey,
env.LANGFUSE_TRACE_UPSERT_QUEUE_SHARD_COUNT,
)
: 0);
// Check if we already have an instance for this shard
if (TraceUpsertQueue.instances.has(shardIndex)) {
return TraceUpsertQueue.instances.get(shardIndex) || null;
}
const newRedis = createNewRedisInstance({
enableOfflineQueue: false,
...redisQueueRetryOptions,
});
TraceUpsertQueue.instance = newRedis
? new Queue<TQueueJobTypes[QueueName.TraceUpsert]>(
QueueName.TraceUpsert,
{
connection: newRedis,
prefix: getQueuePrefix(QueueName.TraceUpsert),
defaultJobOptions: {
removeOnComplete: 100, // Important: If not true, new jobs for that ID would be ignored as jobs in the complete set are still considered as part of the queue
removeOnFail: 100_000,
attempts: 5,
delay: 15_000, // 15 seconds
backoff: {
type: "exponential",
delay: 5000,
},
const name = `${QueueName.TraceUpsert}${shardIndex > 0 ? `-${shardIndex}` : ""}`;
const queueInstance = newRedis
? new Queue<TQueueJobTypes[QueueName.TraceUpsert]>(name, {
connection: newRedis,
prefix: getQueuePrefix(name),
defaultJobOptions: {
removeOnComplete: 100,
removeOnFail: 100_000,
attempts: 5,
delay: 15_000, // 15 seconds
backoff: {
type: "exponential",
delay: 5000,
},
},
)
})
: null;
TraceUpsertQueue.instance?.on("error", (err) => {
logger.error("TraceUpsertQueue error", err);
queueInstance?.on("error", (err) => {
logger.error(`TraceUpsertQueue shard ${shardIndex} error`, err);
});
return TraceUpsertQueue.instance;
TraceUpsertQueue.instances.set(shardIndex, queueInstance);
return queueInstance;
}
}
@@ -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,
@@ -47,12 +47,16 @@ enum TracesAMTs {
* for <= 29 days, we use traces_30d_amt,
* for all other cases we use traces_all_amt.
*
* If LANGFUSE_EXPERIMENT_WHITELISTED_AMT_TABLES is set, we only return timeframes
* that are whitelisted or fallback to the traces_all_amt.
*
* @param fromTimestamp
*/
export const getTimeframesTracesAMT = (
fromTimestamp: Date | undefined,
): TracesAMTs => {
if (!fromTimestamp) {
// The TracesAllAMT must always be returned if there is no timestamp.
return TracesAMTs.TracesAllAMT;
}
@@ -60,12 +64,21 @@ export const getTimeframesTracesAMT = (
const diffInDays = Math.floor(
(now.getTime() - fromTimestamp.getTime()) / (1000 * 60 * 60 * 24),
);
let selectedTable: TracesAMTs;
if (diffInDays <= 6) {
return TracesAMTs.Traces7dAMT;
selectedTable = TracesAMTs.Traces7dAMT;
} else if (diffInDays <= 29) {
return TracesAMTs.Traces30dAMT;
selectedTable = TracesAMTs.Traces30dAMT;
} else {
selectedTable = TracesAMTs.TracesAllAMT;
}
return TracesAMTs.TracesAllAMT;
// Check if the selected table is whitelisted, fallback to TracesAllAMT if not
return env.LANGFUSE_EXPERIMENT_WHITELISTED_AMT_TABLES.length === 0 ||
env.LANGFUSE_EXPERIMENT_WHITELISTED_AMT_TABLES.includes(selectedTable)
? selectedTable
: TracesAMTs.TracesAllAMT;
};
/**
@@ -1184,19 +1197,6 @@ export const deleteTraces = async (projectId: string, traceIds: string[]) => {
},
tags: input.tags,
}),
// Delete from traces_null
commandClickhouse({
query: `
DELETE FROM traces_null
WHERE project_id = {projectId: String}
AND id IN ({traceIds: Array(String)});
`,
params: input.params,
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: input.tags,
}),
// Delete from traces_all_amt
commandClickhouse({
query: `
@@ -1377,18 +1377,6 @@ export const deleteTracesByProjectId = async (projectId: string) => {
},
tags: input.tags,
}),
// Delete from traces_null
commandClickhouse({
query: `
DELETE FROM traces_null
WHERE project_id = {projectId: String};
`,
params: input.params,
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: input.tags,
}),
// Delete from traces_all_amt
commandClickhouse({
query: `
@@ -1761,6 +1749,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,
@@ -1773,12 +1765,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)}
@@ -1796,6 +1788,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,
@@ -1808,6 +1801,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,
@@ -1835,7 +1832,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)}
@@ -1854,6 +1851,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,
@@ -35,6 +35,12 @@ export const HistogramChartConfig = BaseTotalValueChartConfig.extend({
export const PivotTableChartConfig = BaseTotalValueChartConfig.extend({
type: z.literal("PIVOT_TABLE"),
defaultSort: z
.object({
column: z.string(),
order: z.enum(["ASC", "DESC"]),
})
.optional(),
});
// Define dimension schema
@@ -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: {
@@ -15,6 +15,7 @@ export function createBasicAuthHeader(
export type CreateOrgProjectAndApiKeyOptions = {
projectId?: string;
plan?: "Team" | "Hobby" | "Core" | "Pro" | "Enterprise";
};
export const createOrgProjectAndApiKey = async (
props?: CreateOrgProjectAndApiKeyOptions,
@@ -25,7 +26,7 @@ export const createOrgProjectAndApiKey = async (
id: v4(),
name: v4(),
cloudConfig: CloudConfigSchema.parse({
plan: "Team",
plan: props?.plan ?? "Team",
}),
},
});
+88 -147
View File
@@ -9,6 +9,7 @@ overrides:
katex: ^0.16.21
tar-fs: ^2.1.2
rollup@^4.0.0: ^4.22.4
'@types/node-fetch': ^2.6.13
patchedDependencies:
next-auth@4.24.11:
@@ -49,9 +50,6 @@ importers:
'@opentelemetry/api':
specifier: '>=1.0.0 <1.10.0'
version: 1.9.0
axios:
specifier: ^1.8.2
version: 1.8.2
https-proxy-agent:
specifier: ^7.0.6
version: 7.0.6
@@ -188,17 +186,14 @@ importers:
specifier: ^1.1.2
version: 1.1.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@slack/oauth':
specifier: ^2.6.0
version: 2.6.3
specifier: ^3.0.3
version: 3.0.3
'@slack/web-api':
specifier: ^7.0.0
specifier: ^7.9.3
version: 7.9.3
'@types/bcryptjs':
specifier: ^2.4.6
version: 2.4.6
axios:
specifier: ^1.8.2
version: 1.8.2
bcryptjs:
specifier: ^2.4.3
version: 2.4.3
@@ -228,10 +223,10 @@ importers:
version: 0.27.4
langchain:
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))
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.11.0)(cheerio@1.0.0)(handlebars@4.7.8)(openai@4.104.0(zod@3.25.62))
langfuse-langchain:
specifier: 3.38.4
version: 3.38.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)))
version: 3.38.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.11.0)(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
@@ -518,12 +513,6 @@ importers:
'@sentry/nextjs':
specifier: ^8.52.0
version: 8.55.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.53.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.26.0(@opentelemetry/api@1.9.0))(next@14.2.30(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)(webpack@5.97.1)
'@slack/oauth':
specifier: ^2.6.0
version: 2.6.3
'@slack/web-api':
specifier: ^7.0.0
version: 7.9.3
'@t3-oss/env-nextjs':
specifier: ^0.11.1
version: 0.11.1(typescript@5.4.5)(zod@3.25.62)
@@ -912,9 +901,6 @@ importers:
'@prisma/instrumentation':
specifier: ^6.3.0
version: 6.3.0(@opentelemetry/api@1.9.0)
'@slack/web-api':
specifier: ^7.0.0
version: 7.9.3
backoff:
specifier: ^2.5.0
version: 2.5.0
@@ -2304,6 +2290,9 @@ packages:
'@floating-ui/core@1.7.2':
resolution: {integrity: sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==}
'@floating-ui/core@1.7.3':
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
'@floating-ui/dom@1.6.11':
resolution: {integrity: sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ==}
@@ -2313,6 +2302,9 @@ packages:
'@floating-ui/dom@1.7.2':
resolution: {integrity: sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==}
'@floating-ui/dom@1.7.3':
resolution: {integrity: sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==}
'@floating-ui/react-dom@1.3.0':
resolution: {integrity: sha512-htwHm67Ji5E/pROEAr7f8IKFShuiCKHwUC/UY4vC3I5jiSvGFAYnSYiZO5MlGmads+QqvUkR9ANHEguGrDv72g==}
peerDependencies:
@@ -2331,6 +2323,12 @@ packages:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/react-dom@2.1.5':
resolution: {integrity: sha512-HDO/1/1oH9fjj4eLgegrlH3dklZpHtUYYFiVwMUwfGvk9jWDRWqkklA2/NFScknrcNSspbV868WjXORvreDX+Q==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/react@0.19.2':
resolution: {integrity: sha512-JyNk4A0Ezirq8FlXECvRtQOX/iBe5Ize0W/pLkrZjfHW9GUV7Xnq6zm6fyZuQzaHHqEnVizmvlA96e1/CkZv+w==}
peerDependencies:
@@ -2937,8 +2935,8 @@ packages:
'@types/react':
optional: true
'@mui/types@7.4.4':
resolution: {integrity: sha512-p63yhbX52MO/ajXC7hDHJA5yjzJekvWD3q4YDLl1rSg+OXLczMYPvTuSuviPRCgRX8+E42RXz1D/dz9SxPSlWg==}
'@mui/types@7.4.5':
resolution: {integrity: sha512-ZPwlAOE3e8C0piCKbaabwrqZbW4QvWz0uapVPWya7fYj6PeDkl5sSJmomT7wjOcZGPB48G/a6Ubidqreptxz4g==}
peerDependencies:
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
@@ -4697,26 +4695,18 @@ packages:
'@sinonjs/fake-timers@10.3.0':
resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==}
'@slack/logger@3.0.0':
resolution: {integrity: sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA==}
engines: {node: '>= 12.13.0', npm: '>= 6.12.0'}
'@slack/logger@4.0.0':
resolution: {integrity: sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA==}
engines: {node: '>= 18', npm: '>= 8.6.0'}
'@slack/oauth@2.6.3':
resolution: {integrity: sha512-1amXs6xRkJpoH6zSgjVPgGEJXCibKNff9WNDijcejIuVy1HFAl1adh7lehaGNiHhTWfQkfKxBiF+BGn56kvoFw==}
engines: {node: '>=12.13.0', npm: '>=6.12.0'}
'@slack/oauth@3.0.3':
resolution: {integrity: sha512-N3pLJPacZ57bqmD1HzHDmHe/CNsL9pESZXRw7pfv6QXJVRgufPIW84aRpAez2Xb0616RpGBYZW5dZH0Nbskwyg==}
engines: {node: '>=18', npm: '>=8.6.0'}
'@slack/types@2.15.0':
resolution: {integrity: sha512-livb1gyG3J8ATLBJ3KjZfjHpTRz9btY1m5cgNuXxWJbhwRB1Gwb8Ly6XLJm2Sy1W6h+vLgqIHg7IwKrF1C1Szg==}
engines: {node: '>= 12.13.0', npm: '>= 6.12.0'}
'@slack/web-api@6.13.0':
resolution: {integrity: sha512-dv65crIgdh9ZYHrevLU6XFHTQwTyDmNqEqzuIrV+Vqe/vgiG6w37oex5ePDU1RGm2IJ90H8iOvHFvzdEO/vB+g==}
engines: {node: '>= 12.13.0', npm: '>= 6.12.0'}
'@slack/web-api@7.9.3':
resolution: {integrity: sha512-xjnoldVJyoUe61Ltqjr2UVYBolcsTpp5ottqzSI3l41UCaJgHSHIOpGuYps+nhLFvDfGGpDGUeQ7BWiq3+ypqA==}
engines: {node: '>= 18', npm: '>= 8.6.0'}
@@ -5400,9 +5390,6 @@ packages:
'@types/http-errors@2.0.5':
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
'@types/is-stream@1.1.0':
resolution: {integrity: sha512-jkZatu4QVbR60mpIzjINmtS1ZF4a/FqdTUTBeQDVOQ2PYyidtwFKr0B5G6ERukKwliq+7mIXvxyppwzG5EgRYg==}
'@types/istanbul-lib-coverage@2.0.6':
resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==}
@@ -5424,8 +5411,8 @@ packages:
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/jsonwebtoken@8.5.9':
resolution: {integrity: sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg==}
'@types/jsonwebtoken@9.0.10':
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
'@types/katex@0.16.7':
resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==}
@@ -5439,14 +5426,14 @@ packages:
'@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
'@types/ms@0.7.34':
resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==}
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/mysql@2.15.26':
resolution: {integrity: sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==}
'@types/node-fetch@2.6.12':
resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==}
'@types/node-fetch@2.6.13':
resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
'@types/node@18.19.120':
resolution: {integrity: sha512-WtCGHFXnVI8WHLxDAt5TbnCM4eSE+nI0QN2NJtwzcgMhht2eNz6V9evJrk+lwC8bCY8OWV5Ym8Jz7ZEyGnKnMA==}
@@ -6352,8 +6339,8 @@ packages:
caniuse-lite@1.0.30001723:
resolution: {integrity: sha512-1R/elMjtehrFejxwmexeXAtae5UO9iSyFn6G/I806CYC/BLyyBk1EPhrKBkWhy6wM6Xnm47dSJQec+tLJ39WHw==}
caniuse-lite@1.0.30001727:
resolution: {integrity: sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==}
caniuse-lite@1.0.30001733:
resolution: {integrity: sha512-e4QKw/O2Kavj2VQTKZWrwzkt3IxOmIlU6ajRb6LP64LHpBo1J67k2Hi4Vu/TgJWsNtynurfS0uK3MaUTCPfu5Q==}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -7245,8 +7232,8 @@ packages:
electron-to-chromium@1.5.167:
resolution: {integrity: sha512-LxcRvnYO5ez2bMOFpbuuVuAI5QNeY1ncVytE/KXaL6ZNfzX1yPlAO0nSOyIHx2fVAuUprMqPs/TdVhUFZy7SIQ==}
electron-to-chromium@1.5.192:
resolution: {integrity: sha512-rP8Ez0w7UNw/9j5eSXCe10o1g/8B1P5SM90PCCMVkIRQn2R0LEHWz4Eh9RnxkniuDe1W0cTSOB3MLlkTGDcuCg==}
electron-to-chromium@1.5.199:
resolution: {integrity: sha512-3gl0S7zQd88kCAZRO/DnxtBKuhMO4h0EaQIN3YgZfV6+pW+5+bf2AdQeHNESCoaQqo/gjGVYEf2YM4O5HJQqpQ==}
electron-to-chromium@1.5.33:
resolution: {integrity: sha512-+cYTcFB1QqD4j4LegwLfpCNxifb6dDFUAwk6RsLusCwIaZI6or2f+q8rs5tTB2YC53HhOlIbEaqHMAAC8IOIwA==}
@@ -7281,8 +7268,8 @@ packages:
resolution: {integrity: sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==}
engines: {node: '>=10.13.0'}
enhanced-resolve@5.18.2:
resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==}
enhanced-resolve@5.18.3:
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
engines: {node: '>=10.13.0'}
entities@4.5.0:
@@ -7651,9 +7638,6 @@ packages:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
eventemitter3@3.1.2:
resolution: {integrity: sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==}
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
@@ -8515,10 +8499,6 @@ packages:
is-ssh@1.4.1:
resolution: {integrity: sha512-JNeu1wQsHjyHgn9NcWTaXq6zWSR6hqE0++zhfZlkFBbScNkyvxCdeV8sRkSBaeLKxmbpR21brail63ACNxJ0Tg==}
is-stream@1.1.0:
resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==}
engines: {node: '>=0.10.0'}
is-stream@2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
@@ -10479,7 +10459,7 @@ packages:
puppeteer@19.11.1:
resolution: {integrity: sha512-39olGaX2djYUdhaQQHDZ0T0GwEp+5f9UB9HmEP0qHfdQHIq0xGQZuAZ5TLnJIc/88SrPLpEflPC+xUqOTv3c5g==}
deprecated: < 22.8.2 is no longer supported
deprecated: < 24.9.0 is no longer supported
pure-rand@6.0.4:
resolution: {integrity: sha512-LA0Y9kxMYv47GIPJy6MI84fqTd2HmYZI83W/kM/SkKfDlajnZYfmXFTxkbY+xSBPkLJxltMa9hIkmdc29eguMA==}
@@ -12376,7 +12356,7 @@ snapshots:
'@anthropic-ai/sdk@0.39.0':
dependencies:
'@types/node': 18.19.120
'@types/node-fetch': 2.6.12
'@types/node-fetch': 2.6.13
abort-controller: 3.0.0
agentkeepalive: 4.6.0
form-data-encoder: 1.7.2
@@ -14333,6 +14313,10 @@ snapshots:
dependencies:
'@floating-ui/utils': 0.2.10
'@floating-ui/core@1.7.3':
dependencies:
'@floating-ui/utils': 0.2.10
'@floating-ui/dom@1.6.11':
dependencies:
'@floating-ui/core': 1.6.8
@@ -14348,6 +14332,11 @@ snapshots:
'@floating-ui/core': 1.7.2
'@floating-ui/utils': 0.2.10
'@floating-ui/dom@1.7.3':
dependencies:
'@floating-ui/core': 1.7.3
'@floating-ui/utils': 0.2.10
'@floating-ui/react-dom@1.3.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@floating-ui/dom': 1.6.3
@@ -14366,6 +14355,12 @@ snapshots:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
'@floating-ui/react-dom@2.1.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@floating-ui/dom': 1.7.3
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
'@floating-ui/react@0.19.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@floating-ui/react-dom': 1.3.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -14665,7 +14660,7 @@ snapshots:
'@jest/console@29.7.0':
dependencies:
'@jest/types': 29.6.3
'@types/node': 20.11.29
'@types/node': 20.14.8
chalk: 4.1.2
jest-message-util: 29.7.0
jest-util: 29.7.0
@@ -14750,7 +14745,7 @@ snapshots:
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@jridgewell/trace-mapping': 0.3.25
'@types/node': 20.11.29
'@types/node': 20.14.8
chalk: 4.1.2
collect-v8-coverage: 1.0.2
exit: 0.1.2
@@ -15046,8 +15041,8 @@ snapshots:
'@mui/base@5.0.0-beta.40(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@babel/runtime': 7.28.2
'@floating-ui/react-dom': 2.1.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@mui/types': 7.4.4(@types/react@18.2.79)
'@floating-ui/react-dom': 2.1.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@mui/types': 7.4.5(@types/react@18.2.79)
'@mui/utils': 5.17.1(@types/react@18.2.79)(react@18.2.0)
'@popperjs/core': 2.11.8
clsx: 2.1.1
@@ -15065,7 +15060,7 @@ snapshots:
'@mui/base': 5.0.0-beta.40(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@mui/core-downloads-tracker': 5.18.0
'@mui/system': 5.16.7(@emotion/react@11.11.4(@types/react@18.2.79)(react@18.2.0))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.2.79)(react@18.2.0))(@types/react@18.2.79)(react@18.2.0))(@types/react@18.2.79)(react@18.2.0)
'@mui/types': 7.4.4(@types/react@18.2.79)
'@mui/types': 7.4.5(@types/react@18.2.79)
'@mui/utils': 5.17.1(@types/react@18.2.79)(react@18.2.0)
'@types/react-transition-group': 4.4.12(@types/react@18.2.79)
clsx: 2.1.1
@@ -15106,7 +15101,7 @@ snapshots:
'@babel/runtime': 7.28.2
'@mui/private-theming': 5.17.1(@types/react@18.2.79)(react@18.2.0)
'@mui/styled-engine': 5.18.0(@emotion/react@11.11.4(@types/react@18.2.79)(react@18.2.0))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.2.79)(react@18.2.0))(@types/react@18.2.79)(react@18.2.0))(react@18.2.0)
'@mui/types': 7.4.4(@types/react@18.2.79)
'@mui/types': 7.4.5(@types/react@18.2.79)
'@mui/utils': 5.17.1(@types/react@18.2.79)(react@18.2.0)
clsx: 2.1.1
csstype: 3.1.3
@@ -15125,7 +15120,7 @@ snapshots:
optionalDependencies:
'@types/react': 18.2.79
'@mui/types@7.4.4(@types/react@18.2.79)':
'@mui/types@7.4.5(@types/react@18.2.79)':
dependencies:
'@babel/runtime': 7.28.2
optionalDependencies:
@@ -17155,19 +17150,15 @@ snapshots:
dependencies:
'@sinonjs/commons': 3.0.1
'@slack/logger@3.0.0':
dependencies:
'@types/node': 20.11.29
'@slack/logger@4.0.0':
dependencies:
'@types/node': 20.11.29
'@slack/oauth@2.6.3':
'@slack/oauth@3.0.3':
dependencies:
'@slack/logger': 3.0.0
'@slack/web-api': 6.13.0
'@types/jsonwebtoken': 8.5.9
'@slack/logger': 4.0.0
'@slack/web-api': 7.9.3
'@types/jsonwebtoken': 9.0.10
'@types/node': 20.11.29
jsonwebtoken: 9.0.2
lodash.isstring: 4.0.1
@@ -17176,22 +17167,6 @@ snapshots:
'@slack/types@2.15.0': {}
'@slack/web-api@6.13.0':
dependencies:
'@slack/logger': 3.0.0
'@slack/types': 2.15.0
'@types/is-stream': 1.1.0
'@types/node': 20.11.29
axios: 1.8.2
eventemitter3: 3.1.2
form-data: 2.5.3
is-electron: 2.2.2
is-stream: 1.1.0
p-queue: 6.6.2
p-retry: 4.6.2
transitivePeerDependencies:
- debug
'@slack/web-api@7.9.3':
dependencies:
'@slack/logger': 4.0.0
@@ -18075,7 +18050,7 @@ snapshots:
'@types/debug@4.1.8':
dependencies:
'@types/ms': 0.7.34
'@types/ms': 2.1.0
'@types/deep-eql@4.0.2': {}
@@ -18114,7 +18089,7 @@ snapshots:
'@types/express-serve-static-core@4.19.6':
dependencies:
'@types/node': 20.11.29
'@types/node': 20.14.8
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 0.17.5
@@ -18141,7 +18116,7 @@ snapshots:
'@types/graceful-fs@4.1.9':
dependencies:
'@types/node': 20.11.29
'@types/node': 20.14.8
'@types/hammerjs@2.0.46': {}
@@ -18151,10 +18126,6 @@ snapshots:
'@types/http-errors@2.0.5': {}
'@types/is-stream@1.1.0':
dependencies:
'@types/node': 20.11.29
'@types/istanbul-lib-coverage@2.0.6': {}
'@types/istanbul-lib-report@3.0.3':
@@ -18180,8 +18151,9 @@ snapshots:
'@types/json5@0.0.29': {}
'@types/jsonwebtoken@8.5.9':
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 2.1.0
'@types/node': 20.11.29
'@types/katex@0.16.7': {}
@@ -18194,16 +18166,16 @@ snapshots:
'@types/mime@1.3.5': {}
'@types/ms@0.7.34': {}
'@types/ms@2.1.0': {}
'@types/mysql@2.15.26':
dependencies:
'@types/node': 20.11.29
'@types/node-fetch@2.6.12':
'@types/node-fetch@2.6.13':
dependencies:
'@types/node': 20.11.29
form-data: 4.0.2
form-data: 4.0.4
'@types/node@18.19.120':
dependencies:
@@ -19285,8 +19257,8 @@ snapshots:
browserslist@4.25.1:
dependencies:
caniuse-lite: 1.0.30001727
electron-to-chromium: 1.5.192
caniuse-lite: 1.0.30001733
electron-to-chromium: 1.5.199
node-releases: 2.0.19
update-browserslist-db: 1.1.3(browserslist@4.25.1)
@@ -19385,7 +19357,7 @@ snapshots:
caniuse-lite@1.0.30001723: {}
caniuse-lite@1.0.30001727: {}
caniuse-lite@1.0.30001733: {}
ccount@2.0.1: {}
@@ -20329,7 +20301,7 @@ snapshots:
electron-to-chromium@1.5.167: {}
electron-to-chromium@1.5.192: {}
electron-to-chromium@1.5.199: {}
electron-to-chromium@1.5.33: {}
@@ -20359,7 +20331,7 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.2.1
enhanced-resolve@5.18.2:
enhanced-resolve@5.18.3:
dependencies:
graceful-fs: 4.2.11
tapable: 2.2.2
@@ -20976,8 +20948,6 @@ snapshots:
event-target-shim@5.0.1: {}
eventemitter3@3.1.2: {}
eventemitter3@4.0.7: {}
eventemitter3@5.0.1: {}
@@ -21942,8 +21912,6 @@ snapshots:
dependencies:
protocols: 2.0.2
is-stream@1.1.0: {}
is-stream@2.0.1: {}
is-stream@3.0.0: {}
@@ -22076,7 +22044,7 @@ snapshots:
'@jest/expect': 29.7.0
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
'@types/node': 20.11.29
'@types/node': 20.14.8
chalk: 4.1.2
co: 4.6.0
dedent: 1.5.1(babel-plugin-macros@3.1.0)
@@ -22236,7 +22204,7 @@ snapshots:
'@jest/environment': 29.7.0
'@jest/fake-timers': 29.7.0
'@jest/types': 29.6.3
'@types/node': 20.11.29
'@types/node': 20.14.8
jest-mock: 29.7.0
jest-util: 29.7.0
@@ -22246,7 +22214,7 @@ snapshots:
dependencies:
'@jest/types': 29.6.3
'@types/graceful-fs': 4.1.9
'@types/node': 20.11.29
'@types/node': 20.14.8
anymatch: 3.1.3
fb-watchman: 2.0.2
graceful-fs: 4.2.11
@@ -22320,7 +22288,7 @@ snapshots:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 20.11.29
'@types/node': 20.14.8
chalk: 4.1.2
emittery: 0.13.1
graceful-fs: 4.2.11
@@ -22348,7 +22316,7 @@ snapshots:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 20.11.29
'@types/node': 20.14.8
chalk: 4.1.2
cjs-module-lexer: 1.4.1
collect-v8-coverage: 1.0.2
@@ -22413,7 +22381,7 @@ snapshots:
dependencies:
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
'@types/node': 20.11.29
'@types/node': 20.14.8
ansi-escapes: 4.3.2
chalk: 4.1.2
emittery: 0.13.1
@@ -22428,7 +22396,7 @@ snapshots:
jest-worker@29.7.0:
dependencies:
'@types/node': 20.11.29
'@types/node': 20.14.8
jest-util: 29.7.0
merge-stream: 2.0.0
supports-color: 8.1.1
@@ -22674,40 +22642,13 @@ snapshots:
- openai
- ws
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/core': 0.3.58(openai@4.104.0(zod@3.25.62))
'@langchain/openai': 0.5.13(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))
'@langchain/textsplitters': 0.1.0(@langchain/core@0.3.58(openai@4.104.0(zod@3.25.62)))
js-tiktoken: 1.0.15
js-yaml: 4.1.0
jsonpointer: 5.0.1
langsmith: 0.3.30(openai@4.104.0(zod@3.25.62))
openapi-types: 12.1.3
p-retry: 4.6.2
uuid: 10.0.0
yaml: 2.5.1
zod: 3.25.62
optionalDependencies:
'@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/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
transitivePeerDependencies:
- encoding
- openai
- ws
langfuse-core@3.38.4:
dependencies:
mustache: 4.2.0
langfuse-langchain@3.38.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.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.11.0)(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))
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.11.0)(cheerio@1.0.0)(handlebars@4.7.8)(openai@4.104.0(zod@3.25.62))
langfuse: 3.38.4
langfuse-core: 3.38.4
@@ -23740,7 +23681,7 @@ snapshots:
openai@4.104.0(zod@3.25.32):
dependencies:
'@types/node': 18.19.120
'@types/node-fetch': 2.6.12
'@types/node-fetch': 2.6.13
abort-controller: 3.0.0
agentkeepalive: 4.6.0
form-data-encoder: 1.7.2
@@ -23754,7 +23695,7 @@ snapshots:
openai@4.104.0(zod@3.25.62):
dependencies:
'@types/node': 18.19.120
'@types/node-fetch': 2.6.12
'@types/node-fetch': 2.6.13
abort-controller: 3.0.0
agentkeepalive: 4.6.0
form-data-encoder: 1.7.2
@@ -26218,7 +26159,7 @@ snapshots:
acorn: 8.15.0
browserslist: 4.25.1
chrome-trace-event: 1.0.4
enhanced-resolve: 5.18.2
enhanced-resolve: 5.18.3
es-module-lexer: 1.7.0
eslint-scope: 5.1.1
events: 3.3.0
+2 -4
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.95.2",
"version": "3.97.3",
"private": true,
"license": "MIT",
"engines": {
@@ -9,7 +9,7 @@
"scripts": {
"build": "INLINE_RUNTIME_CHUNK=false dotenv -e ../.env -- next build",
"dev": "dotenv -e ../.env -- next dev",
"dev:https": "dotenv -e ../.env -- next dev --experimental-https --experimental-https-key ./localhost+1-key.pem --experimental-https-cert ./localhost+1.pem",
"dev:http": "dotenv -e ../.env -- next dev --experimental-https --experimental-https-key ./localhost+1-key.pem --experimental-https-cert ./localhost+1.pem",
"lint": "dotenv -e ../.env -- next lint --max-warnings 0",
"lint:fix": "dotenv -e ../.env -- next lint --fix",
"clean": "rm -rf node_modules",
@@ -84,8 +84,6 @@
"@radix-ui/react-tooltip": "^1.2.7",
"@remixicon/react": "^4.2.0",
"@sentry/nextjs": "^8.52.0",
"@slack/oauth": "^2.6.0",
"@slack/web-api": "^7.0.0",
"@t3-oss/env-nextjs": "^0.11.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/react-query": "^4.36.1",
+63 -1
View File
@@ -79,6 +79,52 @@ paths:
schema: {}
security:
- BasicAuth: []
post:
description: Create an annotation queue
operationId: annotationQueues_createQueue
tags:
- AnnotationQueues
parameters: []
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/AnnotationQueue'
'400':
description: ''
content:
application/json:
schema: {}
'401':
description: ''
content:
application/json:
schema: {}
'403':
description: ''
content:
application/json:
schema: {}
'404':
description: ''
content:
application/json:
schema: {}
'405':
description: ''
content:
application/json:
schema: {}
security:
- BasicAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateAnnotationQueueRequest'
/api/public/annotation-queues/{queueId}:
get:
description: Get an annotation queue by ID
@@ -3254,7 +3300,7 @@ paths:
parameters:
- name: filter
in: query
description: Filter expression (e.g. userName eq 'value')
description: Filter expression (e.g. userName eq "value")
required: false
schema:
type: string
@@ -4459,6 +4505,22 @@ components:
required:
- data
- meta
CreateAnnotationQueueRequest:
title: CreateAnnotationQueueRequest
type: object
properties:
name:
type: string
description:
type: string
nullable: true
scoreConfigIds:
type: array
items:
type: string
required:
- name
- scoreConfigIds
CreateAnnotationQueueItemRequest:
title: CreateAnnotationQueueItemRequest
type: object
@@ -78,6 +78,39 @@
},
"response": []
},
{
"_type": "endpoint",
"name": "Create Queue",
"request": {
"description": "Create an annotation queue",
"url": {
"raw": "{{baseUrl}}/api/public/annotation-queues",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"annotation-queues"
],
"query": [],
"variable": []
},
"header": [],
"method": "POST",
"auth": null,
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"example\",\n \"description\": \"example\",\n \"scoreConfigIds\": [\n \"example\"\n ]\n}",
"options": {
"raw": {
"language": "json"
}
}
}
},
"response": []
},
{
"_type": "endpoint",
"name": "Get Queue",
@@ -14,6 +14,7 @@ import {
CreateAnnotationQueueItemResponse,
UpdateAnnotationQueueItemResponse,
DeleteAnnotationQueueItemResponse,
CreateAnnotationQueueResponse,
} from "@/src/features/public-api/types/annotation-queues";
import {
AnnotationQueueObjectType,
@@ -173,6 +174,120 @@ describe("Annotation Queues API Endpoints", () => {
});
});
describe("POST /annotation-queues", () => {
it("should create a new annotation queue", async () => {
const scoreConfig = await prisma.scoreConfig.create({
data: {
name: "Test Score Config",
description: "Test Score Config Description",
projectId,
dataType: "NUMERIC",
},
});
const response = await makeZodVerifiedAPICall(
CreateAnnotationQueueResponse,
"POST",
"/api/public/annotation-queues",
{
name: "Test Queue",
description: "Test Queue Description",
scoreConfigIds: [scoreConfig.id],
},
auth,
);
expect(response.status).toBe(200);
expect(response.body.id).toBeDefined();
expect(response.body.name).toBe("Test Queue");
expect(response.body.description).toBe("Test Queue Description");
expect(response.body.scoreConfigIds).toEqual([scoreConfig.id]);
});
it("should return 400 if the queue name already exists", async () => {
const response = await makeAPICall(
"POST",
"/api/public/annotation-queues",
{
name: "Test Queue",
description: "Test Queue Description",
scoreConfigIds: [],
},
auth,
);
expect(response.status).toBe(400);
});
it("should return 400 if no score config IDs are provided", async () => {
const response = await makeAPICall(
"POST",
"/api/public/annotation-queues",
{
name: "No configs queue",
description: "Test Queue Description",
scoreConfigIds: [],
},
auth,
);
expect(response.status).toBe(400);
});
it("should return 400 if the score config IDs are invalid", async () => {
const response = await makeAPICall(
"POST",
"/api/public/annotation-queues",
{
name: "Invalid configs queue",
description: "Test Queue Description",
scoreConfigIds: ["invalid-score-config-id"],
},
auth,
);
expect(response.status).toBe(400);
});
it("should return 405 if the user is on the Hobby plan and has reached the maximum number of annotation queues", async () => {
const { auth: hobbyPlanAuth, projectId: hobbyProjectId } =
await createOrgProjectAndApiKey({
plan: "Hobby",
});
const config = await prisma.scoreConfig.create({
data: {
name: "Test Score Config",
description: "Test Score Config Description",
projectId: hobbyProjectId,
dataType: "NUMERIC",
},
});
await prisma.annotationQueue.create({
data: {
name: "First queue",
description: "First queue description",
scoreConfigIds: [config.id],
projectId: hobbyProjectId,
},
});
const response = await makeAPICall(
"POST",
"/api/public/annotation-queues",
{
name: "Hobby plan queue",
description: "Test Queue Description",
scoreConfigIds: [config.id],
},
hobbyPlanAuth,
);
expect(response.status).toBe(405);
});
});
describe("GET /annotation-queues/:queueId", () => {
it("should get a specific annotation queue", async () => {
const response = await makeZodVerifiedAPICall(
@@ -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");
});
});
});
@@ -30,6 +30,7 @@ import {
type PivotTableConfig,
type DatabaseRow,
type PivotTableRow,
getNextSortState,
} from "@/src/features/widgets/utils/pivot-table-utils";
describe("pivot-table-utils", () => {
@@ -1014,4 +1015,113 @@ describe("pivot-table-utils", () => {
});
});
});
describe("getNextSortState", () => {
const defaultSort = { column: "avg_cost", order: "DESC" as const };
describe("no current sort", () => {
test("returns DESC when no current sort", () => {
const next = getNextSortState(null, null, "count");
expect(next).toEqual({ column: "count", order: "DESC" });
});
test("returns DESC when no current sort with default sort defined", () => {
const next = getNextSortState(defaultSort, null, "count");
expect(next).toEqual({ column: "count", order: "DESC" });
});
});
describe("different column", () => {
test("returns DESC when different column", () => {
const next = getNextSortState(
null,
{ column: "metric", order: "ASC" },
"count",
);
expect(next).toEqual({ column: "count", order: "DESC" });
});
test("returns DESC when different column with default sort", () => {
const next = getNextSortState(
defaultSort,
{ column: "metric", order: "ASC" },
"count",
);
expect(next).toEqual({ column: "count", order: "DESC" });
});
});
describe("non-default column cycling", () => {
test("non-default column: DESC -> ASC", () => {
const next = getNextSortState(
defaultSort,
{ column: "count", order: "DESC" },
"count",
);
expect(next).toEqual({ column: "count", order: "ASC" });
});
test("non-default column: ASC -> default sort", () => {
const next = getNextSortState(
defaultSort,
{ column: "count", order: "ASC" },
"count",
);
expect(next).toEqual(defaultSort);
});
test("non-default column: ASC -> null when no default sort", () => {
const next = getNextSortState(
null,
{ column: "count", order: "ASC" },
"count",
);
expect(next).toBeNull();
});
});
describe("default column cycling", () => {
test("default column: DESC -> ASC", () => {
const next = getNextSortState(
defaultSort,
{ column: "avg_cost", order: "DESC" },
"avg_cost",
);
expect(next).toEqual({ column: "avg_cost", order: "ASC" });
});
test("default column: ASC -> DESC (infinite cycle)", () => {
const next = getNextSortState(
defaultSort,
{ column: "avg_cost", order: "ASC" },
"avg_cost",
);
expect(next).toEqual({ column: "avg_cost", order: "DESC" });
});
});
describe("edge cases", () => {
test("handles undefined default sort", () => {
const next = getNextSortState(
undefined as any,
{ column: "count", order: "ASC" },
"count",
);
expect(next).toBeNull();
});
test("handles null default sort", () => {
const next = getNextSortState(
null,
{ column: "count", order: "ASC" },
"count",
);
expect(next).toBeNull();
});
test("handles null current sort with defined default", () => {
const next = getNextSortState(defaultSort, null, "count");
expect(next).toEqual({ column: "count", order: "DESC" });
});
});
});
});
+13 -2
View File
@@ -7,6 +7,7 @@ import {
IngestionQueue,
logger,
QueueName,
TraceUpsertQueue,
} from "@langfuse/shared/src/server";
import { type z } from "zod/v4";
@@ -101,7 +102,10 @@ export const pruneDatabase = async () => {
};
export const getQueues = () => {
const queues: string[] = Object.values(QueueName);
queues.push(...IngestionQueue.getShardNames());
queues.push(
...IngestionQueue.getShardNames(),
...TraceUpsertQueue.getShardNames(),
);
const listOfQueuesToIgnore = [
QueueName.DataRetentionQueue,
@@ -117,7 +121,14 @@ export const getQueues = () => {
.map((queueName) =>
queueName.startsWith(QueueName.IngestionQueue)
? IngestionQueue.getInstance({ shardName: queueName })
: getQueue(queueName as Exclude<QueueName, QueueName.IngestionQueue>),
: queueName.startsWith(QueueName.TraceUpsert)
? TraceUpsertQueue.getInstance({ shardName: queueName })
: getQueue(
queueName as Exclude<
QueueName,
QueueName.IngestionQueue | QueueName.TraceUpsert
>,
),
);
};
+22 -4
View File
@@ -61,6 +61,9 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
const [modelSettingsOpen, setModelSettingsOpen] = useState(false);
const [modelSettingsUsed, setModelSettingsUsed] = useState(false);
const [createLlmApiKeyDialogOpen, setCreateLlmApiKeyDialogOpen] =
useState(false);
useEffect(() => {
const hasEnabledModelSetting = Object.keys(modelParams).some(
(key) =>
@@ -88,7 +91,10 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
</div>
)}
<p className="text-xs">No LLM API key set in project. </p>
<CreateLLMApiKeyDialog />
<CreateLLMApiKeyDialog
open={createLlmApiKeyDialogOpen}
setOpen={setCreateLlmApiKeyDialogOpen}
/>
</div>
);
}
@@ -204,7 +210,10 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
</SelectItem>
))}
<SelectSeparator />
<CreateLLMApiKeyDialog />
<CreateLLMApiKeyDialog
open={createLlmApiKeyDialogOpen}
setOpen={setCreateLlmApiKeyDialogOpen}
/>
</SelectContent>
</Select>
{modelParamsDescription ? (
@@ -304,6 +313,9 @@ const ModelParamsSelect = ({
modelParamsDescription,
layout = "vertical",
}: ModelParamsSelectProps) => {
const [createLlmApiKeyDialogOpen, setCreateLlmApiKeyDialogOpen] =
useState(false);
// Compact layout - simplified, space-efficient (no individual labels)
if (layout === "compact") {
return (
@@ -328,7 +340,10 @@ const ModelParamsSelect = ({
</SelectItem>
))}
<SelectSeparator />
<CreateLLMApiKeyDialog />
<CreateLLMApiKeyDialog
open={createLlmApiKeyDialogOpen}
setOpen={setCreateLlmApiKeyDialogOpen}
/>
</SelectContent>
</Select>
{modelParamsDescription ? (
@@ -374,7 +389,10 @@ const ModelParamsSelect = ({
</SelectItem>
))}
<SelectSeparator />
<CreateLLMApiKeyDialog />
<CreateLLMApiKeyDialog
open={createLlmApiKeyDialogOpen}
setOpen={setCreateLlmApiKeyDialogOpen}
/>
</SelectContent>
</Select>
{modelParamsDescription ? (
@@ -50,7 +50,7 @@ export const PeekViewEvaluatorConfigDetail = ({
<span className="max-h-fit text-lg font-medium">Configuration</span>
<div className="flex items-center gap-2">
<StatusBadge
type={evalConfig.status.toLowerCase()}
type={evalConfig.finalStatus.toLowerCase()}
isLive
className="max-h-8"
/>
+1 -2
View File
@@ -3,7 +3,6 @@
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { cn } from "@/src/utils/tailwind";
import { Check } from "lucide-react";
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
@@ -33,7 +32,7 @@ const RadioGroupItem = React.forwardRef<
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Check className="h-3.5 w-3.5 fill-primary" />
<div className="h-2 w-2 rounded-full bg-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.95.2";
export const VERSION = "v3.97.3";
@@ -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 ||
@@ -66,7 +66,7 @@ export function RequestResetPasswordEmailButton({
const callback = encodeURIComponent(
`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/auth/reset-password`,
);
const url = `/api/auth/callback/email?email=${formattedEmail}&token=${formattedCode}&callbackUrl=${callback}`;
const url = `${env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/auth/callback/email?email=${formattedEmail}&token=${formattedCode}&callbackUrl=${callback}`;
window.location.href = url;
} catch (error) {
console.error("Error verifying code:", error);
@@ -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)!])]),
);
@@ -33,9 +33,6 @@ export const EvalTemplateDetail = () => {
const templateId = router.query.id as string;
const [isEditing, setIsEditing] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<EvalTemplate | null>(
null,
);
// get the current template by id
const template = api.evals.templateById.useQuery({
@@ -58,15 +55,7 @@ export const EvalTemplateDetail = () => {
},
);
// Set the selected template when data is loaded
React.useEffect(() => {
if (template.data && !selectedTemplate) {
setSelectedTemplate(template.data);
}
}, [template.data, selectedTemplate]);
const handleTemplateSelect = (newTemplate: EvalTemplate) => {
setSelectedTemplate(newTemplate);
// Update URL without full page reload
router.push(
`/project/${projectId}/evals/templates/${newTemplate.id}`,
@@ -75,13 +64,10 @@ export const EvalTemplateDetail = () => {
);
};
// Get the appropriate template to display
const displayTemplate = selectedTemplate || template.data;
return (
<Page
headerProps={{
title: `${displayTemplate?.name || ""}`,
title: `${template.data?.name ?? ""}`,
itemType: "EVALUATOR",
breadcrumb: [
{
@@ -95,7 +81,7 @@ export const EvalTemplateDetail = () => {
projectId={projectId}
isEditing={isEditing}
setIsEditing={setIsEditing}
isCustom={!!displayTemplate?.projectId}
isCustom={!!template.data?.projectId}
/>
{/* TODO: moved to LFE-4573 */}
@@ -114,14 +100,14 @@ export const EvalTemplateDetail = () => {
),
}}
>
{allTemplates.isLoading || !allTemplates.data || !displayTemplate ? (
{allTemplates.isLoading || !allTemplates.data || !template.data ? (
<div className="p-3">Loading...</div>
) : isEditing ? (
<div className="overflow-y-auto p-3 pt-1">
<EvalTemplateForm
useDialog={false}
projectId={projectId}
existingEvalTemplate={displayTemplate}
existingEvalTemplate={template.data}
isEditing={isEditing}
setIsEditing={setIsEditing}
/>
@@ -132,7 +118,7 @@ export const EvalTemplateDetail = () => {
<EvalTemplateForm
useDialog={false}
projectId={projectId}
existingEvalTemplate={displayTemplate}
existingEvalTemplate={template.data}
isEditing={isEditing}
setIsEditing={setIsEditing}
/>
@@ -150,7 +136,7 @@ export const EvalTemplateDetail = () => {
<div
key={template.id}
className={`flex cursor-pointer flex-col rounded-md px-2 py-1.5 hover:bg-accent ${
template.id === displayTemplate.id ? "bg-accent" : ""
template.id === templateId ? "bg-accent" : ""
}`}
onClick={() => handleTemplateSelect(template)}
>
@@ -58,6 +58,12 @@ export function EvaluatorSelector({
},
);
// Ensure per-name arrays are sorted by createdAt ascending so last is latest
const sortByCreatedAt = (arr: EvalTemplate[]) =>
arr.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
Object.values(groupedTemplates.custom).forEach(sortByCreatedAt);
Object.values(groupedTemplates.langfuse).forEach(sortByCreatedAt);
// Filter templates based on search
const filteredTemplates = {
langfuse: Object.entries(groupedTemplates.langfuse)
@@ -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}`,
});
}
@@ -289,6 +289,7 @@ export const llmApiKeyRouter = createTRPCRouter({
customModels: true,
withDefaultModels: true,
extraHeaderKeys: true,
config: true,
},
where: {
projectId: input.projectId,
@@ -0,0 +1,193 @@
import { useCallback, useEffect } from "react";
import type { Path } from "react-hook-form";
import { useRouter } from "next/router";
import { Button } from "@/src/components/ui/button";
import { Form } from "@/src/components/ui/form";
import { LangfuseIcon } from "@/src/components/LangfuseLogo";
import { useSurveyForm } from "../hooks/useSurveyForm";
import { SurveyProgress } from "./SurveyProgress";
import { SurveyStep } from "./SurveyStep";
import type { SurveyFormData } from "../lib/surveyTypes";
export function OnboardingSurvey() {
const router = useRouter();
const {
form,
state,
currentQuestion,
isLastStep,
isFirstStep,
goNext,
goBack,
handleAutoAdvance,
handleSubmit,
totalSteps,
} = useSurveyForm();
const onSubmit = useCallback(
async (data: SurveyFormData) => {
await handleSubmit(data);
void router.push("/");
},
[handleSubmit, router],
);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux) on referralSource step submits the form
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
if (currentQuestion?.id === "referralSource") {
event.preventDefault();
form.handleSubmit(onSubmit)();
return;
}
}
// Regular Enter advances to next step (existing behavior)
if (event.key === "Enter" && !isLastStep) {
if (currentQuestion?.type !== "text") {
goNext();
}
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [
isLastStep,
currentQuestion?.type,
currentQuestion?.id,
goNext,
form,
onSubmit,
]);
// Auto-focus the first form control of the current step
useEffect(() => {
if (!currentQuestion?.id) return;
const field = currentQuestion.id as Path<SurveyFormData>;
const raf = requestAnimationFrame(() => {
try {
form.setFocus(field, { shouldSelect: true });
} catch {
// ignore if the control cannot be focused
}
});
return () => cancelAnimationFrame(raf);
}, [currentQuestion?.id, form]);
// Determine labeling/behavior of the primary (right) button
const roleValue = form.watch("role");
const signupReasonValue = form.watch("signupReason");
const referralSourceValue = form.watch("referralSource");
const currentFieldId = currentQuestion?.id as
| keyof SurveyFormData
| undefined;
const currentValue = currentFieldId
? form.watch(currentFieldId as Path<SurveyFormData>)
: undefined;
const isEmpty = (v: unknown) =>
v == null || (typeof v === "string" && v.trim() === "");
const allFields = {
role: roleValue,
signupReason: signupReasonValue,
referralSource: referralSourceValue,
} as const;
const currentEmpty = isEmpty(currentValue);
const otherTwoEmpty = Object.entries(allFields)
.filter(([key]) => key !== currentFieldId)
.every(([, v]) => isEmpty(v));
// showSkip: ghost button labeled "Skip" when skipping is the intended action
const showSkip = isLastStep ? currentEmpty && otherTwoEmpty : currentEmpty;
const handleSkipButton = () => {
if (isLastStep) {
void router.push("/");
} else {
goNext();
}
};
const handleSubmitButton = () => {
if (isLastStep) {
form.handleSubmit(onSubmit)();
} else {
goNext();
}
};
return (
<div className="flex flex-1 flex-col py-6 sm:min-h-full sm:justify-start sm:px-6 sm:py-12 lg:px-8">
<div className="flex items-center justify-center gap-2 sm:mx-auto sm:w-full sm:max-w-md">
<LangfuseIcon className="h-8 w-8" />
</div>
<div className="mt-6 rounded-lg bg-background px-6 py-6 shadow sm:mx-auto sm:mt-16 sm:w-full sm:max-w-[480px] sm:px-12 sm:py-10">
<Form {...form}>
<form
className="flex h-full flex-col"
onSubmit={form.handleSubmit(onSubmit)}
>
<div className="flex-1">
{currentQuestion && (
<SurveyStep
question={currentQuestion}
control={form.control}
onAutoAdvance={handleAutoAdvance}
isLast={isLastStep}
/>
)}
</div>
<div className="flex flex-row-reverse items-center justify-between pt-6">
{showSkip ? (
<Button
type="button"
onClick={handleSkipButton}
variant="ghost"
className="w-20"
>
Skip
</Button>
) : (
<Button
type="button"
onClick={handleSubmitButton}
variant="default"
className="w-20"
>
{isLastStep ? "Finish" : "Next"}
</Button>
)}
<div className="basis-[10rem] px-4">
<SurveyProgress
currentStep={state.currentStep}
totalSteps={totalSteps}
/>
</div>
{!isFirstStep ? (
<Button
type="button"
variant="ghost"
onClick={goBack}
className="w-20"
>
Back
</Button>
) : (
<div className="w-20" />
)}
</div>
</form>
</Form>
</div>
</div>
);
}
@@ -0,0 +1,27 @@
import { cn } from "@/src/utils/tailwind";
interface SurveyProgressProps {
currentStep: number;
totalSteps: number;
className?: string;
}
export function SurveyProgress({
currentStep,
totalSteps,
className,
}: SurveyProgressProps) {
return (
<div className={cn("flex w-full gap-3", className)}>
{Array.from({ length: totalSteps }).map((_, index) => (
<div
key={index}
className={cn(
"h-1 flex-1 rounded-full transition-colors duration-300",
index <= currentStep ? "bg-primary" : "bg-muted",
)}
/>
))}
</div>
);
}
@@ -0,0 +1,119 @@
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/src/components/ui/form";
import { RadioGroup, RadioGroupItem } from "@/src/components/ui/radio-group";
import { Textarea } from "@/src/components/ui/textarea";
import { Input } from "@/src/components/ui/input";
import { Label } from "@/src/components/ui/label";
import type { Control, Path } from "react-hook-form";
import type { SurveyQuestion, SurveyFormData } from "../lib/surveyTypes";
const AUTO_ADVANCE_DELAY = 300;
interface SurveyStepProps {
question: SurveyQuestion;
control: Control<SurveyFormData>;
onAutoAdvance?: (selectedValue?: string) => void;
isLast?: boolean;
}
export function SurveyStep({
question,
control,
onAutoAdvance,
isLast = false,
}: SurveyStepProps) {
const fieldName = question.id as keyof SurveyFormData;
const handleAutoAdvanceWithTimeout = (selectedValue?: string) => {
if (onAutoAdvance) {
// For signupReason question, ignore isLast and let the hook decide
// For other questions, respect the isLast prop
const shouldAutoAdvance = question.id === "signupReason" || !isLast;
if (shouldAutoAdvance) {
setTimeout(() => {
onAutoAdvance(selectedValue);
}, AUTO_ADVANCE_DELAY);
}
}
};
if (question.type === "radio") {
return (
<FormField
key={fieldName}
control={control}
name={fieldName as Path<SurveyFormData>}
render={({ field }) => (
<FormItem className="flex flex-col gap-2">
<FormLabel className="text-xl font-semibold">
{question.question}
</FormLabel>
<FormControl>
<RadioGroup
name={field.name}
ref={field.ref}
onValueChange={(value) => {
field.onChange(value);
setTimeout(() => {
handleAutoAdvanceWithTimeout(value);
}, 0);
}}
value={field.value as string}
className="grid gap-3"
>
{question.options.map((option) => (
<Label
key={option}
htmlFor={option}
className="flex flex-1 cursor-pointer items-center gap-3 rounded-lg border border-border p-3 text-sm font-medium leading-none transition-colors hover:bg-muted/50 peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<RadioGroupItem value={option} id={option} />
<span className="flex-1">{option}</span>
</Label>
))}
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
if (question.type === "text") {
return (
<FormField
key={fieldName}
control={control}
name={fieldName as Path<SurveyFormData>}
render={({ field }) => (
<FormItem className="flex flex-col gap-2">
<FormLabel className="text-xl font-semibold">
{question.question}
</FormLabel>
<FormControl>
{question.id === "referralSource" ? (
<Input placeholder={question.placeholder} {...field} />
) : (
<Textarea
placeholder={question.placeholder}
className="min-h-[170px] resize-none"
{...field}
/>
)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
return null;
}
@@ -0,0 +1,115 @@
import { useForm } from "react-hook-form";
import { useReducer, useCallback } from "react";
import type { SurveyFormData } from "../lib/surveyTypes";
import { surveyReducer, initialSurveyState } from "../lib/surveyReducer";
import { SURVEY_QUESTIONS, TOTAL_STEPS } from "../lib/questions";
import { api } from "@/src/utils/api";
import { SurveyName } from "@prisma/client";
import { useSession } from "next-auth/react";
import { showErrorToast } from "@/src/features/notifications/showErrorToast";
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
export function useSurveyForm() {
const [state, dispatch] = useReducer(surveyReducer, initialSurveyState);
const { data: session } = useSession();
const createSurveyMutation = api.surveys.create.useMutation({
onSuccess: () => {
showSuccessToast({
title: "Survey submitted",
description: "Thank you for your feedback!",
});
},
onError: (error) => {
showErrorToast(
"Failed to submit survey",
error.message || "Please try again later.",
);
},
});
const form = useForm<SurveyFormData>({
defaultValues: {
role: undefined,
signupReason: undefined,
referralSource: undefined,
},
});
// Check if we should skip the referral source question
const signupReason = form.watch("signupReason");
const shouldSkipReferralQuestion = signupReason === "Invited by team";
// Get effective total steps (skip last question if invited by team)
const effectiveTotalSteps = shouldSkipReferralQuestion
? TOTAL_STEPS - 1
: TOTAL_STEPS;
// Get current question, but skip the referral question if "Invited by team" is selected
const currentQuestion = SURVEY_QUESTIONS[state.currentStep];
const isLastStep = state.currentStep === effectiveTotalSteps - 1;
const isFirstStep = state.currentStep === 0;
const goNext = useCallback(() => {
dispatch({ type: "next" });
}, []);
const goBack = useCallback(() => {
dispatch({ type: "back" });
}, []);
const goToStep = useCallback((step: number) => {
dispatch({ type: "goToStep", step });
}, []);
const handleAutoAdvance = useCallback(
(selectedValue?: string) => {
// Special case: if we're on step 1 (signup reason) and "Invited by team" was selected,
// don't auto-advance, let the user click "Finish"
if (state.currentStep === 1 && selectedValue === "Invited by team") {
return;
}
// For all other cases, advance to next step
goNext();
},
[state.currentStep, goNext],
);
const handleSubmit = useCallback(
async (data: SurveyFormData) => {
const transformedResponse: Record<string, string> = {};
if (data.role) transformedResponse["role"] = data.role;
if (data.signupReason)
transformedResponse["signupReason"] = data.signupReason;
if (data.referralSource)
transformedResponse["referralSource"] = data.referralSource.trim();
try {
await createSurveyMutation.mutateAsync({
surveyName: SurveyName.USER_ONBOARDING,
response: transformedResponse,
orgId: session?.user?.organizations?.[0]?.id,
});
} catch (error) {
// Error handling is done in the mutation callbacks
// This catch block is for any additional error handling if needed
}
},
[createSurveyMutation, session],
);
return {
form,
state,
currentQuestion,
isLastStep,
isFirstStep,
goNext,
goBack,
goToStep,
handleAutoAdvance,
handleSubmit,
totalSteps: effectiveTotalSteps,
};
}
@@ -0,0 +1,38 @@
import type { SurveyQuestion } from "./surveyTypes";
export const SURVEY_QUESTIONS: SurveyQuestion[] = [
{
id: "role",
type: "radio",
question: "What describes you best?",
options: [
"Software Engineer",
"ML Engineer / Data Scientist",
"Product Manager",
"Domain Expert",
"Executive or Manager",
"Other",
],
},
{
id: "signupReason",
type: "radio",
question: "Why are you signing up?",
options: [
"Invited by team",
"Just looking around",
"Evaluating / Testing Langfuse",
"Start using Langfuse",
"Migrating from other solution",
"Migrating from self-hosted",
],
},
{
id: "referralSource",
type: "text",
question: "Where did you hear about us?",
placeholder: "GitHub, X, Reddit, colleague etc.",
},
];
export const TOTAL_STEPS = SURVEY_QUESTIONS.length;
@@ -0,0 +1,31 @@
import type { SurveyState, SurveyAction } from "./surveyTypes";
import { TOTAL_STEPS } from "./questions";
export const initialSurveyState: SurveyState = {
currentStep: 0,
};
export function surveyReducer(
state: SurveyState,
action: SurveyAction,
): SurveyState {
switch (action.type) {
case "next":
return {
...state,
currentStep: Math.min(state.currentStep + 1, TOTAL_STEPS - 1),
};
case "back":
return {
...state,
currentStep: Math.max(state.currentStep - 1, 0),
};
case "goToStep":
return {
...state,
currentStep: Math.max(0, Math.min(action.step, TOTAL_STEPS - 1)),
};
default:
return state;
}
}
@@ -0,0 +1,44 @@
export type QuestionType = "radio" | "text";
export interface BaseQuestion {
id: string;
type: QuestionType;
question: string;
required?: boolean;
}
export interface RadioQuestion extends BaseQuestion {
type: "radio";
options: string[];
}
export interface TextQuestion extends BaseQuestion {
type: "text";
placeholder?: string;
}
export type SurveyQuestion = RadioQuestion | TextQuestion;
export interface SurveyState {
currentStep: number;
}
export type SurveyAction =
| { type: "next" }
| { type: "back" }
| { type: "goToStep"; step: number };
export interface SurveyFormData {
role?: string;
signupReason?: string;
referralSource?: string;
}
export interface SurveyStepProps {
question: SurveyQuestion;
value: unknown;
onChange: (value: unknown) => void;
onNext?: () => void;
showNext?: boolean;
isLast?: boolean;
}
@@ -1,4 +1,5 @@
import { Button } from "@/src/components/ui/button";
import { useEffect } from "react";
import type * as z from "zod/v4";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
@@ -9,12 +10,22 @@ import {
FormItem,
FormLabel,
FormMessage,
FormDescription,
} from "@/src/components/ui/form";
import { Input } from "@/src/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} 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";
export const NewOrganizationForm = ({
onSuccess,
@@ -24,23 +35,49 @@ export const NewOrganizationForm = ({
const { update: updateSession } = useSession();
const form = useForm({
resolver: zodResolver(organizationNameSchema),
resolver: zodResolver(organizationFormSchema),
defaultValues: {
name: "",
type: "Personal",
size: undefined,
},
});
const capture = usePostHogClientCapture();
const createOrgMutation = api.organizations.create.useMutation({
onError: (error) => form.setError("name", { message: error.message }),
});
const createSurveyMutation = api.surveys.create.useMutation();
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({
name: values.name,
})
.then((org) => {
.then(async (org) => {
// Submit survey with organization data only on Cloud and if type is provided
if (isCloud && values.type) {
const surveyResponse: Record<string, string> = {
type: values.type,
};
if (values.size) {
surveyResponse.size = values.size;
}
try {
await createSurveyMutation.mutateAsync({
surveyName: SurveyName.ORG_ONBOARDING,
response: surveyResponse,
orgId: org.id,
});
} catch (error) {
console.error("Failed to submit survey:", error);
// Continue with organization creation even if survey fails
}
}
void updateSession();
onSuccess(org.id);
form.reset();
@@ -50,6 +87,13 @@ export const NewOrganizationForm = ({
});
}
// Clear size whenever type is not Company or Agency to avoid submitting hidden values
useEffect(() => {
if (watchedType !== "Company" && watchedType !== "Agency") {
form.setValue("size", undefined);
}
}, [watchedType, form]);
return (
<Form {...form}>
<form
@@ -75,6 +119,69 @@ export const NewOrganizationForm = ({
</FormItem>
)}
/>
{isCloud && (
<>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Type</FormLabel>
<FormDescription>
What would best describe your organization?
</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger ref={field.ref}>
<SelectValue placeholder="Please choose" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Personal">Personal</SelectItem>
<SelectItem value="Educational">Educational</SelectItem>
<SelectItem value="Company">Company</SelectItem>
<SelectItem value="Startup">Startup</SelectItem>
<SelectItem value="Agency">Agency</SelectItem>
<SelectItem value="N/A">N/A</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{(watchedType === "Company" || watchedType === "Agency") && (
<FormField
control={form.control}
name="size"
render={({ field }) => (
<FormItem>
<FormLabel>{watchedType} size</FormLabel>
<FormDescription>
How many people are in your {watchedType}?
</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger ref={field.ref}>
<SelectValue placeholder="Please choose" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="1-10">1-10</SelectItem>
<SelectItem value="10-49">10-49</SelectItem>
<SelectItem value="50-99">50-99</SelectItem>
<SelectItem value="100-299">100-299</SelectItem>
<SelectItem value="More than 300">
More than 300
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
<Button type="submit" loading={createOrgMutation.isLoading}>
Create
</Button>
@@ -1,9 +1,46 @@
import { StringNoHTML } from "@langfuse/shared";
import * as z from "zod/v4";
const organizationTypeOptions = [
"Personal",
"Educational",
"Company",
"Startup",
"Agency",
"N/A",
] as const;
const organizationSizeOptions = [
"1-10",
"10-49",
"50-99",
"100-299",
"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",
),
});
// 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",
});
}
});
@@ -1,6 +1,4 @@
import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/src/components/ui/button";
import {
Dialog,
@@ -14,9 +12,14 @@ import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
import { useUiCustomization } from "@/src/ee/features/ui-customization/useUiCustomization";
import { CreateLLMApiKeyForm } from "@/src/features/public-api/components/CreateLLMApiKeyForm";
export function CreateLLMApiKeyDialog() {
export function CreateLLMApiKeyDialog({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) {
const projectId = useProjectIdFromURL();
const [open, setOpen] = useState(false);
const hasAccess = useHasProjectAccess({
projectId,
scope: "llmApiKeys:create",
@@ -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>
@@ -29,6 +29,7 @@ import { UpdateLLMApiKeyDialog } from "./UpdateLLMApiKeyDialog";
export function LlmApiKeyList(props: { projectId: string }) {
const [editingKeyId, setEditingKeyId] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const hasAccess = useHasProjectAccess({
projectId: props.projectId,
@@ -148,7 +149,7 @@ export function LlmApiKeyList(props: { projectId: string }) {
</TableBody>
</Table>
</Card>
<CreateLLMApiKeyDialog />
<CreateLLMApiKeyDialog open={open} setOpen={setOpen} />
</div>
);
}
@@ -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 = [
+40 -9
View File
@@ -84,10 +84,9 @@ export const generateTracesForPublicApi = async ({
SELECT
trace_id,
project_id,
sum(total_cost) as total_cost,
date_diff('millisecond', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds,
groupArray(id) as observation_ids
FROM observations FINAL
${includeMetrics ? "sum(total_cost) as total_cost, date_diff('millisecond', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds, " : ""}
groupUniqArray(id) as observation_ids
FROM observations ${includeMetrics ? "FINAL" : ""}
WHERE project_id = {projectId: String}
${timeFilter ? `AND start_time >= {cteTimeFilter: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
${environmentFilter.length() > 0 ? `AND ${appliedEnvironmentFilter.query}` : ""}
@@ -294,16 +293,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 = [
@@ -57,6 +57,17 @@ export const GetAnnotationQueuesResponse = z
})
.strict();
// POST /annotation-queues
export const CreateAnnotationQueueBody = z
.object({
name: z.string(),
description: z.string().nullable(),
scoreConfigIds: z.array(z.string()).min(1),
})
.strict();
export const CreateAnnotationQueueResponse = AnnotationQueueSchema;
// GET /annotation-queues/:queueId
export const GetAnnotationQueueByIdQuery = z
.object({
+1 -1
View File
@@ -458,7 +458,7 @@ const scoreBaseDimensions: DimensionsDeclarationType = {
export const scoresNumericView: ViewDeclarationType = {
name: "scores_numeric",
description:
"Scores are flexible objects that are used for evaluations. This view contains numeric scores.",
"Scores are flexible objects that are used for evaluations. This view contains numeric and boolean scores.",
dimensions: {
...scoreBaseDimensions,
id: {
@@ -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>
@@ -12,12 +12,16 @@ import { Button } from "@/src/components/ui/button";
import { AlertCircle } from "lucide-react";
import { BigNumber } from "@/src/features/widgets/chart-library/BigNumber";
import { PivotTable } from "@/src/features/widgets/chart-library/PivotTable";
import { type OrderByState } from "@langfuse/shared";
export const Chart = ({
chartType,
data,
rowLimit,
chartConfig,
sortState,
onSortChange,
isLoading = false,
}: {
chartType: DashboardWidgetChartType;
data: DataPoint[];
@@ -28,7 +32,11 @@ export const Chart = ({
bins?: number;
dimensions?: string[];
metrics?: string[];
defaultSort?: OrderByState;
};
sortState?: OrderByState | null;
onSortChange?: (sortState: OrderByState | null) => void;
isLoading?: boolean;
}) => {
const [forceRender, setForceRender] = useState(false);
const shouldWarn = data.length > 2000 && !forceRender;
@@ -73,8 +81,17 @@ export const Chart = ({
dimensions: chartConfig?.dimensions ?? [],
metrics: chartConfig?.metrics ?? ["metric"], // Use metrics from chartConfig
rowLimit: chartConfig?.row_limit ?? rowLimit,
defaultSort: chartConfig?.defaultSort,
};
return <PivotTable data={renderedData} config={pivotConfig} />;
return (
<PivotTable
data={renderedData}
config={pivotConfig}
sortState={sortState}
onSortChange={onSortChange}
isLoading={isLoading}
/>
);
}
default:
return <HorizontalBarChart data={renderedData.slice(0, rowLimit)} />;
@@ -12,13 +12,14 @@
* - Responsive design within dashboard grid
* - Consistent styling with Langfuse design system
* - Row limiting to prevent performance issues
* - Interactive sorting with hierarchical behavior
*
* Usage:
* Used as part of the dashboard widget system to display tabular data
* visualizations with grouping and aggregation capabilities.
*/
import React, { useMemo } from "react";
import React, { useMemo, useCallback, useState, useEffect } from "react";
import { cn } from "@/src/utils/tailwind";
import {
Table,
@@ -32,6 +33,8 @@ import {
transformToPivotTable,
extractDimensionValues,
extractMetricValues,
sortPivotTableRows,
getNextSortState,
type PivotTableRow,
type PivotTableConfig,
type DatabaseRow,
@@ -40,6 +43,8 @@ import {
import { type ChartProps } from "@/src/features/widgets/chart-library/chart-props";
import { numberFormatter } from "@/src/utils/numbers";
import { formatMetricName } from "@/src/features/widgets/utils";
import { type OrderByState } from "@langfuse/shared";
import { Loader2 } from "lucide-react";
/**
* Props interface for the PivotTable component
@@ -57,8 +62,89 @@ export interface PivotTableProps {
/** Accessibility layer flag */
accessibilityLayer?: boolean;
/** Current sort state */
sortState?: OrderByState;
/** Callback for sort state changes */
onSortChange?: (sortState: OrderByState | null) => void;
/** Loading state for when data is being refreshed */
isLoading?: boolean;
}
/**
* Non-sortable column header component
* Simple header without sorting functionality
*/
const StaticHeader: React.FC<{
label: string;
className?: string;
}> = ({ label, className }) => {
return (
<TableHead className={cn("p-1", className)}>
<div className="flex select-none items-center">
<span className="truncate">{label}</span>
</div>
</TableHead>
);
};
/**
* Sortable column header component
* Handles click events and visual indicators for sorting
*/
const SortableHeader: React.FC<{
column: string;
label: string;
sortState?: OrderByState;
onSort: (column: string) => void;
className?: string;
rightAlign?: boolean;
}> = ({ column, label, sortState, onSort, className, rightAlign = false }) => {
const isSorted = sortState?.column === column;
const sortDirection = isSorted ? sortState.order : null;
const handleClick = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
onSort(column);
},
[column, onSort],
);
return (
<TableHead
className={cn("group/header cursor-pointer select-none p-1", className)}
onClick={handleClick}
>
<div
className={cn(
"flex select-none items-center",
rightAlign ? "justify-end" : "justify-start",
)}
>
<span className="truncate">{label}</span>
{isSorted && (
<span
className="ml-1"
title={
sortDirection === "ASC"
? "Sorted ascending"
: "Sort by this column"
}
>
{sortDirection === "ASC" ? "▲" : "▼"}
</span>
)}
{/* Visual indicator that appears on hover - matches traces table behavior */}
<div className="pointer-events-none absolute right-0 top-0 h-full w-1.5 touch-none select-none bg-secondary opacity-0 group-hover/header:opacity-100" />
</div>
</TableHead>
);
};
/**
* Individual row component for the pivot table
* Handles styling, indentation, and content display for each row type
@@ -143,8 +229,16 @@ function formatColumnHeader(metricName: string): string {
*
* @param data - Array of data points from the chart query
* @param config - Pivot table configuration including dimensions and metrics
* @param sortState - Current sort state
* @param onSortChange - Callback for sort state changes
*/
export const PivotTable: React.FC<PivotTableProps> = ({ data, config }) => {
export const PivotTable: React.FC<PivotTableProps> = ({
data,
config,
sortState,
onSortChange,
isLoading = false,
}) => {
// Transform chart data into pivot table structure
const pivotTableRows = useMemo(() => {
if (!data || data.length === 0) {
@@ -156,6 +250,7 @@ export const PivotTable: React.FC<PivotTableProps> = ({ data, config }) => {
dimensions: config?.dimensions ?? [],
metrics: config?.metrics ?? ["metric"], // Default to 'metric' field from DataPoint
rowLimit: config?.rowLimit ?? DEFAULT_ROW_LIMIT,
defaultSort: config?.defaultSort,
};
// Transform DataPoint[] to DatabaseRow[] format using utility functions
@@ -206,11 +301,59 @@ export const PivotTable: React.FC<PivotTableProps> = ({ data, config }) => {
}
}, [data, config]);
// Apply sorting to pivot table rows
const sortedRows = useMemo(() => {
// Use user sort state if available, otherwise fall back to default sort
if (!sortState || !sortState.column) {
return pivotTableRows;
}
try {
return sortPivotTableRows(pivotTableRows, sortState);
} catch (error) {
console.error("Error sorting pivot table rows:", error);
return pivotTableRows;
}
}, [pivotTableRows, sortState]);
// Extract metrics from configuration or fallback to default
const metrics = useMemo(() => {
return config?.metrics ?? ["metric"];
}, [config?.metrics]);
// Handle sort click events - simple cycling
const handleSort = useCallback(
(column: string) => {
if (!onSortChange) return;
const nextSort = getNextSortState(
config?.defaultSort || null,
sortState || null,
column,
);
onSortChange(nextSort);
},
[sortState, onSortChange, config?.defaultSort],
);
// Track the last known defaultSort to detect changes
const [lastDefaultSort, setLastDefaultSort] = useState(config?.defaultSort);
// Reset to defaultSort when it changes
useEffect(() => {
const currentDefaultSort = config?.defaultSort;
// If defaultSort changed, reset the sorting
if (currentDefaultSort !== lastDefaultSort) {
setLastDefaultSort(currentDefaultSort);
// Reset to the new default sort
if (onSortChange) {
onSortChange(currentDefaultSort || null);
}
}
}, [config?.defaultSort, onSortChange, lastDefaultSort]);
// Handle empty data state
if (!data || data.length === 0) {
return (
@@ -236,28 +379,45 @@ export const PivotTable: React.FC<PivotTableProps> = ({ data, config }) => {
}
return (
<div className="h-full overflow-auto px-5 pb-2">
<div className="relative h-full overflow-auto px-5 pb-2">
{isLoading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Refreshing data...</span>
</div>
</div>
)}
<Table>
<TableHeader>
<TableRow className="border-b bg-muted/50">
<TableHeader className="sticky top-0 z-10">
<TableRow>
{/* Dimension column header */}
<TableHead className="p-2 text-left font-medium">
{config?.dimensions && config.dimensions.length > 0
? config.dimensions.map(formatColumnHeader).join(" / ") // Show all dimensions
: "Dimension"}
</TableHead>
<StaticHeader
label={
config?.dimensions && config.dimensions.length > 0
? config.dimensions.map(formatColumnHeader).join(" / ") // Show all dimensions
: "Dimension"
}
className="p-2 text-left font-medium first:pl-2"
/>
{/* Metric column headers */}
{metrics.map((metric) => (
<TableHead key={metric} className="p-2 text-right font-medium">
{formatColumnHeader(metric)}
</TableHead>
<SortableHeader
key={metric}
column={metric}
label={formatColumnHeader(metric)}
sortState={sortState}
onSort={handleSort}
className="p-2 font-medium"
rightAlign={true}
/>
))}
</TableRow>
</TableHeader>
<TableBody>
{pivotTableRows.map((row) => (
{sortedRows.map((row) => (
<PivotTableRowComponent key={row.id} row={row} metrics={metrics} />
))}
</TableBody>
@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { api } from "@/src/utils/api";
import {
type views,
@@ -7,7 +7,7 @@ import {
} from "@/src/features/query";
import { type z } from "zod/v4";
import { Chart } from "@/src/features/widgets/chart-library/Chart";
import { type FilterState } from "@langfuse/shared";
import { type FilterState, type OrderByState } from "@langfuse/shared";
import { isTimeSeriesChart } from "@/src/features/widgets/chart-library/utils";
import {
PencilIcon,
@@ -69,6 +69,28 @@ export function DashboardWidget({
: new Date(new Date().getTime() - 1000);
const toTimestamp = dateRange ? dateRange.to : new Date();
// Initialize sort state for pivot tables
const defaultSort =
widget.data?.chartConfig.type === "PIVOT_TABLE"
? widget.data?.chartConfig.defaultSort
: undefined;
const [sortState, setSortState] = useState<OrderByState | null>(() => {
return defaultSort || null;
});
// Apply defaultSort when it becomes available (after widget data loads)
// but only if user hasn't interacted yet
useEffect(() => {
if (defaultSort && sortState === null) {
setSortState(defaultSort);
}
}, [defaultSort, sortState]);
const updateSort = useCallback((newSort: OrderByState | null) => {
setSortState(newSort);
}, []);
const queryResult = api.dashboard.executeQuery.useQuery(
{
projectId,
@@ -94,7 +116,15 @@ export function DashboardWidget({
: null,
fromTimestamp: fromTimestamp.toISOString(),
toTimestamp: toTimestamp.toISOString(),
orderBy: null,
orderBy:
widget.data?.chartConfig.type === "PIVOT_TABLE" && sortState
? [
{
field: sortState.column,
direction: sortState.order.toLowerCase() as "asc" | "desc",
},
]
: null,
chartConfig: widget.data?.chartConfig,
},
},
@@ -301,6 +331,13 @@ export function DashboardWidget({
),
}),
}}
sortState={
widget.data.chartType === "PIVOT_TABLE" ? sortState : undefined
}
onSortChange={
widget.data.chartType === "PIVOT_TABLE" ? updateSort : undefined
}
isLoading={queryResult.isLoading}
/>
</div>
</div>
@@ -249,6 +249,14 @@ export function WidgetForm({
initialValues.chartConfig?.bins ?? 10,
);
// Default sort configuration for pivot tables
const [defaultSortColumn, setDefaultSortColumn] = useState<string>(
initialValues.chartConfig?.defaultSort?.column ?? "none",
);
const [defaultSortOrder, setDefaultSortOrder] = useState<"ASC" | "DESC">(
initialValues.chartConfig?.defaultSort?.order ?? "DESC",
);
// Filter state
const { selectedOption, dateRange, setDateRangeAndOption } =
useDashboardDateRange({ defaultRelativeAggregation: "7 days" });
@@ -270,6 +278,17 @@ export function WidgetForm({
}) ?? [],
);
// Static sort state for pivot table preview (non-interactive)
const previewSortState = useMemo(
() =>
selectedChartType === "PIVOT_TABLE" &&
defaultSortColumn &&
defaultSortColumn !== "none"
? { column: defaultSortColumn, order: defaultSortOrder }
: null,
[selectedChartType, defaultSortColumn, defaultSortOrder],
);
// Helper function to update pivot table dimensions
const updatePivotDimension = (index: number, value: string) => {
const newDimensions = [...pivotDimensions];
@@ -733,7 +752,17 @@ export function WidgetForm({
: null,
fromTimestamp: fromTimestamp.toISOString(),
toTimestamp: toTimestamp.toISOString(),
orderBy: null,
orderBy:
selectedChartType === "PIVOT_TABLE" && previewSortState
? [
{
field: previewSortState.column,
direction: previewSortState.order.toLowerCase() as
| "asc"
| "desc",
},
]
: null,
chartConfig:
selectedChartType === "HISTOGRAM"
? { type: selectedChartType, bins: histogramBins }
@@ -742,6 +771,13 @@ export function WidgetForm({
type: selectedChartType,
dimensions: pivotDimensions,
row_limit: rowLimit,
defaultSort:
defaultSortColumn && defaultSortColumn !== "none"
? {
column: defaultSortColumn,
order: defaultSortOrder,
}
: undefined,
}
: { type: selectedChartType },
};
@@ -757,6 +793,9 @@ export function WidgetForm({
histogramBins,
pivotDimensions,
rowLimit,
defaultSortColumn,
defaultSortOrder,
previewSortState,
]);
const queryResult = api.dashboard.executeQuery.useQuery(
@@ -875,6 +914,13 @@ export function WidgetForm({
? {
type: selectedChartType as DashboardWidgetChartType,
row_limit: rowLimit,
defaultSort:
defaultSortColumn && defaultSortColumn !== "none"
? {
column: defaultSortColumn,
order: defaultSortOrder,
}
: undefined,
}
: {
type: selectedChartType as DashboardWidgetChartType,
@@ -1400,6 +1446,70 @@ export function WidgetForm({
)}
</div>
)}
{/* Pivot Table Default Sort Configuration */}
{selectedChartType === "PIVOT_TABLE" && (
<div className="space-y-4">
<div>
<h4 className="mb-2 text-sm font-semibold">
Default Sort Configuration
</h4>
<p className="mb-3 text-xs text-muted-foreground">
Configure the default sort order for the pivot table. This
will be applied when the widget is first loaded.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="default-sort-column">Sort Column</Label>
<Select
value={defaultSortColumn}
onValueChange={setDefaultSortColumn}
>
<SelectTrigger id="default-sort-column">
<SelectValue placeholder="Select a column to sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No default sort</SelectItem>
{/* Show available metrics as sort options */}
{selectedMetrics
.filter(
(metric) =>
metric.measure && metric.measure !== "",
)
.map((metric) => (
<SelectItem key={metric.id} value={metric.id}>
{formatMetricName(metric.id)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="default-sort-order">Sort Order</Label>
<Select
value={defaultSortOrder}
onValueChange={(value: "ASC" | "DESC") =>
setDefaultSortOrder(value)
}
disabled={
!defaultSortColumn || defaultSortColumn === "none"
}
>
<SelectTrigger id="default-sort-order">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ASC">Ascending (A-Z)</SelectItem>
<SelectItem value="DESC">Descending (Z-A)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
)}
</div>
{/* Visualization Section */}
@@ -1569,6 +1679,13 @@ export function WidgetForm({
dimensions: pivotDimensions,
row_limit: rowLimit,
metrics: selectedMetrics.map((metric) => metric.id), // Pass metric field names
defaultSort:
defaultSortColumn && defaultSortColumn !== "none"
? {
column: defaultSortColumn,
order: defaultSortOrder,
}
: undefined,
}
: selectedChartType === "HISTOGRAM"
? {
@@ -1580,6 +1697,12 @@ export function WidgetForm({
row_limit: rowLimit,
}
}
sortState={
selectedChartType === "PIVOT_TABLE"
? previewSortState
: undefined
}
onSortChange={undefined}
/>
) : (
<CardContent>
+4
View File
@@ -7,6 +7,10 @@ export type WidgetChartConfig = {
type: DashboardWidgetChartType;
row_limit?: number;
bins?: number;
defaultSort?: {
column: string;
order: "ASC" | "DESC";
};
};
/**
@@ -17,6 +17,7 @@
* - Supports future expansion beyond current 2-dimension limit
*/
import { isNotNullOrUndefined } from "@/src/utils/types";
import { type OrderByState } from "@langfuse/shared";
/**
* Default dimension limit for pivot table data rows
@@ -81,6 +82,8 @@ export interface PivotTableConfig {
/** Maximum number of data rows to display (before totals) */
rowLimit?: number;
defaultSort?: OrderByState;
}
/**
@@ -608,3 +611,196 @@ export function createGrandTotalRow(
dimensionValues,
};
}
/**
* Sorts pivot table rows hierarchically based on sort configuration
*
* Hierarchical sorting behavior:
* 1. Sort groups (subtotal rows) by their total values first
* 2. Sort individual items within each group
* 3. Maintain grand total at the top
* 4. Preserve group hierarchy and indentation
*
* @param rows - Array of pivot table rows to sort
* @param sortConfig - Sort configuration with column and order
* @returns Sorted array of pivot table rows
*/
export function sortPivotTableRows(
rows: PivotTableRow[],
sortConfig: { column: string; order: "ASC" | "DESC" },
): PivotTableRow[] {
if (!sortConfig || !sortConfig.column) {
return rows;
}
const { column, order } = sortConfig;
const isAscending = order === "ASC";
// Helper function to get sortable value from a row
const getSortValue = (row: PivotTableRow): number => {
const value = row.values[column];
if (typeof value === "number") {
return value;
}
if (typeof value === "string") {
// Try to parse as number, fallback to 0
const parsed = parseFloat(value);
return isNaN(parsed) ? 0 : parsed;
}
return 0;
};
// Helper function to compare two values
const compareValues = (a: number, b: number): number => {
if (isAscending) {
return a - b;
}
return b - a;
};
// Helper function to compare strings (for equal numeric values)
const compareLabels = (a: string, b: string): number => {
if (isAscending) {
return a.localeCompare(b);
}
return b.localeCompare(a);
};
// Separate different row types for processing
const grandTotalRows = rows.filter(isTotalRow);
const subtotalRows = rows.filter(isSubtotalRow);
const dataRows = rows.filter(isDataRow);
// Sort subtotal rows (groups) by their total values
const sortedSubtotalRows = subtotalRows.sort((a, b) => {
const aValue = getSortValue(a);
const bValue = getSortValue(b);
const valueComparison = compareValues(aValue, bValue);
if (valueComparison !== 0) {
return valueComparison;
}
// For equal values, sort by label
return compareLabels(a.label, b.label);
});
// Group data rows by their parent subtotal
const dataRowsByGroup: Record<string, PivotTableRow[]> = {};
for (const row of dataRows) {
// Find the parent subtotal for this data row
const parentGroup = findParentGroup(row, subtotalRows);
const groupKey = parentGroup?.label || "ungrouped";
if (!dataRowsByGroup[groupKey]) {
dataRowsByGroup[groupKey] = [];
}
dataRowsByGroup[groupKey].push(row);
}
// Sort data rows within each group
for (const groupKey in dataRowsByGroup) {
dataRowsByGroup[groupKey].sort((a, b) => {
const aValue = getSortValue(a);
const bValue = getSortValue(b);
const valueComparison = compareValues(aValue, bValue);
if (valueComparison !== 0) {
return valueComparison;
}
// For equal values, sort by label
return compareLabels(a.label, b.label);
});
}
// Reconstruct the sorted rows maintaining hierarchy
const sortedRows: PivotTableRow[] = [];
// Add grand total at the top
sortedRows.push(...grandTotalRows);
// Add sorted subtotal rows with their sorted data rows
for (const subtotalRow of sortedSubtotalRows) {
sortedRows.push(subtotalRow);
// Add sorted data rows for this group
const groupKey = subtotalRow.label;
const groupDataRows = dataRowsByGroup[groupKey] || [];
sortedRows.push(...groupDataRows);
}
// Add any ungrouped data rows at the end
const ungroupedRows = dataRowsByGroup["ungrouped"] || [];
sortedRows.push(...ungroupedRows);
return sortedRows;
}
/**
* Finds the parent subtotal group for a data row
*
* @param dataRow - The data row to find parent for
* @param subtotalRows - Array of all subtotal rows
* @returns The parent subtotal row or null if not found
*/
function findParentGroup(
dataRow: PivotTableRow,
subtotalRows: PivotTableRow[],
): PivotTableRow | null {
// For now, use a simple approach: find subtotal row with matching level
// This can be enhanced later for more complex grouping logic
const parentLevel = dataRow.level - 1;
return (
subtotalRows.find(
(subtotal) =>
subtotal.level === parentLevel &&
subtotal.label.includes(dataRow.label.split(" - ")[0]), // Simple matching
) || null
);
}
/**
* Gets the next sort state in the simple cycle: DESC ASC unsorted
*
* @param defaultSort - Default sort state (OrderByState or null for unsorted)
* @param currentSort - Current sort state (OrderByState or null for unsorted)
* @param column - Column to sort by
* @returns Next sort state in the cycle
*/
export function getNextSortState(
defaultSort: OrderByState,
currentSort: OrderByState,
column: string,
): OrderByState | null {
// Different column or no current sort → start with DESC
if (!currentSort || currentSort.column !== column) {
return { column, order: "DESC" };
}
// Same column: DESC → ASC → null (unsorted)
if (currentSort.order === "DESC") {
return { column, order: "ASC" };
}
// Column other than the default column, go back to default
if (
currentSort.order === "ASC" &&
currentSort.column !== defaultSort?.column
) {
return defaultSort || null;
}
// Default column, flip back to DESC
if (
currentSort.order === "ASC" &&
currentSort.column === defaultSort?.column
) {
return { column, order: "DESC" };
}
// Fallback (shouldn't happen)
return null;
}
+48 -18
View File
@@ -5,6 +5,7 @@ import {
QueueName,
getQueue,
IngestionQueue,
TraceUpsertQueue,
IngestionEvent,
} from "@langfuse/shared/src/server";
import { AdminApiAuthService } from "@/src/ee/features/admin-api/server/adminApiAuth";
@@ -55,24 +56,26 @@ export default async function handler(
return;
}
const body = ManageBullBody.safeParse(req.body);
if (!body.success) {
res.status(400).json({ error: body.error });
return;
}
if (req.method === "GET") {
const queues: string[] = Object.values(QueueName);
queues.push(...IngestionQueue.getShardNames());
queues.push(...TraceUpsertQueue.getShardNames());
const queueCounts = await Promise.all(
queues.map(async (queueName) => {
try {
const queue = queueName.startsWith(QueueName.IngestionQueue)
? IngestionQueue.getInstance({ shardName: queueName })
: getQueue(
queueName as Exclude<QueueName, QueueName.IngestionQueue>,
);
let queue;
if (queueName.startsWith(QueueName.IngestionQueue)) {
queue = IngestionQueue.getInstance({ shardName: queueName });
} else if (queueName.startsWith(QueueName.TraceUpsert)) {
queue = TraceUpsertQueue.getInstance({ shardName: queueName });
} else {
queue = getQueue(
queueName as Exclude<
QueueName,
QueueName.IngestionQueue | QueueName.TraceUpsert
>,
);
}
const jobCount = await queue?.getJobCounts();
return { queueName, jobCount };
} catch (e) {
@@ -84,15 +87,32 @@ export default async function handler(
return res.status(200).json(queueCounts);
}
const body = ManageBullBody.safeParse(req.body);
if (!body.success) {
res.status(400).json({ error: body.error });
return;
}
if (req.method === "POST" && body.data.action === "remove") {
logger.info(
`Removing jobs for queues ${body.data.queueNames.join(", ")}`,
);
for (const queueName of body.data.queueNames) {
const queue = queueName.startsWith(QueueName.IngestionQueue)
? IngestionQueue.getInstance({ shardName: queueName })
: getQueue(queueName as Exclude<QueueName, QueueName.IngestionQueue>);
let queue;
if (queueName.startsWith(QueueName.IngestionQueue)) {
queue = IngestionQueue.getInstance({ shardName: queueName });
} else if (queueName.startsWith(QueueName.TraceUpsert)) {
queue = TraceUpsertQueue.getInstance({ shardName: queueName });
} else {
queue = getQueue(
queueName as Exclude<
QueueName,
QueueName.IngestionQueue | QueueName.TraceUpsert
>,
);
}
let totalCount = 0;
let failedCountInLoop;
@@ -127,9 +147,19 @@ export default async function handler(
);
for (const queueName of body.data.queueNames) {
const queue = queueName.startsWith(QueueName.IngestionQueue)
? IngestionQueue.getInstance({ shardName: queueName })
: getQueue(queueName as Exclude<QueueName, QueueName.IngestionQueue>);
let queue;
if (queueName.startsWith(QueueName.IngestionQueue)) {
queue = IngestionQueue.getInstance({ shardName: queueName });
} else if (queueName.startsWith(QueueName.TraceUpsert)) {
queue = TraceUpsertQueue.getInstance({ shardName: queueName });
} else {
queue = getQueue(
queueName as Exclude<
QueueName,
QueueName.IngestionQueue | QueueName.TraceUpsert
>,
);
}
const jobCount = await queue?.getJobCounts("failed");
logger.info(
`Retrying ${JSON.stringify(jobCount)} jobs for queue ${queueName}`,
@@ -2,12 +2,14 @@ import { prisma } from "@langfuse/shared/src/db";
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
import { createAuthedProjectAPIRoute } from "@/src/features/public-api/server/createAuthedProjectAPIRoute";
import {
CreateAnnotationQueueBody,
CreateAnnotationQueueResponse,
GetAnnotationQueuesQuery,
GetAnnotationQueuesResponse,
} from "@/src/features/public-api/types/annotation-queues";
import { InvalidRequestError, MethodNotAllowedError } from "@langfuse/shared";
export default withMiddlewares({
// NOTE: Post API requires entitlement check
GET: createAuthedProjectAPIRoute({
name: "Get annotation queues",
querySchema: GetAnnotationQueuesQuery,
@@ -54,4 +56,72 @@ export default withMiddlewares({
};
},
}),
POST: createAuthedProjectAPIRoute({
name: "Create annotation queue",
bodySchema: CreateAnnotationQueueBody,
responseSchema: CreateAnnotationQueueResponse,
fn: async ({ body, auth }) => {
// entitlement check
if (auth.scope.plan === "cloud:hobby") {
if (
(await prisma.annotationQueue.count({
where: {
projectId: auth.scope.projectId,
},
})) >= 1
) {
throw new MethodNotAllowedError(
"Maximum number of annotation queues reached on Hobby plan.",
);
}
}
const existingQueue = await prisma.annotationQueue.findFirst({
where: {
projectId: auth.scope.projectId,
name: body.name,
},
});
if (existingQueue) {
throw new InvalidRequestError("A queue with this name already exists.");
}
// verify the score configs exist
const scoreConfigs = await prisma.scoreConfig.findMany({
where: {
id: { in: body.scoreConfigIds },
projectId: auth.scope.projectId,
},
select: {
id: true,
},
});
const scoreConfigIdSet = new Set(scoreConfigs.map((config) => config.id));
if (body.scoreConfigIds.some((id) => !scoreConfigIdSet.has(id))) {
throw new InvalidRequestError(
"At least one of the score config IDs cannot be found for the given project.",
);
}
const queue = await prisma.annotationQueue.create({
data: {
projectId: auth.scope.projectId,
name: body.name,
description: body.description,
scoreConfigIds: body.scoreConfigIds,
},
});
return {
id: queue.id,
name: queue.name,
description: queue.description,
scoreConfigIds: queue.scoreConfigIds,
createdAt: queue.createdAt,
updatedAt: queue.updatedAt,
};
},
}),
});
+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({
+9 -86
View File
@@ -1,93 +1,16 @@
// This page is currently only shown to Langfuse cloud users.
// It might be expanded to everyone in the future when it does not only ask for the referral source.
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod/v4";
import { Button } from "@/src/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/src/components/ui/form";
import { usePostHog } from "posthog-js/react";
import { useRouter } from "next/router";
import { LangfuseIcon } from "@/src/components/LangfuseLogo";
import { Textarea } from "@/src/components/ui/textarea";
const referralSourceSchema = z.object({
referralSource: z.string().optional(),
});
export default function ReferralSource() {
const posthog = usePostHog();
const router = useRouter();
const form = useForm({
resolver: zodResolver(referralSourceSchema),
defaultValues: {
referralSource: "",
},
});
function onSubmit(values: z.infer<typeof referralSourceSchema>) {
if (values.referralSource && values.referralSource !== "") {
posthog.capture("survey sent", {
$survey_id: "018ade05-4d8c-0000-36b7-fc390b221590",
$survey_name: "Referral source",
$survey_response: values.referralSource,
});
}
void router.push("/");
}
import Head from "next/head";
import { OnboardingSurvey } from "@/src/features/onboarding/components/OnboardingSurvey";
export default function OnboardingPage() {
return (
<div className="flex flex-1 flex-col py-6 sm:min-h-full sm:justify-center sm:px-6 sm:py-12 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<LangfuseIcon className="mx-auto" />
<h2 className="mt-4 text-center text-2xl font-bold leading-9 tracking-tight text-primary">
Welcome to Langfuse
</h2>
</div>
<div className="mt-14 bg-background px-6 py-10 shadow sm:mx-auto sm:w-full sm:max-w-[480px] sm:rounded-lg sm:px-12">
<Form {...form}>
<form
className="space-y-6"
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="referralSource"
render={({ field }) => (
<FormItem>
<FormLabel>
Where did you hear about us?{" "}
<span className="font-normal">(optional)</span>
</FormLabel>
<FormControl>
<Textarea
placeholder="We're curious to know how you discovered the project! Thanks for sharing."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
variant={form.formState.isDirty ? "default" : "secondary"}
className="w-full"
loading={form.formState.isSubmitting}
>
{form.formState.isDirty ? "Continue" : "Skip"}
</Button>
</form>
</Form>
</div>
</div>
<>
<Head>
<title>Onboarding | Langfuse</title>
</Head>
<OnboardingSurvey />
</>
);
}
+2
View File
@@ -43,6 +43,7 @@ import { defaultEvalModelRouter } from "@/src/features/evals/server/defaultEvalM
import { plainRouter } from "@/src/features/support-chat/trpc/plain";
import { slackRouter } from "@/src/features/slack/server/router";
import { queueAssignmentRouter } from "@/src/features/annotation-queues/server/annotationQueueAssignments";
import { surveysRouter } from "@/src/server/api/routers/surveys";
/**
* This is the primary router for your server.
@@ -94,6 +95,7 @@ export const appRouter = createTRPCRouter({
automations: automationsRouter,
slack: slackRouter,
plain: plainRouter,
surveys: surveysRouter,
});
// export type definition of API
+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);
+40
View File
@@ -0,0 +1,40 @@
import { z } from "zod/v4";
import { createTRPCRouter, protectedProcedure } from "@/src/server/api/trpc";
import { TRPCError } from "@trpc/server";
import { SurveyName } from "@prisma/client";
import { logger } from "@langfuse/shared/src/server";
const surveyResponseSchema = z.object({
surveyName: z.nativeEnum(SurveyName),
response: z.record(z.string(), z.string()),
orgId: z.string().optional(),
});
export const surveysRouter = createTRPCRouter({
create: protectedProcedure
.input(surveyResponseSchema)
.mutation(async ({ ctx, input }) => {
try {
const survey = await ctx.prisma.survey.create({
data: {
surveyName: input.surveyName,
response: input.response,
userId: ctx.session.user.id,
userEmail: ctx.session.user.email ?? undefined,
orgId: input.orgId,
},
});
return survey;
} catch (error) {
logger.error("Failed to call surveys.create", error);
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to save survey response",
});
}
}),
});
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "worker",
"version": "3.95.2",
"version": "3.97.3",
"description": "",
"license": "MIT",
"private": true,
@@ -38,7 +38,6 @@
"@opentelemetry/resources": "^1.26.0",
"@opentelemetry/sdk-node": "^0.53.0",
"@prisma/instrumentation": "^6.3.0",
"@slack/web-api": "^7.0.0",
"backoff": "^2.5.0",
"bullmq": "^5.34.10",
"cors": "^2.8.5",
+9 -3
View File
@@ -13,9 +13,14 @@ describe.sequential("handle redis events", () => {
});
test("handle redis job succeeding", async () => {
WorkerManager.register(QueueName.TraceUpsert, async () => true);
const shardingKey = "project-id-trace-id";
const traceUpsertQueue = TraceUpsertQueue.getInstance({ shardingKey });
const shardNames = TraceUpsertQueue.getShardNames();
const traceUpsertQueue = TraceUpsertQueue.getInstance();
// Register workers for all shards since we don't know which one will be used
shardNames.forEach((shardName) => {
WorkerManager.register(shardName as QueueName, async () => true);
});
expect(traceUpsertQueue).toBeDefined();
@@ -44,7 +49,8 @@ describe.sequential("handle redis events", () => {
// IngestionQueue worker vs TraceUpsert producer
WorkerManager.register(QueueName.IngestionQueue, async () => true);
const traceUpsertQueue = TraceUpsertQueue.getInstance();
const shardingKey = "project-id-trace-id";
const traceUpsertQueue = TraceUpsertQueue.getInstance({ shardingKey });
expect(traceUpsertQueue).toBeDefined();
+12 -7
View File
@@ -29,6 +29,7 @@ import {
BlobStorageIntegrationQueue,
DeadLetterRetryQueue,
IngestionQueue,
TraceUpsertQueue,
} from "@langfuse/shared/src/server";
import { env } from "./env";
import { ingestionQueueProcessorBuilder } from "./queues/ingestionQueue";
@@ -90,13 +91,17 @@ ClickhouseReadSkipCache.getInstance(prisma)
});
if (env.QUEUE_CONSUMER_TRACE_UPSERT_QUEUE_IS_ENABLED === "true") {
WorkerManager.register(
QueueName.TraceUpsert,
evalJobTraceCreatorQueueProcessor,
{
concurrency: env.LANGFUSE_TRACE_UPSERT_WORKER_CONCURRENCY,
},
);
// Register workers for all trace upsert queue shards
const traceUpsertShardNames = TraceUpsertQueue.getShardNames();
traceUpsertShardNames.forEach((shardName) => {
WorkerManager.register(
shardName as QueueName,
evalJobTraceCreatorQueueProcessor,
{
concurrency: env.LANGFUSE_TRACE_UPSERT_WORKER_CONCURRENCY,
},
);
});
}
if (env.QUEUE_CONSUMER_CREATE_EVAL_QUEUE_IS_ENABLED === "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.95.2";
export const VERSION = "v3.97.3";
+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",
+3
View File
@@ -242,6 +242,9 @@ const EnvSchema = z.object({
.positive()
.default(120_000), // 2 minutes
LANGFUSE_EXPERIMENT_INSERT_INTO_TRACES_TABLE: z
.enum(["true", "false"])
.default("true"),
LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES: z
.enum(["true", "false"])
.default("false"),
@@ -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(
+5 -3
View File
@@ -138,10 +138,12 @@ export const ingestionQueueProcessorBuilder = (
const events: IngestionEventType[] = [];
// Check if we should skip S3 list operation
// The producer sets skipS3List to true if it's an OTel observation
const shouldSkipS3List =
job.data.payload.data.skipS3List && job.data.payload.data.fileKey;
// The producer sets skipS3List to true if it's an OTel observation
(job.data.payload.data.skipS3List && job.data.payload.data.fileKey) ||
// If we do not insert into the traces table, we can skip the list and process single files
(env.LANGFUSE_EXPERIMENT_INSERT_INTO_TRACES_TABLE === "false" &&
clickhouseEntityType === "trace");
const s3Prefix = `${env.LANGFUSE_S3_EVENT_UPLOAD_PREFIX}${job.data.payload.authCheck.scope.projectId}/${clickhouseEntityType}/${job.data.payload.data.eventBodyId}/`;
let totalS3DownloadSizeBytes = 0;
+9 -1
View File
@@ -7,6 +7,7 @@ import {
logger,
QueueName,
IngestionQueue,
TraceUpsertQueue,
recordGauge,
recordHistogram,
recordIncrement,
@@ -35,7 +36,14 @@ export class WorkerManager {
const result = await processor(job);
const queue = queueName.startsWith(QueueName.IngestionQueue)
? IngestionQueue.getInstance({ shardName: queueName })
: getQueue(queueName as Exclude<QueueName, QueueName.IngestionQueue>);
: queueName.startsWith(QueueName.TraceUpsert)
? TraceUpsertQueue.getInstance({ shardName: queueName })
: getQueue(
queueName as Exclude<
QueueName,
QueueName.IngestionQueue | QueueName.TraceUpsert
>,
);
Promise.allSettled([
// Here we only consider waiting jobs instead of the default ("waiting" or "delayed"
// or "prioritized" or "waiting-children") that count provides
+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
+78 -68
View File
@@ -371,75 +371,59 @@ export class IngestionService {
traceEventList: timeSortedEvents,
});
const minTimestamp = Math.min(
...timeSortedEvents.flatMap((e) =>
e.body?.timestamp ? [new Date(e.body.timestamp).getTime()] : [],
),
);
const timestamp =
minTimestamp === Infinity
? undefined
: convertDateToClickhouseDateTime(new Date(minTimestamp));
const clickhouseTraceRecord = await this.getClickhouseRecord({
projectId,
entityId,
table: TableName.Traces,
additionalFilters: {
whereCondition: timestamp
? " AND timestamp >= {timestamp: DateTime64(3)} "
: "",
params: { timestamp },
},
});
if (clickhouseTraceRecord) {
recordIncrement("langfuse.ingestion.lookup.hit", 1, {
store: "clickhouse",
object: "trace",
});
}
const finalTraceRecord = await this.mergeTraceRecords({
clickhouseTraceRecord,
traceRecords,
});
finalTraceRecord.created_at =
clickhouseTraceRecord?.created_at ?? createdAtTimestamp.getTime();
// Search for the first non-null input and output in the trace events and set them on the merged result.
// Fallback to the ClickHouse input/output if none are found within the events list.
const reversedRawRecords = timeSortedEvents.slice().reverse();
finalTraceRecord.input = this.stringify(
reversedRawRecords.find((record) => record?.body?.input)?.body?.input ??
clickhouseTraceRecord?.input,
);
finalTraceRecord.output = this.stringify(
reversedRawRecords.find((record) => record?.body?.output)?.body?.output ??
clickhouseTraceRecord?.output,
);
const finalIO = {
input: this.stringify(
reversedRawRecords.find((record) => record?.body?.input)?.body?.input,
),
output: this.stringify(
reversedRawRecords.find((record) => record?.body?.output)?.body?.output,
),
};
// If the trace has a sessionId, we upsert the corresponding session into Postgres.
if (finalTraceRecord.session_id) {
try {
await this.prisma.$executeRaw`
INSERT INTO trace_sessions (id, project_id, environment, created_at, updated_at)
VALUES (${finalTraceRecord.session_id}, ${projectId}, ${finalTraceRecord.environment}, NOW(), NOW())
ON CONFLICT (id, project_id)
DO UPDATE SET
environment = EXCLUDED.environment,
updated_at = NOW()
WHERE trace_sessions.environment IS DISTINCT FROM EXCLUDED.environment
`;
} catch (e) {
logger.error(
`Failed to upsert session ${finalTraceRecord.session_id}`,
e,
);
throw e;
if (env.LANGFUSE_EXPERIMENT_INSERT_INTO_TRACES_TABLE === "true") {
const minTimestamp = Math.min(
...timeSortedEvents.flatMap((e) =>
e.body?.timestamp ? [new Date(e.body.timestamp).getTime()] : [],
),
);
const timestamp =
minTimestamp === Infinity
? undefined
: convertDateToClickhouseDateTime(new Date(minTimestamp));
const clickhouseTraceRecord = await this.getClickhouseRecord({
projectId,
entityId,
table: TableName.Traces,
additionalFilters: {
whereCondition: timestamp
? " AND timestamp >= {timestamp: DateTime64(3)} "
: "",
params: { timestamp },
},
});
if (clickhouseTraceRecord) {
recordIncrement("langfuse.ingestion.lookup.hit", 1, {
store: "clickhouse",
object: "trace",
});
}
}
this.clickHouseWriter.addToQueue(TableName.Traces, finalTraceRecord);
const finalTraceRecord = await this.mergeTraceRecords({
clickhouseTraceRecord,
traceRecords,
});
finalTraceRecord.created_at =
clickhouseTraceRecord?.created_at ?? createdAtTimestamp.getTime();
finalTraceRecord.input = finalIO.input ?? clickhouseTraceRecord?.input;
finalTraceRecord.output = finalIO.output ?? clickhouseTraceRecord?.output;
this.clickHouseWriter.addToQueue(TableName.Traces, finalTraceRecord);
}
// Experimental: Also write to traces_null table if experiment flag is enabled
// Here we use the raw events to ensure that we stop relying on the merge logic
@@ -450,22 +434,48 @@ export class IngestionService {
this.clickHouseWriter.addToQueue(TableName.TracesNull, {
...r,
// We need to re-add input and output here as they were excluded in a previous mapping step
input: finalTraceRecord.input ?? "",
output: finalTraceRecord.output ?? "",
input: finalIO.input ?? "",
output: finalIO.output ?? "",
}),
);
}
// If the trace has a sessionId, we upsert the corresponding session into Postgres.
const traceRecordWithSession = traceRecords
.slice()
.reverse()
.find((t) => t.session_id);
if (traceRecordWithSession) {
try {
await this.prisma.$executeRaw`
INSERT INTO trace_sessions (id, project_id, environment, created_at, updated_at)
VALUES (${traceRecordWithSession.session_id}, ${projectId}, ${traceRecordWithSession.environment}, NOW(), NOW())
ON CONFLICT (id, project_id)
DO UPDATE SET
environment = EXCLUDED.environment,
updated_at = NOW()
WHERE trace_sessions.environment IS DISTINCT FROM EXCLUDED.environment
`;
} catch (e) {
logger.error(
`Failed to upsert session ${traceRecordWithSession.session_id}`,
e,
);
throw e;
}
}
// Add trace into trace upsert queue for eval processing
const traceUpsertQueue = TraceUpsertQueue.getInstance();
const shardingKey = `${projectId}-${entityId}`;
const traceUpsertQueue = TraceUpsertQueue.getInstance({ shardingKey });
if (!traceUpsertQueue) {
logger.error("TraceUpsertQueue is not initialized");
return;
}
await traceUpsertQueue.add(QueueJobs.TraceUpsert, {
payload: {
projectId: finalTraceRecord.project_id,
traceId: finalTraceRecord.id,
projectId,
traceId: entityId,
},
id: randomUUID(),
timestamp: new Date(),