fix(langfuse): preserve trace sampling for feedback

This commit is contained in:
Ravi Kumar L 2026-07-29 20:20:50 +02:00
parent 5ad650696d
commit 35efbcc982
10 changed files with 85 additions and 2 deletions

View file

@ -12,6 +12,8 @@ const {
encodeAndFormatAudios,
encodeAndFormatVideos,
encodeAndFormatDocuments,
isLangfuseTraceSampled,
traceIdForMessage,
} = require('@librechat/api');
const {
Constants,
@ -716,6 +718,9 @@ class BaseClient {
conversationId,
parentMessageId: userMessage.messageId,
isCreatedByUser: false,
...(isAgentsEndpoint(this.options.endpoint) && {
langfuseSampled: isLangfuseTraceSampled(traceIdForMessage(responseMessageId)),
}),
isEdited,
model: this.getResponseModel(),
sender: this.sender,

View file

@ -746,6 +746,30 @@ describe('BaseClient', () => {
);
});
test('persists the generation-time Langfuse sampling decision for agent responses', async () => {
const previousSampleRate = process.env.LANGFUSE_SAMPLE_RATE;
process.env.LANGFUSE_SAMPLE_RATE = '0';
TestClient.options.endpoint = 'agents';
const saveSpy = jest.spyOn(TestClient, 'saveMessageToDatabase');
try {
const response = await TestClient.sendMessage('Hello, world!', { user: {} });
expect(response.langfuseSampled).toBe(false);
expect(saveSpy).toHaveBeenCalledWith(
expect.objectContaining({ langfuseSampled: false }),
expect.any(Object),
expect.any(Object),
);
} finally {
if (previousSampleRate == null) {
delete process.env.LANGFUSE_SAMPLE_RATE;
} else {
process.env.LANGFUSE_SAMPLE_RATE = previousSampleRate;
}
}
});
test('should handle existing conversation when getConvo retrieves one', async () => {
const existingConvo = {
conversationId: 'existing-convo-id',

View file

@ -445,6 +445,7 @@ router.put(
if (!isAssistantsEndpoint(updatedMessage.endpoint)) {
sendFeedbackScore({
traceId: traceIdForMessage(messageId),
sampled: updatedMessage.langfuseSampled,
feedback: updatedMessage.feedback,
appConfig: req.config,
metadata: {

View file

@ -3,6 +3,7 @@ import {
hasLangfuseEnvCredentials,
isLangfuseFanoutEnabled,
isLangfuseTenantExportEnabled,
isLangfuseTracingEnabled,
isLangfuseTraceSampled,
usesLangfuseMultiTenantRouting,
} from './policy';
@ -106,8 +107,13 @@ function getConfiguredScoreDestination(
export function getScoreDestinations(
appConfig: AppConfig | undefined,
traceId: string,
sampled?: boolean,
): LangfuseScoreDestination[] {
if (!isLangfuseTraceSampled(traceId)) {
if (
!isLangfuseTracingEnabled() ||
sampled === false ||
(sampled == null && !isLangfuseTraceSampled(traceId))
) {
return [];
}

View file

@ -256,6 +256,32 @@ describe('Langfuse feedback scores', () => {
expect(getFetchMock()).not.toHaveBeenCalled();
});
it('preserves a sampled trace when the sample rate decreases', async () => {
process.env.LANGFUSE_SAMPLE_RATE = '0.1';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: '658f74b0a232417fc3e6e4d9ef5f563a',
sampled: true,
feedback: { rating: 'thumbsUp' },
});
expect(getFetchMock()).toHaveBeenCalledTimes(1);
});
it('preserves an excluded trace when the sample rate increases', async () => {
process.env.LANGFUSE_SAMPLE_RATE = '1';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: '86d413435f8b0d7f32d4d010ce769e2e',
sampled: false,
feedback: { rating: 'thumbsUp' },
});
expect(getFetchMock()).not.toHaveBeenCalled();
});
it('posts feedback scores to central fanout and tenant Langfuse projects', async () => {
enableTenantFanout();
delete process.env.TENANT_ISOLATION_STRICT;

View file

@ -12,6 +12,7 @@ export type LangfuseFeedbackMetadata = Record<string, string | number | boolean
export type SendFeedbackScoreParams = {
traceId: string;
sampled?: boolean;
feedback?: LangfuseFeedback | null;
metadata?: LangfuseFeedbackMetadata;
observationId?: string;
@ -102,6 +103,7 @@ function buildScorePayload({
export async function sendFeedbackScore({
traceId,
sampled,
feedback,
metadata = {},
observationId,
@ -111,7 +113,7 @@ export async function sendFeedbackScore({
return;
}
const destinations = getScoreDestinations(appConfig, traceId);
const destinations = getScoreDestinations(appConfig, traceId, sampled);
if (destinations.length === 0) {
return;
}

View file

@ -158,6 +158,20 @@ describe('Message Operations', () => {
expect(updatedMessage?.text).toBe('Updated text');
});
it('returns the generation-time Langfuse sampling decision with feedback updates', async () => {
await saveMessage(mockCtx, {
...mockMessageData,
langfuseSampled: true,
});
const result = await updateMessage(mockCtx.userId, {
messageId: 'msg123',
feedback: { rating: 'thumbsUp' },
});
expect(result?.langfuseSampled).toBe(true);
});
it('should throw an error if message is not found', async () => {
await expect(
updateMessage(mockCtx.userId, { messageId: 'nonexistent', text: 'Test' }),

View file

@ -295,6 +295,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
tokenCount: updatedMessage.tokenCount,
feedback: updatedMessage.feedback,
endpoint: updatedMessage.endpoint,
langfuseSampled: updatedMessage.langfuseSampled,
};
} catch (err) {
logger.error('Error updating message:', err);

View file

@ -97,6 +97,9 @@ const messageSchema: Schema<IMessage> = new Schema(
default: undefined,
required: false,
},
langfuseSampled: {
type: Boolean,
},
_meiliIndex: {
type: Boolean,
required: false,

View file

@ -27,6 +27,7 @@ export interface IMessage extends Document {
tag: TFeedbackTag | undefined;
text?: string;
};
langfuseSampled?: boolean;
_meiliIndex?: boolean;
files?: unknown[];
plugin?: {