📛 feat: Tag Langfuse Traces With Tenant ID (#13808)

* feat: tag Langfuse traces with tenant id

* fix: propagate tenant id to agent Langfuse config
This commit is contained in:
Ravi Kumar L 2026-06-18 02:27:55 +02:00 • committed by GitHub
parent 8628897c9c
commit 27b0782201
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 96 additions and 28 deletions

View file

@ -1327,6 +1327,7 @@ class AgentClient extends BaseClient {
customHandlers: this.options.eventHandlers,
requestBody: config.configurable.requestBody,
user: createSafeUser(this.options.req?.user),
tenantId: this.options.req?.user?.tenantId,
summarizationConfig: appConfig?.summarization,
appConfig,
tokenCounter,

View file

@ -743,6 +743,7 @@ const OpenAIChatCompletionController = async (req, res) => {
conversationId,
},
user: { id: userId },
tenantId: req.user?.tenantId,
/** Bills subagent child-run model calls (reported outside the
* streamEvents loop) into the same collectedUsage array. */
subagentUsageSink: createSubagentUsageSink(collectedUsage),

View file

@ -750,6 +750,7 @@ const createResponse = async (req, res) => {
conversationId,
},
user: { id: userId },
tenantId: req.user?.tenantId,
/** Bills subagent child-run model calls (reported outside the
* streamEvents loop) into the same collectedUsage array. */
subagentUsageSink: createSubagentUsageSink(collectedUsage),
@ -927,6 +928,7 @@ const createResponse = async (req, res) => {
conversationId,
},
user: { id: userId },
tenantId: req.user?.tenantId,
/** Bills subagent child-run model calls (reported outside the
* streamEvents loop) into the same collectedUsage array. */
subagentUsageSink: createSubagentUsageSink(collectedUsage),

View file

@ -1,12 +1,12 @@
import { logger } from '@librechat/data-schemas';
import type { AppConfig } from '@librechat/data-schemas';
import type { SummarizationConfig, TEndpoint } from 'librechat-data-provider';
import {
EModelEndpoint,
FileSources,
MAX_SUBAGENT_DEPTH,
MAX_SUBAGENT_RUN_CONFIGS,
} from 'librechat-data-provider';
import type { SummarizationConfig, TEndpoint } from 'librechat-data-provider';
import type { AppConfig } from '@librechat/data-schemas';
import { createRun } from '~/agents/run';
// Mock winston logger — `format` must be callable so @librechat/data-schemas
@ -1103,39 +1103,88 @@ describe('subagentConfigs', () => {
});
});
/**
* Captures the top-level `Run.create` config (not just agentInputs) so tests
* can assert presence/absence of run-level options.
*/
async function callAndCaptureRunConfig({
overrides,
user,
tenantId,
}: {
overrides?: Record<string, unknown>;
user?: Record<string, unknown>;
tenantId?: string;
} = {}): Promise<Record<string, unknown>> {
const agents = [makeAgent(overrides)];
const signal = new AbortController().signal;
await createRun({
agents: agents as never,
signal,
streaming: true,
streamUsage: true,
user: user as never,
tenantId,
});
const createMock = Run.create as jest.Mock;
expect(createMock).toHaveBeenCalledTimes(1);
return createMock.mock.calls[0][0] as Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// Suite: Langfuse run config
// ---------------------------------------------------------------------------
describe('Langfuse run config', () => {
it('passes deterministic Langfuse trace config without tenant metadata by default', async () => {
const callArgs = await callAndCaptureRunConfig();
expect(callArgs.langfuse).toEqual({ deterministicTraceId: true });
});
it('adds the explicit request tenant id to Langfuse trace metadata and tags', async () => {
const callArgs = await callAndCaptureRunConfig({
user: {
id: 'user-1',
},
tenantId: 'tenant-1',
});
expect(callArgs.langfuse).toEqual({
deterministicTraceId: true,
metadata: { 'librechat.tenant.id': 'tenant-1' },
tags: ['tenant:tenant-1'],
});
});
it('falls back to a full user tenant id for direct createRun callers', async () => {
const callArgs = await callAndCaptureRunConfig({
user: {
tenantId: 'tenant-2',
},
});
expect(callArgs.langfuse).toEqual({
deterministicTraceId: true,
metadata: { 'librechat.tenant.id': 'tenant-2' },
tags: ['tenant:tenant-2'],
});
});
});
// ---------------------------------------------------------------------------
// Suite: toolOutputReferences gating
// ---------------------------------------------------------------------------
describe('toolOutputReferences gating', () => {
/**
* Captures the top-level `Run.create` config (not just agentInputs) so the
* test can assert presence/absence of the `toolOutputReferences` key.
*/
async function callAndCaptureRunConfig(
overrides?: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const agents = [makeAgent(overrides)];
const signal = new AbortController().signal;
await createRun({
agents: agents as never,
signal,
streaming: true,
streamUsage: true,
});
const createMock = Run.create as jest.Mock;
expect(createMock).toHaveBeenCalledTimes(1);
return createMock.mock.calls[0][0] as Record<string, unknown>;
}
it('passes toolOutputReferences when agent has codeEnvAvailable=true', async () => {
const callArgs = await callAndCaptureRunConfig({ codeEnvAvailable: true });
const callArgs = await callAndCaptureRunConfig({
overrides: { codeEnvAvailable: true },
});
expect(callArgs.toolOutputReferences).toEqual({ enabled: true });
});
it('omits toolOutputReferences when codeEnvAvailable is false', async () => {
const callArgs = await callAndCaptureRunConfig({ codeEnvAvailable: false });
const callArgs = await callAndCaptureRunConfig({
overrides: { codeEnvAvailable: false },
});
expect(callArgs).not.toHaveProperty('toolOutputReferences');
});

View file

@ -31,6 +31,7 @@ import type {
ToolCall,
} from './types';
import type { OpenAIStreamHandlerConfig, EventHandler } from './handlers';
import type { ToolExecuteOptions } from '../handlers';
import {
createOpenAIContentAggregator,
createOpenAIStreamTracker,
@ -40,7 +41,6 @@ import {
writeSSE,
} from './handlers';
import { createSafeUser } from '~/utils';
import type { ToolExecuteOptions } from '../handlers';
/**
* Dependencies for the chat completion service
@ -175,6 +175,7 @@ type CreateRunFn = (params: {
customHandlers: Record<string, EventHandler>;
requestBody: Record<string, unknown>;
user: Record<string, unknown>;
tenantId?: string;
tokenCounter?: (message: unknown) => number;
}) => Promise<{
Graph?: unknown;
@ -524,6 +525,7 @@ export async function createAgentChatCompletion(
conversationId,
},
user: safeUser,
tenantId: typeof reqUser?.tenantId === 'string' ? reqUser.tenantId : undefined,
});
if (run) {

View file

@ -794,6 +794,17 @@ function buildSubagentConfigs(
return configs;
}
function buildLangfuseConfig(tenantIdInput?: unknown) {
const tenantId = typeof tenantIdInput === 'string' ? tenantIdInput.trim() : '';
return {
deterministicTraceId: true,
...(tenantId !== '' && {
metadata: { 'librechat.tenant.id': tenantId },
tags: [`tenant:${tenantId}`],
}),
};
}
/**
* Creates a new Run instance with custom handlers and configuration.
*
@ -816,6 +827,7 @@ export async function createRun({
messages,
requestBody,
user,
tenantId,
tokenCounter,
customHandlers,
indexTokenCountMap,
@ -835,6 +847,7 @@ export async function createRun({
streamUsage?: boolean;
requestBody?: t.RequestBody;
user?: IUser;
tenantId?: string;
/** Message history for extracting previously discovered tools */
messages?: BaseMessage[];
summarizationConfig?: SummarizationConfig;
@ -1097,7 +1110,7 @@ export async function createRun({
// feedback can be scored against the trace without a lookup (see the
// feedback route in api/server/routes/messages.js). No-op unless Langfuse
// tracing is enabled. Requires @librechat/agents >= 3.2.21.
langfuse: { deterministicTraceId: true },
langfuse: buildLangfuseConfig(tenantId ?? user?.tenantId),
...(enableToolOutputReferences && {
toolOutputReferences: { enabled: true },
}),