feat: fan out Langfuse feedback scores

This commit is contained in:
Ravi Kumar L 2026-06-21 01:40:13 +02:00
parent 45ec145029
commit 24a90392f5
7 changed files with 548 additions and 107 deletions

View file

@ -9,7 +9,7 @@ const {
traceIdForMessage,
} = require('@librechat/api');
const { findAllArtifacts, replaceArtifactContent } = require('~/server/services/Artifacts/update');
const { requireJwtAuth, validateMessageReq } = require('~/server/middleware');
const { requireJwtAuth, validateMessageReq, configMiddleware } = require('~/server/middleware');
const db = require('~/models');
const router = express.Router();
@ -382,49 +382,56 @@ router.put('/:conversationId/:messageId', validateMessageReq, async (req, res) =
}
});
router.put('/:conversationId/:messageId/feedback', validateMessageReq, async (req, res) => {
try {
const { conversationId, messageId } = req.params;
const { feedback } = req.body;
router.put(
'/:conversationId/:messageId/feedback',
validateMessageReq,
configMiddleware,
async (req, res) => {
try {
const { conversationId, messageId } = req.params;
const { feedback } = req.body;
const updatedMessage = await db.updateMessage(
req?.user?.id,
{
messageId,
feedback: feedback || null,
},
{ context: 'updateFeedback' },
);
// Best-effort: Assistants messages do not have deterministic AgentRun traces.
if (!isAssistantsEndpoint(updatedMessage.endpoint)) {
sendFeedbackScore({
traceId: traceIdForMessage(messageId),
feedback: updatedMessage.feedback,
metadata: {
messageId: updatedMessage.messageId ?? messageId,
parentMessageId: updatedMessage.parentMessageId,
conversationId: updatedMessage.conversationId ?? conversationId,
sessionId: updatedMessage.conversationId ?? conversationId,
userId: req?.user?.id,
endpoint: updatedMessage.endpoint,
sender: updatedMessage.sender,
isCreatedByUser: updatedMessage.isCreatedByUser,
tokenCount: updatedMessage.tokenCount,
const updatedMessage = await db.updateMessage(
req?.user?.id,
{
messageId,
feedback: feedback || null,
},
}).catch((err) => logger.error('[langfuse] feedback score failed:', err));
}
{ context: 'updateFeedback' },
);
res.json({
messageId,
conversationId,
feedback: updatedMessage.feedback,
});
} catch (error) {
logger.error('Error updating message feedback:', error);
res.status(500).json({ error: 'Failed to update feedback' });
}
});
// Best-effort: Assistants messages do not have deterministic AgentRun traces.
if (!isAssistantsEndpoint(updatedMessage.endpoint)) {
sendFeedbackScore({
traceId: traceIdForMessage(messageId),
feedback: updatedMessage.feedback,
appConfig: req.config,
metadata: {
messageId: updatedMessage.messageId ?? messageId,
parentMessageId: updatedMessage.parentMessageId,
conversationId: updatedMessage.conversationId ?? conversationId,
sessionId: updatedMessage.conversationId ?? conversationId,
userId: req?.user?.id,
tenantId: req?.user?.tenantId,
endpoint: updatedMessage.endpoint,
sender: updatedMessage.sender,
isCreatedByUser: updatedMessage.isCreatedByUser,
tokenCount: updatedMessage.tokenCount,
},
}).catch((err) => logger.error('[langfuse] feedback score failed:', err));
}
res.json({
messageId,
conversationId,
feedback: updatedMessage.feedback,
});
} catch (error) {
logger.error('Error updating message feedback:', error);
res.status(500).json({ error: 'Failed to update feedback' });
}
},
);
router.delete('/:conversationId/:messageId', validateMessageReq, async (req, res) => {
try {

View file

@ -42,6 +42,7 @@ function normalizeTenantConfigs(value) {
publicKey,
secretKey,
baseUrl: normalizeString(config.baseUrl ?? config.base_url),
fanoutBaseUrl: normalizeString(config.fanoutBaseUrl ?? config.fanout_base_url),
};
});
}
@ -70,6 +71,7 @@ async function patchTenantLangfuseConfig({ tenantId, langfuse, patchConfigFields
}
mongoose = require('mongoose');
require('@librechat/data-schemas').createModels(mongoose);
const connect = require('./connect');
const { patchConfigFields } = require('~/models');
@ -80,14 +82,14 @@ async function patchTenantLangfuseConfig({ tenantId, langfuse, patchConfigFields
await connect();
for (const config of tenantConfigs) {
const fanoutBaseUrl = config.baseUrl ?? defaultFanoutBaseUrl;
const langfuse = {
...(config.enabled === false ? { enabled: false } : {}),
...(config.publicKey ? { publicKey: config.publicKey } : {}),
...(config.secretKey ? { secretKey: config.secretKey } : {}),
...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
fanout: {
enabled: config.enabled !== false,
baseUrl: fanoutBaseUrl,
baseUrl: config.fanoutBaseUrl ?? defaultFanoutBaseUrl,
},
};
await patchTenantLangfuseConfig({ tenantId: config.tenantId, langfuse, patchConfigFields });
@ -95,6 +97,7 @@ async function patchTenantLangfuseConfig({ tenantId, langfuse, patchConfigFields
}
await mongoose.disconnect();
process.exit(0);
})().catch(async (error) => {
console.error(error);
if (mongoose?.connection?.readyState) {

View file

@ -1,12 +1,9 @@
import type { AppConfig } from '@librechat/data-schemas';
import type { RunConfig } from '@librechat/agents';
import { normalizeString } from './utils';
type LangfuseRunConfig = NonNullable<RunConfig['langfuse']>;
function normalizeString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
}
function mergeTraceMetadata(
base: LangfuseRunConfig['metadata'],
tenantId?: string,

View file

@ -0,0 +1,110 @@
import type { AppConfig } from '@librechat/data-schemas';
import { normalizeString, toBasicAuthorization } from './utils';
const DEFAULT_BASE_URL = 'https://cloud.langfuse.com';
export type LangfuseScoreDestination = {
name: 'central' | 'tenant';
baseUrl: string;
authorization: string;
};
function isFalseEnv(value?: string): boolean {
return value != null && ['0', 'false', 'no', 'off'].includes(value.trim().toLowerCase());
}
function isSampleRateEnabled(value?: string): boolean {
if (value == null || value.trim() === '') {
return true;
}
const parsed = Number(value);
return !Number.isFinite(parsed) || parsed !== 0;
}
function isTracingEnabled(): boolean {
return (
!isFalseEnv(process.env.LANGFUSE_TRACING_ENABLED) &&
isSampleRateEnabled(process.env.LANGFUSE_SAMPLE_RATE)
);
}
function getLegacyBaseUrl(): string {
return (
normalizeString(process.env.LANGFUSE_BASE_URL) ??
normalizeString(process.env.LANGFUSE_HOST) ??
normalizeString(process.env.LANGFUSE_BASEURL) ??
DEFAULT_BASE_URL
);
}
function getCentralScoreDestination(): LangfuseScoreDestination | undefined {
if (!isTracingEnabled()) {
return undefined;
}
const fanoutAuthorization = normalizeString(process.env.LANGFUSE_FANOUT_CENTRAL_AUTH_HEADER);
if (fanoutAuthorization) {
return {
name: 'central',
baseUrl: normalizeString(process.env.LANGFUSE_FANOUT_CENTRAL_BASE_URL) ?? DEFAULT_BASE_URL,
authorization: fanoutAuthorization,
};
}
const publicKey = normalizeString(process.env.LANGFUSE_PUBLIC_KEY);
const secretKey = normalizeString(process.env.LANGFUSE_SECRET_KEY);
if (!publicKey || !secretKey) {
return undefined;
}
return {
name: 'central',
baseUrl: getLegacyBaseUrl(),
authorization: toBasicAuthorization(publicKey, secretKey),
};
}
function getTenantScoreDestination(appConfig?: AppConfig): LangfuseScoreDestination | undefined {
if (!isTracingEnabled()) {
return undefined;
}
const config = appConfig?.langfuse;
if (config?.enabled === false) {
return undefined;
}
const publicKey = normalizeString(config?.publicKey);
const secretKey = normalizeString(config?.secretKey);
if (!publicKey || !secretKey) {
return undefined;
}
return {
name: 'tenant',
baseUrl:
normalizeString(config?.baseUrl) ??
normalizeString(process.env.LANGFUSE_FANOUT_TENANT_BASE_URL) ??
DEFAULT_BASE_URL,
authorization: toBasicAuthorization(publicKey, secretKey),
};
}
/**
* Score fanout uses Langfuse's direct REST API. Trace fanout may use the OTLP
* collector via appConfig.langfuse.fanout/base LANGFUSE_FANOUT_BASE_URL.
*/
export function getScoreDestinations(appConfig?: AppConfig): LangfuseScoreDestination[] {
const destinations = [getCentralScoreDestination(), getTenantScoreDestination(appConfig)].filter(
(destination): destination is LangfuseScoreDestination => Boolean(destination),
);
const seen = new Set<string>();
return destinations.filter((destination) => {
const key = `${destination.baseUrl}\n${destination.authorization}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}

View file

@ -1,8 +1,11 @@
import type { AppConfig } from '@librechat/data-schemas';
jest.mock(
'@librechat/data-schemas',
() => ({
logger: {
debug: jest.fn(),
error: jest.fn(),
},
}),
{ virtual: true },
@ -17,6 +20,9 @@ const langfuseEnvKeys = [
'LANGFUSE_TRACING_ENABLED',
'LANGFUSE_SAMPLE_RATE',
'LANGFUSE_TRACING_ENVIRONMENT',
'LANGFUSE_FANOUT_CENTRAL_AUTH_HEADER',
'LANGFUSE_FANOUT_CENTRAL_BASE_URL',
'LANGFUSE_FANOUT_TENANT_BASE_URL',
];
let fetchMock: jest.SpiedFunction<typeof fetch>;
@ -40,6 +46,17 @@ function getFetchMock(): jest.SpiedFunction<typeof fetch> {
return fetchMock;
}
function getTenantAuthorization(
publicKey = 'tenant-public-key',
secretKey = 'tenant-secret-key',
): string {
return `Basic ${Buffer.from(`${publicKey}:${secretKey}`).toString('base64')}`;
}
function appConfigWithLangfuse(langfuse: AppConfig['langfuse']): AppConfig {
return { langfuse } as AppConfig;
}
describe('Langfuse feedback scores', () => {
beforeEach(() => {
clearLangfuseEnv();
@ -120,6 +137,253 @@ describe('Langfuse feedback scores', () => {
);
});
it('posts feedback scores to central fanout and tenant Langfuse projects', 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_BASE_URL = 'http://tenant-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsDown', tag: 'wrong' },
metadata: { tenantId: 'tenant-a' },
appConfig: {
langfuse: {
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
},
} as AppConfig,
});
expect(getFetchMock()).toHaveBeenCalledTimes(2);
expect(getFetchMock()).toHaveBeenNthCalledWith(
1,
'http://central-langfuse:3000/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Basic central-auth',
}),
}),
);
expect(getFetchMock()).toHaveBeenNthCalledWith(
2,
'http://tenant-langfuse:3000/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: getTenantAuthorization(),
}),
}),
);
const [, tenantInit] = getFetchMock().mock.calls[1];
expect(JSON.parse(tenantInit?.body as string)).toMatchObject({
id: 'feedback-trace-id',
traceId: 'trace-id',
name: 'user-feedback',
value: 0,
metadata: {
rating: 'thumbsDown',
tag: 'wrong',
tenantId: 'tenant-a',
},
});
});
it('deletes feedback scores from central and tenant Langfuse projects', 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_BASE_URL = 'http://tenant-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: null,
appConfig: {
langfuse: {
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
},
} as AppConfig,
});
expect(getFetchMock()).toHaveBeenCalledTimes(2);
expect(getFetchMock()).toHaveBeenNthCalledWith(
1,
'http://central-langfuse:3000/api/public/scores/feedback-trace-id',
expect.objectContaining({
method: 'DELETE',
headers: { Authorization: 'Basic central-auth' },
}),
);
expect(getFetchMock()).toHaveBeenNthCalledWith(
2,
'http://tenant-langfuse:3000/api/public/scores/feedback-trace-id',
expect.objectContaining({
method: 'DELETE',
headers: {
Authorization: getTenantAuthorization(),
},
}),
);
});
it('posts feedback scores to tenant Langfuse when no central destination is configured', async () => {
delete process.env.LANGFUSE_PUBLIC_KEY;
delete process.env.LANGFUSE_SECRET_KEY;
process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'http://tenant-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
}),
});
expect(getFetchMock()).toHaveBeenCalledTimes(1);
expect(getFetchMock()).toHaveBeenCalledWith(
'http://tenant-langfuse:3000/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: getTenantAuthorization() }),
}),
);
});
it('skips tenant scores when tenant Langfuse is disabled but keeps central scores', async () => {
process.env.LANGFUSE_FANOUT_CENTRAL_AUTH_HEADER = 'Basic central-auth';
process.env.LANGFUSE_FANOUT_CENTRAL_BASE_URL = 'http://central-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
enabled: false,
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
}),
});
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';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'http://shared-langfuse:3000',
}),
});
expect(getFetchMock()).toHaveBeenCalledTimes(1);
expect(getFetchMock()).toHaveBeenCalledWith(
'http://shared-langfuse:3000/api/public/scores',
expect.objectContaining({ method: 'POST' }),
);
});
it('attempts every destination and reports partial feedback score failures', 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_BASE_URL = 'http://tenant-langfuse:3000';
fetchMock
.mockResolvedValueOnce(new Response('central down', { status: 500 }))
.mockResolvedValueOnce(new Response(null, { status: 200 }));
const { sendFeedbackScore } = await loadFeedback();
const { logger } = await import('@librechat/data-schemas');
await expect(
sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
}),
}),
).rejects.toThrow('langfuse central score create failed: score create 500: central down');
expect(getFetchMock()).toHaveBeenCalledTimes(2);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('[langfuse] central feedback score send failed'),
expect.any(Error),
);
});
it('reports tenant feedback score failures after central succeeds', 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_BASE_URL = 'http://tenant-langfuse:3000';
fetchMock
.mockResolvedValueOnce(new Response(null, { status: 200 }))
.mockResolvedValueOnce(new Response('tenant down', { status: 503 }));
const { sendFeedbackScore } = await loadFeedback();
const { logger } = await import('@librechat/data-schemas');
await expect(
sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
}),
}),
).rejects.toThrow('langfuse tenant score create failed: score create 503: tenant down');
expect(getFetchMock()).toHaveBeenCalledTimes(2);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('[langfuse] central feedback score sent'),
);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('[langfuse] tenant feedback score send failed'),
expect.any(Error),
);
});
it('aggregates feedback score failures when every destination fails', 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_BASE_URL = 'http://tenant-langfuse:3000';
fetchMock
.mockResolvedValueOnce(new Response('central down', { status: 500 }))
.mockResolvedValueOnce(new Response('tenant down', { status: 503 }));
const { sendFeedbackScore } = await loadFeedback();
await expect(
sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
}),
}),
).rejects.toThrow(
'langfuse central score create failed: score create 500: central down; langfuse tenant score create failed: score create 503: tenant down',
);
expect(getFetchMock()).toHaveBeenCalledTimes(2);
});
it('skips scores when Langfuse tracing is disabled', async () => {
process.env.LANGFUSE_TRACING_ENABLED = 'false';
const { sendFeedbackScore } = await loadFeedback();

View file

@ -1,4 +1,6 @@
import { logger } from '@librechat/data-schemas';
import type { AppConfig } from '@librechat/data-schemas';
import { getScoreDestinations, type LangfuseScoreDestination } from './destinations';
export type LangfuseFeedback = {
rating?: 'thumbsUp' | 'thumbsDown';
@ -13,39 +15,23 @@ export type SendFeedbackScoreParams = {
feedback?: LangfuseFeedback | null;
metadata?: LangfuseFeedbackMetadata;
observationId?: string;
appConfig?: AppConfig;
};
const DEFAULT_BASE_URL = 'https://cloud.langfuse.com';
const BASE =
process.env.LANGFUSE_BASE_URL ??
process.env.LANGFUSE_HOST ??
process.env.LANGFUSE_BASEURL ??
DEFAULT_BASE_URL;
function isFalseEnv(value?: string): boolean {
return value != null && ['0', 'false', 'no', 'off'].includes(value.trim().toLowerCase());
}
function isSampleRateEnabled(value?: string): boolean {
if (value == null || value.trim() === '') {
return true;
}
const parsed = Number(value);
return !Number.isFinite(parsed) || parsed !== 0;
}
const ENABLED =
Boolean(process.env.LANGFUSE_PUBLIC_KEY && process.env.LANGFUSE_SECRET_KEY) &&
!isFalseEnv(process.env.LANGFUSE_TRACING_ENABLED) &&
isSampleRateEnabled(process.env.LANGFUSE_SAMPLE_RATE);
const AUTHORIZATION = ENABLED
? 'Basic ' +
Buffer.from(`${process.env.LANGFUSE_PUBLIC_KEY}:${process.env.LANGFUSE_SECRET_KEY}`).toString(
'base64',
)
: undefined;
const ENVIRONMENT = process.env.LANGFUSE_TRACING_ENVIRONMENT;
type LangfuseScorePayload = {
id: string;
traceId: string;
name: 'user-feedback';
value: number;
dataType: 'BOOLEAN';
comment?: string;
metadata: Record<string, string | number | boolean>;
observationId?: string;
environment?: string;
};
function cleanMetadata(
metadata: LangfuseFeedbackMetadata,
): Record<string, string | number | boolean> {
@ -61,30 +47,47 @@ function cleanMetadata(
);
}
export async function sendFeedbackScore({
async function deleteScore(destination: LangfuseScoreDestination, scoreId: string): Promise<void> {
const res = await fetch(
`${destination.baseUrl}/api/public/scores/${encodeURIComponent(scoreId)}`,
{
method: 'DELETE',
headers: { Authorization: destination.authorization },
},
);
if (!res.ok && res.status !== 404) {
throw new Error(`score delete ${res.status}: ${await res.text()}`);
}
}
async function createScore(
destination: LangfuseScoreDestination,
payload: LangfuseScorePayload,
): Promise<void> {
const res = await fetch(`${destination.baseUrl}/api/public/scores`, {
method: 'POST',
headers: { Authorization: destination.authorization, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
throw new Error(`score create ${res.status}: ${await res.text()}`);
}
}
function buildScorePayload({
scoreId,
traceId,
feedback,
metadata = {},
metadata,
observationId,
}: SendFeedbackScoreParams): Promise<void> {
if (!ENABLED || !AUTHORIZATION || !traceId) {
return;
}
const scoreId = `feedback-${traceId}`;
if (!feedback?.rating) {
const res = await fetch(`${BASE}/api/public/scores/${encodeURIComponent(scoreId)}`, {
method: 'DELETE',
headers: { Authorization: AUTHORIZATION },
});
if (!res.ok && res.status !== 404) {
throw new Error(`langfuse score delete ${res.status}: ${await res.text()}`);
}
return;
}
const body = {
}: {
scoreId: string;
traceId: string;
feedback: LangfuseFeedback;
metadata: LangfuseFeedbackMetadata;
observationId?: string;
}): LangfuseScorePayload {
return {
id: scoreId,
traceId,
name: 'user-feedback',
@ -95,14 +98,64 @@ export async function sendFeedbackScore({
...(observationId ? { observationId } : {}),
...(ENVIRONMENT ? { environment: ENVIRONMENT } : {}),
};
const res = await fetch(`${BASE}/api/public/scores`, {
method: 'POST',
headers: { Authorization: AUTHORIZATION, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`langfuse score create ${res.status}: ${await res.text()}`);
}
logger.debug(`[langfuse] feedback score sent for trace ${traceId} (${feedback.rating})`);
}
export async function sendFeedbackScore({
traceId,
feedback,
metadata = {},
observationId,
appConfig,
}: SendFeedbackScoreParams): Promise<void> {
if (!traceId) {
return;
}
const destinations = getScoreDestinations(appConfig);
if (destinations.length === 0) {
return;
}
const scoreId = `feedback-${traceId}`;
const payload = feedback?.rating
? buildScorePayload({ scoreId, traceId, feedback, metadata, observationId })
: undefined;
const results = await Promise.allSettled(
destinations.map((destination) =>
payload ? createScore(destination, payload) : deleteScore(destination, scoreId),
),
);
const failures: string[] = [];
results.forEach((result, index) => {
const destination = destinations[index];
if (!destination) {
return;
}
if (result.status === 'fulfilled') {
logger.debug(
`[langfuse] ${destination.name} feedback score ${
payload ? 'sent' : 'deleted'
} for trace ${traceId} (${feedback?.rating ?? 'none'})`,
);
return;
}
logger.error(
`[langfuse] ${destination.name} feedback score ${
payload ? 'send' : 'delete'
} failed for trace ${traceId}:`,
result.reason,
);
failures.push(
`langfuse ${destination.name} score ${payload ? 'create' : 'delete'} failed: ${
result.reason instanceof Error ? result.reason.message : String(result.reason)
}`,
);
});
if (failures.length > 0) {
throw new Error(failures.join('; '));
}
}

View file

@ -0,0 +1,7 @@
export function normalizeString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
}
export function toBasicAuthorization(publicKey: string, secretKey: string): string {
return `Basic ${Buffer.from(`${publicKey}:${secretKey}`).toString('base64')}`;
}