From b17579ae404fce71e955f5e2059cf36cbc1e0105 Mon Sep 17 00:00:00 2001 From: Ravi Kumar L Date: Sun, 21 Jun 2026 23:27:29 +0200 Subject: [PATCH] fix: treat blank tenant fanout toggle as disabled --- .env.example | 2 +- deploy-compose.langfuse-fanout.yml | 2 +- docker-compose.langfuse-fanout.yml | 2 +- helm/librechat/readme.md | 7 ++--- otel/langfuse-fanout/README.md | 7 ++--- .../__tests__/run-summarization.test.ts | 26 +++++++++++++++++++ packages/api/src/langfuse/config.ts | 4 +-- packages/api/src/langfuse/destinations.ts | 9 +++++-- packages/api/src/langfuse/feedback.spec.ts | 26 +++++++++++++++++++ packages/api/src/langfuse/utils.ts | 9 +++++++ 10 files changed, 81 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index 3fc5a7fe00..05e9ca5074 100644 --- a/.env.example +++ b/.env.example @@ -132,7 +132,7 @@ NODE_MAX_OLD_SPACE_SIZE=6144 # See otel/langfuse-fanout/README.md. # LANGFUSE_FANOUT_ENABLED=false # LANGFUSE_FANOUT_COLLECTOR_URL=http://langfuse-fanout-collector:4318 -# Emergency switch: set false to keep central collector export but skip tenant trace/score export. +# Emergency switch: unset defaults enabled; set false or blank to keep central collector export but skip tenant trace/score export. # LANGFUSE_FANOUT_TENANT_EXPORT_ENABLED=true # Langfuse Cloud base URL options: https://cloud.langfuse.com (EU), # https://us.cloud.langfuse.com (US), https://jp.cloud.langfuse.com (JP), diff --git a/deploy-compose.langfuse-fanout.yml b/deploy-compose.langfuse-fanout.yml index 98e3c2dc7b..4110eb1526 100644 --- a/deploy-compose.langfuse-fanout.yml +++ b/deploy-compose.langfuse-fanout.yml @@ -5,7 +5,7 @@ services: environment: - LANGFUSE_FANOUT_ENABLED=true - LANGFUSE_FANOUT_COLLECTOR_URL=http://langfuse-fanout-collector:4318 - - LANGFUSE_FANOUT_TENANT_EXPORT_ENABLED=${LANGFUSE_FANOUT_TENANT_EXPORT_ENABLED:-true} + - LANGFUSE_FANOUT_TENANT_EXPORT_ENABLED=${LANGFUSE_FANOUT_TENANT_EXPORT_ENABLED-true} - LANGFUSE_FANOUT_TENANT_DESTINATIONS=${LANGFUSE_FANOUT_TENANT_DESTINATIONS:-eu=https://cloud.langfuse.com,us=https://us.cloud.langfuse.com,jp=https://jp.cloud.langfuse.com,hipaa=https://hipaa.cloud.langfuse.com} networks: - default diff --git a/docker-compose.langfuse-fanout.yml b/docker-compose.langfuse-fanout.yml index 98e3c2dc7b..4110eb1526 100644 --- a/docker-compose.langfuse-fanout.yml +++ b/docker-compose.langfuse-fanout.yml @@ -5,7 +5,7 @@ services: environment: - LANGFUSE_FANOUT_ENABLED=true - LANGFUSE_FANOUT_COLLECTOR_URL=http://langfuse-fanout-collector:4318 - - LANGFUSE_FANOUT_TENANT_EXPORT_ENABLED=${LANGFUSE_FANOUT_TENANT_EXPORT_ENABLED:-true} + - LANGFUSE_FANOUT_TENANT_EXPORT_ENABLED=${LANGFUSE_FANOUT_TENANT_EXPORT_ENABLED-true} - LANGFUSE_FANOUT_TENANT_DESTINATIONS=${LANGFUSE_FANOUT_TENANT_DESTINATIONS:-eu=https://cloud.langfuse.com,us=https://us.cloud.langfuse.com,jp=https://jp.cloud.langfuse.com,hipaa=https://hipaa.cloud.langfuse.com} networks: - default diff --git a/helm/librechat/readme.md b/helm/librechat/readme.md index 9d0dd25425..23cd14a25e 100755 --- a/helm/librechat/readme.md +++ b/helm/librechat/readme.md @@ -64,9 +64,10 @@ When enabled, the chart also sets `LANGFUSE_FANOUT_ENABLED` and `LANGFUSE_FANOUT_COLLECTOR_URL` for the LibreChat app unless those values are already provided in `librechat.configEnv`. -Set `librechat.configEnv.LANGFUSE_FANOUT_TENANT_EXPORT_ENABLED=false` to keep -central trace export flowing through the collector while disabling tenant trace -and score export. +Set `librechat.configEnv.LANGFUSE_FANOUT_TENANT_EXPORT_ENABLED=false` or a +blank value to keep central trace export flowing through the collector while +disabling tenant trace and score export. When omitted, tenant export defaults to +enabled if tenant keys and a known destination are configured. Langfuse tenant base URLs are selected from the startup-configured destination map rendered into LibreChat and the collector. Tenant API keys can still be added diff --git a/otel/langfuse-fanout/README.md b/otel/langfuse-fanout/README.md index c301700cb2..f8765ab933 100644 --- a/otel/langfuse-fanout/README.md +++ b/otel/langfuse-fanout/README.md @@ -40,9 +40,10 @@ defined in this collector config. - Tenant app configuration must set a Langfuse base URL matching one of the startup destinations before tenant trace/score export is enabled; keys alone are treated as central-only. -- `LANGFUSE_FANOUT_TENANT_EXPORT_ENABLED=false` can be set on LibreChat as an - emergency switch to stop tenant trace and score export while keeping central - collector export active. +- `LANGFUSE_FANOUT_TENANT_EXPORT_ENABLED=false` or a blank value can be set on + LibreChat as an emergency switch to stop tenant trace and score export while + keeping central collector export active. When omitted, tenant export defaults + to enabled if tenant keys and a known destination are configured. - This supports Langfuse Cloud and self-hosted Langfuse as long as each allowed tenant base URL is configured at LibreChat/collector startup. Runtime tenant config selects from those known destinations; it does not inject arbitrary diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index 25bb9b896f..a711d77f0a 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -1351,6 +1351,32 @@ describe('Langfuse run config', () => { }); }); + it('treats blank tenant fanout export env as disabled', async () => { + process.env.LANGFUSE_PUBLIC_KEY = 'pk-central'; + process.env.LANGFUSE_SECRET_KEY = 'sk-central'; + process.env.LANGFUSE_FANOUT_ENABLED = 'true'; + process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318'; + process.env.LANGFUSE_FANOUT_TENANT_EXPORT_ENABLED = ' '; + + const callArgs = await callAndCaptureRunConfig({ + tenantId: 'tenant-1', + appConfig: { + langfuse: { + publicKey: 'pk-tenant-1', + secretKey: 'sk-tenant-1', + baseUrl: 'https://cloud.langfuse.com', + }, + } as AppConfig, + }); + + expect(callArgs.langfuse).toEqual({ + deterministicTraceId: true, + baseUrl: 'http://collector-from-env:4318', + metadata: { 'librechat.tenant.id': 'tenant-1' }, + tags: ['tenant:tenant-1'], + }); + }); + it('lets tenant fanout.enabled=false override deployment fanout env', async () => { process.env.LANGFUSE_FANOUT_ENABLED = 'true'; process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318'; diff --git a/packages/api/src/langfuse/config.ts b/packages/api/src/langfuse/config.ts index e220c69ec4..3fb89f76f1 100644 --- a/packages/api/src/langfuse/config.ts +++ b/packages/api/src/langfuse/config.ts @@ -1,7 +1,7 @@ import type { AppConfig } from '@librechat/data-schemas'; import type { RunConfig } from '@librechat/agents'; import { resolveLangfuseTenantDestination } from './tenantDestinations'; -import { isFalseEnv, normalizeString } from './utils'; +import { isEnabledUnlessBlankOrFalse, normalizeString } from './utils'; type LangfuseRunConfig = NonNullable; type LangfuseRunConfigWithTraceAttributes = LangfuseRunConfig & { @@ -11,7 +11,7 @@ const TENANT_EXPORT_ATTRIBUTE = 'librechat.langfuse.tenant_export.enabled'; const TENANT_DESTINATION_ATTRIBUTE = 'librechat.langfuse.destination'; function isTenantExportEnabled(): boolean { - return !isFalseEnv(process.env.LANGFUSE_FANOUT_TENANT_EXPORT_ENABLED); + return isEnabledUnlessBlankOrFalse(process.env.LANGFUSE_FANOUT_TENANT_EXPORT_ENABLED); } function mergeTraceMetadata( diff --git a/packages/api/src/langfuse/destinations.ts b/packages/api/src/langfuse/destinations.ts index 37da3236ae..2a16cd6350 100644 --- a/packages/api/src/langfuse/destinations.ts +++ b/packages/api/src/langfuse/destinations.ts @@ -1,5 +1,10 @@ import type { AppConfig } from '@librechat/data-schemas'; -import { isFalseEnv, normalizeString, toBasicAuthorization } from './utils'; +import { + isEnabledUnlessBlankOrFalse, + isFalseEnv, + normalizeString, + toBasicAuthorization, +} from './utils'; import { resolveLangfuseTenantDestination } from './tenantDestinations'; const DEFAULT_BASE_URL = 'https://cloud.langfuse.com'; @@ -65,7 +70,7 @@ function getTenantScoreDestination(appConfig?: AppConfig): LangfuseScoreDestinat if (!isTracingEnabled()) { return undefined; } - if (isFalseEnv(process.env.LANGFUSE_FANOUT_TENANT_EXPORT_ENABLED)) { + if (!isEnabledUnlessBlankOrFalse(process.env.LANGFUSE_FANOUT_TENANT_EXPORT_ENABLED)) { return undefined; } diff --git a/packages/api/src/langfuse/feedback.spec.ts b/packages/api/src/langfuse/feedback.spec.ts index af542089e8..575b685375 100644 --- a/packages/api/src/langfuse/feedback.spec.ts +++ b/packages/api/src/langfuse/feedback.spec.ts @@ -363,6 +363,32 @@ describe('Langfuse feedback scores', () => { ); }); + it('treats blank tenant fanout export env as disabled for scores', async () => { + process.env.LANGFUSE_FANOUT_CENTRAL_AUTH_HEADER = 'Basic central-auth'; + process.env.LANGFUSE_FANOUT_CENTRAL_BASE_URL = 'http://central-langfuse:3000'; + process.env.LANGFUSE_FANOUT_TENANT_EXPORT_ENABLED = ' '; + const { sendFeedbackScore } = await loadFeedback(); + + await sendFeedbackScore({ + traceId: 'trace-id', + feedback: { rating: 'thumbsUp' }, + appConfig: appConfigWithLangfuse({ + publicKey: 'tenant-public-key', + secretKey: 'tenant-secret-key', + baseUrl: 'https://cloud.langfuse.com', + }), + }); + + expect(getFetchMock()).toHaveBeenCalledTimes(1); + expect(getFetchMock()).toHaveBeenCalledWith( + 'http://central-langfuse:3000/api/public/scores', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ Authorization: 'Basic central-auth' }), + }), + ); + }); + it('deduplicates matching central and tenant score destinations', async () => { process.env.LANGFUSE_FANOUT_CENTRAL_AUTH_HEADER = getTenantAuthorization(); process.env.LANGFUSE_FANOUT_CENTRAL_BASE_URL = 'http://shared-langfuse:3000'; diff --git a/packages/api/src/langfuse/utils.ts b/packages/api/src/langfuse/utils.ts index 85d242e0d2..dab546da90 100644 --- a/packages/api/src/langfuse/utils.ts +++ b/packages/api/src/langfuse/utils.ts @@ -6,6 +6,15 @@ export function isFalseEnv(value?: string): boolean { return value != null && ['0', 'false', 'no', 'off'].includes(value.trim().toLowerCase()); } +export function isEnabledUnlessBlankOrFalse(value?: string): boolean { + if (value == null) { + return true; + } + + const normalized = value.trim().toLowerCase(); + return normalized !== '' && !['0', 'false', 'no', 'off'].includes(normalized); +} + export function toBasicAuthorization(publicKey: string, secretKey: string): string { return `Basic ${Buffer.from(`${publicKey}:${secretKey}`).toString('base64')}`; }