feat(langfuse): refine tenant connection controls

This commit is contained in:
Ravi Kumar L 2026-07-08 16:20:41 +02:00
parent 26f6c70936
commit 30214e0302
13 changed files with 777 additions and 365 deletions

View file

@ -277,7 +277,9 @@ router.get('/', async function (req, res) {
conversationImportMaxFileSize: process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES
? parseInt(process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES, 10)
: 0,
langfuseFanoutEnabled: /^(true|1)$/i.test((process.env.LANGFUSE_FANOUT_ENABLED ?? '').trim()),
langfuseFanoutEnabled: /^(true|1|yes|on)$/i.test(
(process.env.LANGFUSE_FANOUT_ENABLED ?? '').trim(),
),
...(cloudFront ? { cloudFront } : {}),
...(rum ? { rum } : {}),
};

View file

@ -1,6 +1,11 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import {
Button,
CircleHelpIcon,
HoverCard,
HoverCardContent,
HoverCardPortal,
HoverCardTrigger,
Input,
Label,
SecretInput,
@ -8,12 +13,62 @@ import {
Switch,
useToastContext,
} from '@librechat/client';
import type { TLangfuseConnectionStatus } from 'librechat-data-provider';
import type { TranslationKeys } from '~/hooks';
import {
useGetLangfuseConnectionQuery,
useUpdateLangfuseConnectionMutation,
useTestLangfuseConnectionMutation,
} from '~/data-provider';
import { useLocalize } from '~/hooks';
import { ESide } from '~/common';
type ConnectionTestState = 'idle' | 'checking' | 'connected' | 'failed';
function getStoredConnectionTestKey(status?: TLangfuseConnectionStatus): string | undefined {
if (status?.configured !== true || !status.destination || !status.publicKey) {
return undefined;
}
return [status.destination, status.publicKey, status.updatedAt ?? ''].join('\u0000');
}
function getConnectionStatusLabelKey(state: ConnectionTestState): TranslationKeys {
switch (state) {
case 'checking':
return 'com_ui_langfuse_status_checking';
case 'connected':
return 'com_ui_langfuse_status_connected';
case 'failed':
return 'com_ui_langfuse_status_failed';
case 'idle':
default:
return 'com_ui_langfuse_status_not_configured';
}
}
function getConnectionStatusDotClass(state: ConnectionTestState): string {
switch (state) {
case 'connected':
return 'bg-green-500';
case 'failed':
return 'bg-red-500';
case 'checking':
return 'bg-yellow-500';
case 'idle':
default:
return 'border border-border-medium';
}
}
function getDisplayPublicKey(publicKey: string): string {
const trimmedPublicKey = publicKey.trim();
if (trimmedPublicKey.length <= 12) {
return trimmedPublicKey;
}
return `${trimmedPublicKey.slice(0, 6)}...${trimmedPublicKey.slice(-4)}`;
}
export default function LangfuseConnection() {
const localize = useLocalize();
@ -22,153 +77,326 @@ export default function LangfuseConnection() {
const updateMutation = useUpdateLangfuseConnectionMutation();
const testMutation = useTestLangfuseConnectionMutation();
const [connectionStatus, setConnectionStatus] = useState<TLangfuseConnectionStatus>();
const [enabled, setEnabled] = useState(false);
const [baseUrl, setBaseUrl] = useState('');
const [destination, setDestination] = useState('');
const [publicKey, setPublicKey] = useState('');
const [secretKey, setSecretKey] = useState('');
const [isEditingPublicKey, setIsEditingPublicKey] = useState(false);
const [isEditingSecret, setIsEditingSecret] = useState(false);
const [connectionTestState, setConnectionTestState] = useState<ConnectionTestState>('idle');
const [connectionTestMessage, setConnectionTestMessage] = useState('');
const autoTestedConnectionRef = useRef<string>();
useEffect(() => {
if (!status) {
return;
}
setEnabled(status.enabled === true);
setBaseUrl(status.baseUrl ?? '');
setPublicKey(status.publicKey ?? '');
setConnectionStatus(status);
}, [status]);
const secretConfigured = status?.configured === true;
const trimmedBaseUrl = baseUrl.trim();
useEffect(() => {
if (!connectionStatus) {
return;
}
setEnabled(connectionStatus.enabled === true);
const availableDestinations = connectionStatus.destinations ?? [];
const storedDestination = availableDestinations.some(
(option) => option.key === connectionStatus.destination,
)
? connectionStatus.destination
: undefined;
setDestination(storedDestination ?? '');
setPublicKey(connectionStatus.publicKey ?? '');
}, [connectionStatus]);
const secretConfigured = connectionStatus?.configured === true;
const destinations = connectionStatus?.destinations ?? [];
const selectedDestination = destinations.find((option) => option.key === destination);
const trimmedPublicKey = publicKey.trim();
const trimmedSecretKey = secretKey.trim();
const publicKeyInputVisible = !secretConfigured || isEditingPublicKey;
const secretInputVisible = !secretConfigured || isEditingSecret;
const displayPublicKey = getDisplayPublicKey(publicKey);
const canSubmit =
trimmedBaseUrl !== '' &&
trimmedPublicKey !== '' &&
(trimmedSecretKey !== '' || secretConfigured);
destination !== '' &&
(!publicKeyInputVisible || trimmedPublicKey !== '') &&
(!secretInputVisible || trimmedSecretKey !== '');
const handleSave = () => {
updateMutation.mutate(
useEffect(() => {
const storedConnectionTestKey = getStoredConnectionTestKey(connectionStatus);
if (!connectionStatus || !storedConnectionTestKey) {
setConnectionTestState('idle');
setConnectionTestMessage('');
return;
}
if (autoTestedConnectionRef.current === storedConnectionTestKey) {
return;
}
autoTestedConnectionRef.current = storedConnectionTestKey;
setConnectionTestState('checking');
testMutation.mutate(
{
enabled,
baseUrl: trimmedBaseUrl,
publicKey: trimmedPublicKey,
...(trimmedSecretKey ? { secretKey: trimmedSecretKey } : {}),
destination: connectionStatus.destination ?? '',
publicKey: connectionStatus.publicKey ?? '',
},
{
onSuccess: () => {
onSuccess: (result) => {
setConnectionTestState(result.success ? 'connected' : 'failed');
setConnectionTestMessage(result.success ? '' : (result.message ?? ''));
},
onError: () => {
setConnectionTestState('failed');
setConnectionTestMessage(localize('com_ui_langfuse_test_error'));
},
},
);
}, [connectionStatus, localize, testMutation]);
const connectionStatusLabel =
connectionTestState === 'failed' && connectionTestMessage !== ''
? connectionTestMessage
: localize(getConnectionStatusLabelKey(connectionTestState));
const connectionStatusDotClass = getConnectionStatusDotClass(connectionTestState);
const connectionStatusTextClass =
connectionTestState === 'failed' ? 'text-red-600 dark:text-red-400' : 'text-text-secondary';
const connectionStatusTitle =
connectionTestState === 'failed' ? localize('com_ui_langfuse_status_failed_hover') : undefined;
const handleSave = () => {
const payload = {
enabled,
destination,
publicKey: trimmedPublicKey,
...(trimmedSecretKey ? { secretKey: trimmedSecretKey } : {}),
};
const saveConnection = () => {
updateMutation.mutate(payload, {
onSuccess: (nextStatus) => {
autoTestedConnectionRef.current = getStoredConnectionTestKey(nextStatus);
setConnectionStatus(nextStatus);
setConnectionTestState(enabled ? 'connected' : 'idle');
setConnectionTestMessage('');
setSecretKey('');
setIsEditingPublicKey(false);
setIsEditingSecret(false);
showToast({ message: localize('com_ui_langfuse_saved'), status: 'success' });
},
onError: () =>
showToast({ message: localize('com_ui_langfuse_save_error'), status: 'error' }),
},
);
};
});
};
if (!enabled) {
saveConnection();
return;
}
const handleTest = () => {
testMutation.mutate(
{
baseUrl: trimmedBaseUrl,
destination,
publicKey: trimmedPublicKey,
...(trimmedSecretKey ? { secretKey: trimmedSecretKey } : {}),
},
{
onSuccess: (result) =>
showToast({
message: result.success
? localize('com_ui_langfuse_test_success')
: (result.message ?? localize('com_ui_langfuse_test_error')),
status: result.success ? 'success' : 'error',
}),
onError: () =>
showToast({ message: localize('com_ui_langfuse_test_error'), status: 'error' }),
onSuccess: (result) => {
if (!result.success) {
setConnectionTestState('failed');
setConnectionTestMessage(result.message ?? localize('com_ui_langfuse_test_error'));
showToast({
message: result.message ?? localize('com_ui_langfuse_test_error'),
status: 'error',
});
return;
}
setConnectionTestState('connected');
setConnectionTestMessage('');
saveConnection();
},
onError: () => {
setConnectionTestState('failed');
setConnectionTestMessage(localize('com_ui_langfuse_test_error'));
showToast({ message: localize('com_ui_langfuse_test_error'), status: 'error' });
},
},
);
};
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<div id="langfuse-enabled-label" className="font-medium">
{localize('com_ui_langfuse_title')}
</div>
<div className="mt-1 text-xs text-text-secondary">
{localize('com_ui_langfuse_description')}
</div>
</div>
<Switch
checked={enabled}
onCheckedChange={setEnabled}
aria-labelledby="langfuse-enabled-label"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="langfuse-base-url">{localize('com_ui_langfuse_base_url')}</Label>
<Input
id="langfuse-base-url"
value={baseUrl}
placeholder="https://cloud.langfuse.com"
onChange={(e) => setBaseUrl(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="langfuse-public-key">{localize('com_ui_langfuse_public_key')}</Label>
<Input
id="langfuse-public-key"
value={publicKey}
placeholder="pk-lf-..."
onChange={(e) => setPublicKey(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="langfuse-secret-key">
{localize('com_ui_langfuse_secret_key')}{' '}
<span className="sr-only">
({secretConfigured ? localize('com_ui_set') : localize('com_ui_unset')})
</span>
</Label>
<div
aria-hidden="true"
className="flex min-w-fit items-center gap-2 whitespace-nowrap rounded-full border border-border-light px-2 py-0.5 text-xs font-medium text-text-secondary"
>
<div
className={
secretConfigured
? 'h-1.5 w-1.5 rounded-full bg-green-500'
: 'h-1.5 w-1.5 rounded-full border border-border-medium'
}
<HoverCard openDelay={50}>
<div className="flex flex-col gap-2">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div id="langfuse-enabled-label" className="font-medium">
{localize('com_ui_langfuse_title')}
</div>
<div className="rounded-full border border-purple-600/40 bg-purple-500/10 px-2 py-0.5 text-xs font-medium text-purple-700 hover:bg-purple-700/10 dark:text-purple-400">
{localize('com_ui_beta')}
</div>
<HoverCardTrigger>
<CircleHelpIcon className="h-4 w-4 text-text-tertiary" />
</HoverCardTrigger>
</div>
<div className="mt-1 max-w-md text-xs text-text-secondary">
{localize('com_ui_langfuse_description')}
</div>
</div>
<Switch
checked={enabled}
onCheckedChange={setEnabled}
aria-labelledby="langfuse-enabled-label"
/>
<span>{secretConfigured ? localize('com_ui_set') : localize('com_ui_unset')}</span>
</div>
<div
className={`flex items-center gap-1.5 text-xs ${connectionStatusTextClass}`}
aria-live="polite"
title={connectionStatusTitle}
>
{connectionTestState === 'checking' ? (
<Spinner className="h-3 w-3" />
) : (
<span className={`h-2 w-2 rounded-full ${connectionStatusDotClass}`} />
)}
<span>{connectionStatusLabel}</span>
</div>
</div>
<SecretInput
id="langfuse-secret-key"
autoComplete="new-password"
controlsOnHover
value={secretKey}
placeholder={secretConfigured ? localize('com_ui_langfuse_secret_key_set') : 'sk-lf-...'}
onChange={(e) => setSecretKey(e.target.value)}
/>
<span className="text-xs text-text-tertiary">
{localize('com_ui_langfuse_secret_key_hint')}
</span>
{secretConfigured && status?.secretKeyFingerprint != null && (
<span className="text-xs text-text-tertiary">
{localize('com_ui_langfuse_secret_key_fingerprint')}{' '}
<code>{status.secretKeyFingerprint}</code>
</span>
<HoverCardPortal>
<HoverCardContent side={ESide.Top} className="w-80">
<p className="text-sm text-text-secondary">{localize('com_ui_langfuse_beta_info')}</p>
</HoverCardContent>
</HoverCardPortal>
</HoverCard>
<div className="flex flex-col gap-1.5">
<Label htmlFor="langfuse-destination">{localize('com_ui_langfuse_destination')}</Label>
<select
id="langfuse-destination"
className="flex h-9 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm text-text-primary shadow-sm outline-none focus:ring-2 focus:ring-ring-primary disabled:cursor-not-allowed disabled:opacity-50"
value={destination}
disabled={destinations.length === 0}
onChange={(e) => setDestination(e.target.value)}
>
<option value="">{localize('com_ui_select')}</option>
{destinations.map((option) => (
<option key={option.key} value={option.key}>
{option.key} - {option.baseUrl}
</option>
))}
</select>
{selectedDestination != null && (
<span className="text-xs text-text-tertiary">{selectedDestination.baseUrl}</span>
)}
</div>
<div className="flex flex-col gap-1.5">
{publicKeyInputVisible ? (
<Label htmlFor="langfuse-public-key">{localize('com_ui_langfuse_public_key')}</Label>
) : (
<div className="text-sm font-medium text-text-primary">
{localize('com_ui_langfuse_public_key')}
</div>
)}
{secretConfigured && !isEditingPublicKey && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-border-light px-3 py-2">
<code className="min-w-0 truncate font-mono text-sm text-text-primary">
{displayPublicKey}
</code>
<Button
variant="outline"
className="h-8 shrink-0 px-3"
aria-label={`${localize('com_ui_edit')} ${localize('com_ui_langfuse_public_key')}`}
onClick={() => setIsEditingPublicKey(true)}
>
{localize('com_ui_edit')}
</Button>
</div>
)}
{publicKeyInputVisible && (
<div className="flex items-center gap-2">
<Input
id="langfuse-public-key"
value={publicKey}
placeholder="pk-lf-..."
className="flex-1"
onChange={(e) => setPublicKey(e.target.value)}
/>
{secretConfigured && (
<Button
variant="outline"
className="h-10 shrink-0 px-3"
onClick={() => {
setPublicKey(connectionStatus?.publicKey ?? '');
setIsEditingPublicKey(false);
}}
>
{localize('com_ui_cancel')}
</Button>
)}
</div>
)}
</div>
<div className="flex flex-col gap-1.5">
{secretInputVisible ? (
<Label htmlFor="langfuse-secret-key">{localize('com_ui_langfuse_secret_key')}</Label>
) : (
<div className="text-sm font-medium text-text-primary">
{localize('com_ui_langfuse_secret_key')}
</div>
)}
{secretConfigured && !isEditingSecret && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-border-light px-3 py-2">
<code className="min-w-0 truncate font-mono text-sm text-text-primary">
{connectionStatus?.displaySecretKey}
</code>
<Button
variant="outline"
className="h-8 shrink-0 px-3"
aria-label={`${localize('com_ui_edit')} ${localize('com_ui_langfuse_secret_key')}`}
onClick={() => setIsEditingSecret(true)}
>
{localize('com_ui_edit')}
</Button>
</div>
)}
{secretInputVisible && (
<div className="flex items-center gap-2">
<SecretInput
id="langfuse-secret-key"
autoComplete="new-password"
controlsOnHover
value={secretKey}
placeholder="sk-lf-..."
className="flex-1"
onChange={(e) => setSecretKey(e.target.value)}
/>
{secretConfigured && (
<Button
variant="outline"
className="h-10 shrink-0 px-3"
onClick={() => {
setSecretKey('');
setIsEditingSecret(false);
}}
>
{localize('com_ui_cancel')}
</Button>
)}
</div>
)}
</div>
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
disabled={!canSubmit || testMutation.isLoading}
onClick={handleTest}
disabled={!canSubmit || testMutation.isLoading || updateMutation.isLoading}
onClick={handleSave}
>
{testMutation.isLoading ? (
<span className="flex items-center gap-2">
@ -176,12 +404,9 @@ export default function LangfuseConnection() {
{localize('com_ui_langfuse_testing')}
</span>
) : (
localize('com_ui_langfuse_test')
localize('com_ui_save')
)}
</Button>
<Button disabled={!canSubmit || updateMutation.isLoading} onClick={handleSave}>
{localize('com_ui_save')}
</Button>
</div>
</div>
);

View file

@ -1,5 +1,5 @@
import userEvent from '@testing-library/user-event';
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import LangfuseConnection from '../LangfuseConnection';
const mockGet = jest.fn();
@ -25,42 +25,95 @@ beforeEach(() => {
mockGet.mockReset();
mockUpdate.mockReset();
mockTest.mockReset();
mockGet.mockReturnValue({ data: undefined });
mockTest.mockImplementation((_payload, options) => {
options?.onSuccess?.({ success: true });
});
mockGet.mockReturnValue({
data: {
configured: false,
enabled: false,
destinations: [
{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' },
{ key: 'us', baseUrl: 'https://us.cloud.langfuse.com' },
],
},
});
});
describe('LangfuseConnection', () => {
it('renders the connection form fields', () => {
render(<LangfuseConnection />);
expect(screen.getByLabelText('com_ui_langfuse_base_url')).toBeInTheDocument();
expect(screen.getByLabelText('com_ui_langfuse_destination')).toHaveValue('');
expect(screen.getByLabelText('com_ui_langfuse_public_key')).toBeInTheDocument();
expect(screen.getByLabelText(/com_ui_langfuse_secret_key/)).toBeInTheDocument();
expect(screen.queryByText('com_ui_langfuse_test')).not.toBeInTheDocument();
expect(screen.getByText('com_ui_langfuse_status_not_configured')).toBeInTheDocument();
expect(mockTest).not.toHaveBeenCalled();
});
it('prefills stored values and shows the key fingerprint without the secret', () => {
it('prefills stored values, tests on load, and shows masked keys until edit', async () => {
mockGet.mockReturnValue({
data: {
configured: true,
enabled: true,
baseUrl: 'https://cloud.langfuse.com',
publicKey: 'pk-lf-1',
secretKeyFingerprint: 'abc123def456',
destinations: [
{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' },
{ key: 'us', baseUrl: 'https://us.cloud.langfuse.com' },
],
destination: 'us',
publicKey: 'pk-lf-12345678-515f',
displaySecretKey: 'sk-lf-...515f',
},
});
render(<LangfuseConnection />);
expect(screen.getByLabelText('com_ui_langfuse_base_url')).toHaveValue(
'https://cloud.langfuse.com',
expect(screen.getByLabelText('com_ui_langfuse_destination')).toHaveValue('us');
expect(screen.queryByLabelText('com_ui_langfuse_public_key')).not.toBeInTheDocument();
expect(screen.getByText('pk-lf-...515f')).toBeInTheDocument();
expect(screen.queryByLabelText('com_ui_langfuse_secret_key')).not.toBeInTheDocument();
expect(screen.getByText('sk-lf-...515f')).toBeInTheDocument();
await userEvent.click(
screen.getByRole('button', { name: 'com_ui_edit com_ui_langfuse_public_key' }),
);
expect(screen.getByLabelText('com_ui_langfuse_public_key')).toHaveValue('pk-lf-1');
expect(screen.getByLabelText(/com_ui_langfuse_secret_key/)).toHaveValue('');
expect(screen.getByText('abc123def456')).toBeInTheDocument();
expect(screen.getByLabelText('com_ui_langfuse_public_key')).toHaveValue('pk-lf-12345678-515f');
await waitFor(() => expect(mockTest).toHaveBeenCalledTimes(1));
expect(mockTest.mock.calls[0][0]).toEqual({
destination: 'us',
publicKey: 'pk-lf-12345678-515f',
});
expect(screen.getByText('com_ui_langfuse_status_connected')).toBeInTheDocument();
});
it('includes the typed secret key when saving a new connection', async () => {
render(<LangfuseConnection />);
fireEvent.change(screen.getByLabelText('com_ui_langfuse_base_url'), {
target: { value: 'https://cloud.langfuse.com' },
it('shows a failed saved-connection status when the load-time test fails', async () => {
mockTest.mockImplementation((_payload, options) => {
options?.onSuccess?.({ success: false, message: 'Langfuse rejected these keys' });
});
mockGet.mockReturnValue({
data: {
configured: true,
enabled: true,
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
destination: 'eu',
publicKey: 'pk-lf-1',
displaySecretKey: 'sk-lf-...515f',
},
});
render(<LangfuseConnection />);
await waitFor(() =>
expect(screen.getByText('Langfuse rejected these keys')).toBeInTheDocument(),
);
expect(screen.getByText('Langfuse rejected these keys').closest('div')).toHaveAttribute(
'title',
'com_ui_langfuse_status_failed_hover',
);
});
it('tests and saves the typed secret key when enabling a new connection', async () => {
render(<LangfuseConnection />);
await userEvent.click(screen.getByRole('switch', { name: 'com_ui_langfuse_title' }));
await userEvent.selectOptions(screen.getByLabelText('com_ui_langfuse_destination'), 'us');
fireEvent.change(screen.getByLabelText('com_ui_langfuse_public_key'), {
target: { value: 'pk-lf-1' },
});
@ -70,50 +123,165 @@ describe('LangfuseConnection', () => {
await userEvent.click(screen.getByText('com_ui_save'));
expect(mockTest).toHaveBeenCalledTimes(1);
expect(mockTest.mock.calls[0][0]).toEqual({
destination: 'us',
publicKey: 'pk-lf-1',
secretKey: 'sk-lf-secret',
});
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdate.mock.calls[0][0]).toEqual({
enabled: false,
baseUrl: 'https://cloud.langfuse.com',
enabled: true,
destination: 'us',
publicKey: 'pk-lf-1',
secretKey: 'sk-lf-secret',
});
});
it('omits the secret key when saving without re-entering it for an already-configured connection', async () => {
mockGet.mockReturnValue({
data: {
it('shows the display secret key immediately after saving a new connection', async () => {
mockUpdate.mockImplementation((_payload, options) => {
options?.onSuccess?.({
configured: true,
enabled: true,
baseUrl: 'https://cloud.langfuse.com',
destinations: [
{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' },
{ key: 'us', baseUrl: 'https://us.cloud.langfuse.com' },
],
destination: 'us',
publicKey: 'pk-lf-1',
secretKeyFingerprint: 'abc123def456',
},
displaySecretKey: 'sk-lf-...cret',
});
});
render(<LangfuseConnection />);
await userEvent.click(screen.getByRole('switch', { name: 'com_ui_langfuse_title' }));
await userEvent.selectOptions(screen.getByLabelText('com_ui_langfuse_destination'), 'us');
fireEvent.change(screen.getByLabelText('com_ui_langfuse_public_key'), {
target: { value: 'pk-lf-1' },
});
fireEvent.change(screen.getByLabelText(/com_ui_langfuse_secret_key/), {
target: { value: 'sk-lf-secret' },
});
await userEvent.click(screen.getByText('com_ui_save'));
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdate.mock.calls[0][0]).not.toHaveProperty('secretKey');
expect(mockUpdate.mock.calls[0][0]).toMatchObject({ publicKey: 'pk-lf-1' });
expect(mockTest).toHaveBeenCalledTimes(1);
expect(screen.queryByLabelText('com_ui_langfuse_secret_key')).not.toBeInTheDocument();
expect(screen.getByText('sk-lf-...cret')).toBeInTheDocument();
});
it('triggers a connection test', async () => {
it('tests and saves without re-entering the secret for an already-configured connection', async () => {
mockGet.mockReturnValue({
data: {
configured: true,
enabled: true,
baseUrl: 'https://cloud.langfuse.com',
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
destination: 'eu',
publicKey: 'pk-lf-1',
displaySecretKey: 'sk-lf-...515f',
},
});
render(<LangfuseConnection />);
await waitFor(() => expect(mockTest).toHaveBeenCalledTimes(1));
mockTest.mockClear();
await userEvent.click(screen.getByText('com_ui_langfuse_test'));
await userEvent.click(screen.getByText('com_ui_save'));
expect(mockTest).toHaveBeenCalledTimes(1);
expect(mockTest.mock.calls[0][0]).toMatchObject({
baseUrl: 'https://cloud.langfuse.com',
destination: 'eu',
publicKey: 'pk-lf-1',
});
expect(mockTest.mock.calls[0][0]).not.toHaveProperty('secretKey');
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdate.mock.calls[0][0]).not.toHaveProperty('secretKey');
expect(mockUpdate.mock.calls[0][0]).toMatchObject({ destination: 'eu', publicKey: 'pk-lf-1' });
});
it('shows the secret input only while replacing an already-configured secret', async () => {
mockGet.mockReturnValue({
data: {
configured: true,
enabled: true,
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
destination: 'eu',
publicKey: 'pk-lf-1',
displaySecretKey: 'sk-lf-...515f',
},
});
render(<LangfuseConnection />);
await waitFor(() => expect(mockTest).toHaveBeenCalledTimes(1));
mockTest.mockClear();
expect(screen.queryByLabelText('com_ui_langfuse_secret_key')).not.toBeInTheDocument();
await userEvent.click(
screen.getByRole('button', { name: 'com_ui_edit com_ui_langfuse_secret_key' }),
);
expect(screen.getByLabelText(/com_ui_langfuse_secret_key/)).toHaveValue('');
fireEvent.change(screen.getByLabelText(/com_ui_langfuse_secret_key/), {
target: { value: 'sk-lf-replacement' },
});
await userEvent.click(screen.getByText('com_ui_save'));
expect(mockTest).toHaveBeenCalledTimes(1);
expect(mockTest.mock.calls[0][0]).toMatchObject({
destination: 'eu',
publicKey: 'pk-lf-1',
secretKey: 'sk-lf-replacement',
});
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdate.mock.calls[0][0]).toMatchObject({
destination: 'eu',
publicKey: 'pk-lf-1',
secretKey: 'sk-lf-replacement',
});
});
it('blocks saving when the implicit connection test fails', async () => {
mockTest.mockImplementation((_payload, options) => {
options?.onSuccess?.({ success: false, message: 'bad key' });
});
render(<LangfuseConnection />);
await userEvent.click(screen.getByRole('switch', { name: 'com_ui_langfuse_title' }));
await userEvent.selectOptions(screen.getByLabelText('com_ui_langfuse_destination'), 'us');
fireEvent.change(screen.getByLabelText('com_ui_langfuse_public_key'), {
target: { value: 'pk-lf-1' },
});
fireEvent.change(screen.getByLabelText(/com_ui_langfuse_secret_key/), {
target: { value: 'sk-lf-secret' },
});
await userEvent.click(screen.getByText('com_ui_save'));
expect(mockTest).toHaveBeenCalledTimes(1);
expect(mockUpdate).not.toHaveBeenCalled();
});
it('skips the connection test when disabling an already-configured connection', async () => {
mockGet.mockReturnValue({
data: {
configured: true,
enabled: true,
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
destination: 'eu',
publicKey: 'pk-lf-1',
displaySecretKey: 'sk-lf-...515f',
},
});
render(<LangfuseConnection />);
await waitFor(() => expect(mockTest).toHaveBeenCalledTimes(1));
mockTest.mockClear();
await userEvent.click(screen.getByRole('switch', { name: 'com_ui_langfuse_title' }));
await userEvent.click(screen.getByText('com_ui_save'));
expect(mockTest).not.toHaveBeenCalled();
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdate.mock.calls[0][0]).toMatchObject({
enabled: false,
destination: 'eu',
publicKey: 'pk-lf-1',
});
});

View file

@ -1641,19 +1641,20 @@
"com_ui_settings_section_accessibility": "Accessibility",
"com_ui_settings_section_api_keys": "API keys",
"com_ui_settings_section_integrations": "Integrations",
"com_ui_langfuse_title": "Langfuse connection",
"com_ui_langfuse_title": "Langfuse export",
"com_ui_langfuse_description": "Send this organization's traces and feedback scores to your own Langfuse project.",
"com_ui_langfuse_base_url": "Host",
"com_ui_langfuse_beta_info": "This feature is in beta. Enabling this setting will export traces from all agents in your org to this Langfuse connection.",
"com_ui_langfuse_destination": "Destination",
"com_ui_langfuse_public_key": "Public key",
"com_ui_langfuse_secret_key": "Secret key",
"com_ui_langfuse_secret_key_set": "Saved. Enter a new key to replace it",
"com_ui_langfuse_secret_key_hint": "The secret key is encrypted at rest and is never shown again after saving.",
"com_ui_langfuse_secret_key_fingerprint": "Configured key fingerprint:",
"com_ui_langfuse_test": "Test connection",
"com_ui_langfuse_status_checking": "Checking connection",
"com_ui_langfuse_status_connected": "Connected",
"com_ui_langfuse_status_failed": "Connection failed",
"com_ui_langfuse_status_failed_hover": "Check Langfuse to see if traces are still failing. A one-time ping with the keys just failed.",
"com_ui_langfuse_status_not_configured": "Not configured",
"com_ui_langfuse_testing": "Testing connection",
"com_ui_langfuse_saved": "Langfuse connection saved",
"com_ui_langfuse_save_error": "Failed to save the Langfuse connection",
"com_ui_langfuse_test_success": "Connected to Langfuse successfully",
"com_ui_langfuse_test_error": "Could not connect to Langfuse",
"com_ui_settings_section_appearance": "Appearance",
"com_ui_settings_section_billing": "Billing",

View file

@ -14,6 +14,14 @@ beforeAll(async () => {
({ createAdminLangfuseHandlers } = await import('./langfuse'));
});
beforeEach(() => {
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
});
afterEach(() => {
delete process.env.LANGFUSE_FANOUT_ENABLED;
});
function mockReq(overrides = {}) {
return {
user: { id: 'u1', role: 'ADMIN', tenantId: 't1' },
@ -83,6 +91,52 @@ function rehydrate(fields: Record<string, unknown>): Record<string, unknown> {
}
describe('createAdminLangfuseHandlers', () => {
describe('fanout feature gate', () => {
it('rejects connection reads when deployment fanout is disabled', async () => {
delete process.env.LANGFUSE_FANOUT_ENABLED;
const { handlers, deps } = createHandlers();
const res = mockRes();
await handlers.getConnection(mockReq(), res);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'Langfuse fanout is not enabled' });
expect(deps.findConfigByPrincipal).not.toHaveBeenCalled();
});
it('rejects connection updates when deployment fanout is disabled', async () => {
delete process.env.LANGFUSE_FANOUT_ENABLED;
const { handlers, deps } = createHandlers();
const res = mockRes();
await handlers.updateConnection(
mockReq({ body: { destination: 'eu', publicKey: 'pk', secretKey: 'sk' } }),
res,
);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'Langfuse fanout is not enabled' });
expect(deps.patchConfigFields).not.toHaveBeenCalled();
});
it('rejects connection tests when deployment fanout is disabled', async () => {
delete process.env.LANGFUSE_FANOUT_ENABLED;
global.fetch = jest.fn() as unknown as typeof fetch;
const { handlers, deps } = createHandlers();
const res = mockRes();
await handlers.testConnection(
mockReq({ body: { destination: 'eu', publicKey: 'pk', secretKey: 'sk' } }),
res,
);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'Langfuse fanout is not enabled' });
expect(deps.findConfigByPrincipal).not.toHaveBeenCalled();
expect(global.fetch).not.toHaveBeenCalled();
});
});
describe('getConnection', () => {
it('reports not configured when no base config exists', async () => {
const { handlers } = createHandlers();
@ -100,10 +154,10 @@ describe('createAdminLangfuseHandlers', () => {
findConfigByPrincipal: jest.fn().mockResolvedValue(
baseConfigDoc({
enabled: true,
baseUrl: 'https://cloud.langfuse.com',
destination: 'eu',
publicKey: 'pk-lf-1',
secretKey: encryptV3('sk-lf-secret'),
secretKeyFingerprint: 'abc123def456',
displaySecretKey: 'sk-lf...cret',
}),
),
});
@ -114,10 +168,13 @@ describe('createAdminLangfuseHandlers', () => {
expect(res.body).toMatchObject({
configured: true,
enabled: true,
baseUrl: 'https://cloud.langfuse.com',
destination: 'eu',
publicKey: 'pk-lf-1',
secretKeyFingerprint: 'abc123def456',
displaySecretKey: 'sk-lf...cret',
});
expect(res.body?.destinations).toEqual(
expect.arrayContaining([{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }]),
);
expect(res.body?.secretKey).toBeUndefined();
expect(JSON.stringify(res.body)).not.toContain('sk-lf-secret');
expect(JSON.stringify(res.body)).not.toContain('v3:');
@ -125,7 +182,7 @@ describe('createAdminLangfuseHandlers', () => {
});
describe('updateConnection', () => {
it('requires baseUrl', async () => {
it('requires destination', async () => {
const { handlers } = createHandlers();
const res = mockRes();
await handlers.updateConnection(mockReq({ body: { publicKey: 'pk' } }), res);
@ -135,35 +192,43 @@ describe('createAdminLangfuseHandlers', () => {
it('requires publicKey', async () => {
const { handlers } = createHandlers();
const res = mockRes();
await handlers.updateConnection(
mockReq({ body: { baseUrl: 'https://cloud.langfuse.com' } }),
res,
);
await handlers.updateConnection(mockReq({ body: { destination: 'eu' } }), res);
expect(res.statusCode).toBe(400);
});
it('rejects an invalid baseUrl', async () => {
it('rejects an unknown destination', async () => {
const { handlers } = createHandlers();
const res = mockRes();
await handlers.updateConnection(
mockReq({ body: { baseUrl: 'not-a-url', publicKey: 'pk', secretKey: 'sk' } }),
mockReq({ body: { destination: 'mars', publicKey: 'pk', secretKey: 'sk' } }),
res,
);
expect(res.statusCode).toBe(400);
});
it('requires a secret key on first-time configuration', async () => {
it('rejects encrypted secret values from clients', async () => {
const { handlers, deps } = createHandlers();
const res = mockRes();
await handlers.updateConnection(
mockReq({ body: { baseUrl: 'https://cloud.langfuse.com', publicKey: 'pk' } }),
mockReq({ body: { destination: 'eu', publicKey: 'pk', secretKey: encryptV3('sk') } }),
res,
);
expect(res.statusCode).toBe(400);
expect(deps.patchConfigFields).not.toHaveBeenCalled();
});
it('encrypts the secret key, stores a fingerprint, and never returns the secret', async () => {
it('requires a secret key on first-time configuration', async () => {
const { handlers, deps } = createHandlers();
const res = mockRes();
await handlers.updateConnection(
mockReq({ body: { destination: 'eu', publicKey: 'pk' } }),
res,
);
expect(res.statusCode).toBe(400);
expect(deps.patchConfigFields).not.toHaveBeenCalled();
});
it('stores the secret through the shared config secret helper and never returns the secret', async () => {
const { handlers, deps } = createHandlers();
const res = mockRes();
@ -171,7 +236,7 @@ describe('createAdminLangfuseHandlers', () => {
mockReq({
body: {
enabled: true,
baseUrl: 'https://cloud.langfuse.com',
destination: 'eu',
publicKey: 'pk-lf-1',
secretKey: 'sk-lf-secret',
},
@ -183,8 +248,9 @@ describe('createAdminLangfuseHandlers', () => {
const fields = deps.patchConfigFields.mock.calls[0][3];
expect(fields['langfuse.secretKey']).toMatch(/^v3:/);
expect(fields['langfuse.secretKey']).not.toContain('sk-lf-secret');
expect(fields['langfuse.secretKeyFingerprint']).toMatch(/^[a-f0-9]{12}$/);
expect(fields['langfuse.displaySecretKey']).toBe('sk-lf-...cret');
expect(fields['langfuse.enabled']).toBe(true);
expect(fields['langfuse.destination']).toBe('eu');
expect(fields['langfuse.publicKey']).toBe('pk-lf-1');
expect(res.body?.secretKey).toBeUndefined();
expect(deps.invalidateConfigCaches).toHaveBeenCalledWith('t1');
@ -200,7 +266,7 @@ describe('createAdminLangfuseHandlers', () => {
await handlers.updateConnection(
mockReq({
body: { enabled: false, baseUrl: 'https://us.cloud.langfuse.com', publicKey: 'pk-2' },
body: { enabled: false, destination: 'us', publicKey: 'pk-2' },
}),
res,
);
@ -208,6 +274,7 @@ describe('createAdminLangfuseHandlers', () => {
expect(res.statusCode).toBe(200);
const fields = deps.patchConfigFields.mock.calls[0][3];
expect(fields['langfuse.secretKey']).toBeUndefined();
expect(fields['langfuse.destination']).toBe('us');
expect(fields['langfuse.publicKey']).toBe('pk-2');
});
});
@ -218,11 +285,28 @@ describe('createAdminLangfuseHandlers', () => {
global.fetch = realFetch;
});
it('requires baseUrl and publicKey', async () => {
it('requires destination and publicKey', async () => {
const { handlers } = createHandlers();
const res = mockRes();
await handlers.testConnection(mockReq({ body: { destination: 'eu' } }), res);
expect(res.statusCode).toBe(400);
});
it('rejects an unknown destination', async () => {
const { handlers } = createHandlers();
const res = mockRes();
await handlers.testConnection(
mockReq({ body: { baseUrl: 'https://cloud.langfuse.com' } }),
mockReq({ body: { destination: 'mars', publicKey: 'pk', secretKey: 'sk' } }),
res,
);
expect(res.statusCode).toBe(400);
});
it('rejects encrypted secret values from clients', async () => {
const { handlers } = createHandlers();
const res = mockRes();
await handlers.testConnection(
mockReq({ body: { destination: 'eu', publicKey: 'pk', secretKey: encryptV3('sk') } }),
res,
);
expect(res.statusCode).toBe(400);
@ -237,7 +321,7 @@ describe('createAdminLangfuseHandlers', () => {
await handlers.testConnection(
mockReq({
body: { baseUrl: 'https://cloud.langfuse.com', publicKey: 'pk', secretKey: 'sk' },
body: { destination: 'eu', publicKey: 'pk', secretKey: 'sk' },
}),
res,
);
@ -248,7 +332,7 @@ describe('createAdminLangfuseHandlers', () => {
expect(init.headers.Authorization).toMatch(/^Basic /);
});
it('returns failure with status when Langfuse rejects the credentials', async () => {
it('returns a key-specific failure when Langfuse rejects the credentials', async () => {
global.fetch = jest
.fn()
.mockResolvedValue({ ok: false, status: 401 }) as unknown as typeof fetch;
@ -257,13 +341,35 @@ describe('createAdminLangfuseHandlers', () => {
await handlers.testConnection(
mockReq({
body: { baseUrl: 'https://cloud.langfuse.com', publicKey: 'pk', secretKey: 'sk' },
body: { destination: 'eu', publicKey: 'pk', secretKey: 'sk' },
}),
res,
);
expect(res.body?.success).toBe(false);
expect(res.body?.message).toContain('401');
expect(res.body).toEqual({
success: false,
message: 'Langfuse rejected these keys. Check the public and secret keys.',
});
});
it('returns an incident-oriented failure when Langfuse returns a server error', async () => {
global.fetch = jest
.fn()
.mockResolvedValue({ ok: false, status: 503 }) as unknown as typeof fetch;
const { handlers } = createHandlers();
const res = mockRes();
await handlers.testConnection(
mockReq({
body: { destination: 'eu', publicKey: 'pk', secretKey: 'sk' },
}),
res,
);
expect(res.body).toEqual({
success: false,
message: 'Langfuse is returning server errors. This may be a Langfuse incident.',
});
});
it('falls back to the stored (decrypted) secret when none is supplied', async () => {
@ -277,10 +383,7 @@ describe('createAdminLangfuseHandlers', () => {
});
const res = mockRes();
await handlers.testConnection(
mockReq({ body: { baseUrl: 'https://cloud.langfuse.com', publicKey: 'pk' } }),
res,
);
await handlers.testConnection(mockReq({ body: { destination: 'eu', publicKey: 'pk' } }), res);
expect(res.body).toEqual({ success: true });
const [, init] = (global.fetch as unknown as jest.Mock).mock.calls[0];

View file

@ -1,6 +1,5 @@
import crypto from 'node:crypto';
import { PrincipalType, PrincipalModel } from 'librechat-data-provider';
import { logger, BASE_CONFIG_PRINCIPAL_ID, encryptV3, decryptV3 } from '@librechat/data-schemas';
import { logger, BASE_CONFIG_PRINCIPAL_ID } from '@librechat/data-schemas';
import type {
TCustomConfig,
LangfuseConfig,
@ -13,15 +12,15 @@ import type { IConfig } from '@librechat/data-schemas';
import type { Types, ClientSession } from 'mongoose';
import type { Response } from 'express';
import type { ServerRequest } from '~/types/http';
import {
getLangfuseTenantDestinations,
resolveLangfuseTenantDestination,
} from '~/langfuse/tenantDestinations';
import { decryptConfigSecret, encryptConfigSecretFields } from './secrets';
import { isLangfuseFanoutEnabled } from '~/langfuse/config';
const DEFAULT_PRIORITY = 10;
const FINGERPRINT_LENGTH = 12;
/** Short, non-reversible fingerprint of a secret so reads can show which key is
* configured without exposing it. */
function fingerprintSecret(secret: string): string {
return crypto.createHash('sha256').update(secret).digest('hex').slice(0, FINGERPRINT_LENGTH);
}
const ENCRYPTED_PREFIX = 'v3:';
export interface AdminLangfuseDeps {
findConfigByPrincipal: (
@ -55,21 +54,32 @@ function buildStatus(config: IConfig | null): TLangfuseConnectionStatus {
return {
configured: Boolean(stored?.publicKey && stored?.secretKey),
enabled: stored?.enabled === true,
baseUrl: stored?.baseUrl,
destinations: getLangfuseTenantDestinations(),
destination: stored?.destination,
publicKey: stored?.publicKey,
secretKeyFingerprint: stored?.secretKeyFingerprint,
displaySecretKey: stored?.displaySecretKey,
updatedAt: config?.updatedAt ? new Date(config.updatedAt).toISOString() : undefined,
};
}
function resolveStoredSecret(secret?: string): string | undefined {
if (!secret) {
function rejectWhenFanoutDisabled(res: Response): Response | undefined {
if (isLangfuseFanoutEnabled()) {
return undefined;
}
if (!secret.startsWith('v3:')) {
return secret;
return res.status(404).json({ error: 'Langfuse fanout is not enabled' });
}
function getLangfuseTestFailureMessage(status: number): string {
if (status === 401) {
return 'Langfuse rejected these keys. Check the public and secret keys.';
}
return decryptV3(secret);
if (status >= 500) {
return 'Langfuse is returning server errors. This may be a Langfuse incident.';
}
return `Langfuse responded with status ${status}`;
}
/**
@ -93,6 +103,11 @@ export function createAdminLangfuseHandlers(deps: AdminLangfuseDeps): {
}
async function getConnection(req: ServerRequest, res: Response): Promise<Response> {
const disabledResponse = rejectWhenFanoutDisabled(res);
if (disabledResponse) {
return disabledResponse;
}
try {
const config = await findBaseConfig();
return res.status(200).json(buildStatus(config));
@ -103,23 +118,30 @@ export function createAdminLangfuseHandlers(deps: AdminLangfuseDeps): {
}
async function updateConnection(req: ServerRequest, res: Response): Promise<Response> {
const disabledResponse = rejectWhenFanoutDisabled(res);
if (disabledResponse) {
return disabledResponse;
}
try {
const body = (req.body ?? {}) as TUpdateLangfuseConnectionRequest;
const enabled = body.enabled === true;
const baseUrl = typeof body.baseUrl === 'string' ? body.baseUrl.trim() : '';
const destination = typeof body.destination === 'string' ? body.destination.trim() : '';
const publicKey = typeof body.publicKey === 'string' ? body.publicKey.trim() : '';
const secretKey = typeof body.secretKey === 'string' ? body.secretKey.trim() : '';
const tenantDestination = resolveLangfuseTenantDestination(destination);
if (!baseUrl) {
return res.status(400).json({ error: 'baseUrl is required' });
if (!destination) {
return res.status(400).json({ error: 'destination is required' });
}
if (!publicKey) {
return res.status(400).json({ error: 'publicKey is required' });
}
try {
new URL(baseUrl);
} catch {
return res.status(400).json({ error: 'baseUrl must be a valid URL' });
if (!tenantDestination) {
return res.status(400).json({ error: 'destination is not configured' });
}
if (secretKey.startsWith(ENCRYPTED_PREFIX)) {
return res.status(400).json({ error: 'Encrypted secretKey values cannot be submitted' });
}
const existing = await findBaseConfig();
@ -132,19 +154,18 @@ export function createAdminLangfuseHandlers(deps: AdminLangfuseDeps): {
const fields: Record<string, unknown> = {
'langfuse.enabled': enabled,
'langfuse.baseUrl': baseUrl,
'langfuse.destination': tenantDestination.key,
'langfuse.publicKey': publicKey,
};
if (secretKey) {
fields['langfuse.secretKey'] = encryptV3(secretKey);
fields['langfuse.secretKeyFingerprint'] = fingerprintSecret(secretKey);
fields['langfuse.secretKey'] = secretKey;
}
const updated = await patchConfigFields(
PrincipalType.ROLE,
BASE_CONFIG_PRINCIPAL_ID,
PrincipalModel.ROLE,
fields,
encryptConfigSecretFields(fields),
existing?.priority ?? DEFAULT_PRIORITY,
);
@ -160,26 +181,40 @@ export function createAdminLangfuseHandlers(deps: AdminLangfuseDeps): {
}
async function testConnection(req: ServerRequest, res: Response): Promise<Response> {
const disabledResponse = rejectWhenFanoutDisabled(res);
if (disabledResponse) {
return disabledResponse;
}
try {
const body = (req.body ?? {}) as TLangfuseConnectionTestRequest;
const baseUrl = typeof body.baseUrl === 'string' ? body.baseUrl.trim() : '';
const destination = typeof body.destination === 'string' ? body.destination.trim() : '';
const publicKey = typeof body.publicKey === 'string' ? body.publicKey.trim() : '';
let secretKey = typeof body.secretKey === 'string' ? body.secretKey.trim() : '';
const tenantDestination = resolveLangfuseTenantDestination(destination);
if (!baseUrl || !publicKey) {
return res.status(400).json({ error: 'baseUrl and publicKey are required' });
if (!destination || !publicKey) {
return res.status(400).json({ error: 'destination and publicKey are required' });
}
if (!tenantDestination) {
return res.status(400).json({ error: 'destination is not configured' });
}
if (secretKey.startsWith(ENCRYPTED_PREFIX)) {
return res.status(400).json({ error: 'Encrypted secretKey values cannot be submitted' });
}
if (!secretKey) {
const existing = await findBaseConfig();
try {
secretKey = resolveStoredSecret(readStoredLangfuse(existing)?.secretKey) ?? '';
} catch {
const failed: TLangfuseConnectionTestResponse = {
success: false,
message: 'Stored secret key could not be decrypted',
};
return res.status(200).json(failed);
const storedSecret = readStoredLangfuse(existing)?.secretKey;
if (storedSecret) {
secretKey = decryptConfigSecret(storedSecret) ?? '';
if (!secretKey) {
const failed: TLangfuseConnectionTestResponse = {
success: false,
message: 'Stored secret key could not be decrypted',
};
return res.status(200).json(failed);
}
}
}
@ -192,12 +227,12 @@ export function createAdminLangfuseHandlers(deps: AdminLangfuseDeps): {
}
const auth = Buffer.from(`${publicKey}:${secretKey}`).toString('base64');
const url = `${baseUrl.replace(/\/+$/, '')}/api/public/projects`;
const url = `${tenantDestination.baseUrl}/api/public/projects`;
const response = await fetch(url, { headers: { Authorization: `Basic ${auth}` } });
const result: TLangfuseConnectionTestResponse = response.ok
? { success: true }
: { success: false, message: `Langfuse responded with status ${response.status}` };
: { success: false, message: getLangfuseTestFailureMessage(response.status) };
return res.status(200).json(result);
} catch (error) {
logger.error('[adminLangfuse] testConnection error:', error);

View file

@ -1194,6 +1194,7 @@ describe('Langfuse run config', () => {
});
it('adds tenant Langfuse credentials from tenant-scoped app config', async () => {
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://langfuse-fanout-collector:4318';
const callArgs = await callAndCaptureRunConfig({
@ -1203,9 +1204,6 @@ describe('Langfuse run config', () => {
publicKey: 'pk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
fanout: {
enabled: true,
},
},
} as unknown as AppConfig,
});
@ -1628,69 +1626,10 @@ describe('Langfuse run config', () => {
},
);
it('uses central env Langfuse config when tenant fanout.enabled=false overrides deployment fanout env', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-central';
process.env.LANGFUSE_SECRET_KEY = 'sk-central';
process.env.LANGFUSE_BASE_URL = 'https://central.langfuse.example';
it('keeps central collector tracing when tenant Langfuse export is disabled', async () => {
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
fanout: {
enabled: false,
},
},
} as AppConfig,
});
expect(callArgs.langfuse).toEqual({
deterministicTraceId: true,
publicKey: 'pk-central',
secretKey: 'sk-central',
baseUrl: 'https://central.langfuse.example',
metadata: { 'librechat.tenant.id': 'tenant-1' },
tags: ['tenant:tenant-1'],
});
});
it('uses central env Langfuse config when tenant fanout.enabled is the string false', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-central';
process.env.LANGFUSE_SECRET_KEY = 'sk-central';
process.env.LANGFUSE_BASE_URL = 'https://central.langfuse.example';
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
fanout: {
enabled: 'false',
},
},
} as unknown as AppConfig,
});
expect(callArgs.langfuse).toEqual({
deterministicTraceId: true,
publicKey: 'pk-central',
secretKey: 'sk-central',
baseUrl: 'https://central.langfuse.example',
metadata: { 'librechat.tenant.id': 'tenant-1' },
tags: ['tenant:tenant-1'],
});
});
it('honors tenant Langfuse enabled=false as a tracing opt-out', async () => {
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
@ -1704,13 +1643,16 @@ describe('Langfuse run config', () => {
expect(callArgs.langfuse).toEqual({
deterministicTraceId: true,
enabled: false,
baseUrl: 'http://collector-from-env:4318',
metadata: { 'librechat.tenant.id': 'tenant-1' },
tags: ['tenant:tenant-1'],
});
});
it('honors tenant Langfuse enabled as the string false', async () => {
it('keeps central collector tracing when tenant Langfuse enabled is the string false', async () => {
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
@ -1724,7 +1666,7 @@ describe('Langfuse run config', () => {
expect(callArgs.langfuse).toEqual({
deterministicTraceId: true,
enabled: false,
baseUrl: 'http://collector-from-env:4318',
metadata: { 'librechat.tenant.id': 'tenant-1' },
tags: ['tenant:tenant-1'],
});

View file

@ -5,8 +5,6 @@ import { resolveLangfuseTenantDestination } from './tenantDestinations';
import { normalizeString } from '~/utils/text';
type LangfuseRunConfig = NonNullable<RunConfig['langfuse']>;
type LangfuseAppConfig = NonNullable<AppConfig['langfuse']>;
export type LangfuseFanoutConfig = LangfuseAppConfig['fanout'];
type LangfuseRunConfigWithTraceAttributes = LangfuseRunConfig & {
librechatTraceAttributes?: Record<string, string | number | boolean | null | undefined>;
};
@ -22,9 +20,8 @@ export function isLangfuseTenantExportEnabled(): boolean {
return !isTrueEnv(process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED);
}
export function isLangfuseFanoutEnabled(fanout?: LangfuseFanoutConfig): boolean {
const enabled = normalizeBoolean(fanout?.enabled);
return enabled !== false && (enabled === true || isTrueEnv(process.env.LANGFUSE_FANOUT_ENABLED));
export function isLangfuseFanoutEnabled(): boolean {
return isTrueEnv(process.env.LANGFUSE_FANOUT_ENABLED);
}
function mergeTraceMetadata(
@ -83,22 +80,16 @@ export function buildLangfuseConfig({
langfuse.tags = tags;
}
if (normalizeBoolean(config?.enabled) === false) {
return {
...langfuse,
enabled: false,
};
}
const tenantLangfuseEnabled = normalizeBoolean(config?.enabled) !== false;
const tenantCredentials = resolveTenantCredentials(config);
const hasTenantCredentials = Boolean(tenantCredentials);
const fanout = config?.fanout as LangfuseFanoutConfig | undefined;
const fanoutEnabled = isLangfuseFanoutEnabled(fanout);
const fanoutEnabled = isLangfuseFanoutEnabled();
const fanoutCollectorUrl = normalizeString(process.env.LANGFUSE_FANOUT_COLLECTOR_URL);
const tenantDestination = resolveLangfuseTenantDestination(config?.destination);
const tenantExportDestination = hasTenantCredentials ? tenantDestination : undefined;
const tenantExportCollectorUrl = fanoutCollectorUrl;
const tenantExportEnabled =
tenantLangfuseEnabled &&
hasTenantCredentials &&
fanoutEnabled &&
isLangfuseTenantExportEnabled() &&

View file

@ -1,5 +1,4 @@
import type { AppConfig } from '@librechat/data-schemas';
import type { LangfuseFanoutConfig } from './config';
import {
isFalseEnv,
normalizeBoolean,
@ -75,8 +74,7 @@ function getTenantScoreDestination(appConfig?: AppConfig): LangfuseScoreDestinat
if (normalizeBoolean(config?.enabled) === false) {
return undefined;
}
const fanout = config?.fanout as LangfuseFanoutConfig | undefined;
if (!isLangfuseFanoutEnabled(fanout)) {
if (!isLangfuseFanoutEnabled()) {
return undefined;
}
const fanoutCollectorUrl = normalizeString(process.env.LANGFUSE_FANOUT_COLLECTOR_URL);

View file

@ -617,58 +617,6 @@ describe('Langfuse feedback scores', () => {
);
});
it('skips tenant scores when tenant fanout is disabled in app config', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: encryptedTenantSecret(),
destination: 'eu',
fanout: { enabled: false },
}),
});
expect(getFetchMock()).toHaveBeenCalledTimes(1);
expect(getFetchMock()).toHaveBeenCalledWith(
'http://central-langfuse:3000/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: getCentralAuthorization() }),
}),
);
});
it('skips tenant scores when tenant fanout enabled is the string false', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: encryptedTenantSecret(),
destination: 'eu',
fanout: { enabled: 'false' },
} as unknown as AppConfig['langfuse']),
});
expect(getFetchMock()).toHaveBeenCalledTimes(1);
expect(getFetchMock()).toHaveBeenCalledWith(
'http://central-langfuse:3000/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: getCentralAuthorization() }),
}),
);
});
it('skips tenant scores when fanout has no collector URL', async () => {
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';

View file

@ -1180,16 +1180,14 @@ describe('specsConfigSchema', () => {
});
describe('configSchema langfuse', () => {
it('accepts tenant Langfuse fanout config', () => {
it('accepts tenant Langfuse connection config', () => {
const result = configSchema.safeParse({
version: '1.3.7',
langfuse: {
enabled: true,
publicKey: 'pk-lf-tenant',
secretKey: 'sk-lf-tenant',
fanout: {
enabled: true,
collectorUrl: 'http://langfuse-fanout-collector:4318',
},
destination: 'eu',
},
});

View file

@ -1840,11 +1840,6 @@ export const langfuseConfigSchema = z.object({
displaySecretKey: z.string().optional(),
/** Routing key for one of the deployment-configured tenant Langfuse destinations. */
destination: z.string().optional(),
fanout: z
.object({
enabled: z.boolean().optional(),
})
.optional(),
});
export type LangfuseConfig = z.infer<typeof langfuseConfigSchema>;

View file

@ -834,21 +834,27 @@ export type TUpdateSkillNodeRequest = {
export type TLangfuseConnectionStatus = {
configured: boolean;
enabled: boolean;
baseUrl?: string;
destinations: TLangfuseDestinationOption[];
destination?: string;
publicKey?: string;
secretKeyFingerprint?: string;
displaySecretKey?: string;
updatedAt?: string;
};
export type TLangfuseDestinationOption = {
key: string;
baseUrl: string;
};
export type TUpdateLangfuseConnectionRequest = {
enabled: boolean;
baseUrl: string;
destination: string;
publicKey: string;
secretKey?: string;
};
export type TLangfuseConnectionTestRequest = {
baseUrl: string;
destination: string;
publicKey: string;
secretKey?: string;
};