diff --git a/api/server/routes/admin/langfuse.js b/api/server/routes/admin/langfuse.js
index 2508af2a7c..a8ab284dfd 100644
--- a/api/server/routes/admin/langfuse.js
+++ b/api/server/routes/admin/langfuse.js
@@ -38,12 +38,14 @@ const handlers = createAdminLangfuseHandlers({
findConfigByPrincipal: db.findConfigByPrincipal,
patchConfigFields: db.patchConfigFields,
toggleConfigActive: db.toggleConfigActive,
+ getMessages: db.getMessages,
invalidateConfigCaches,
});
router.use(requireJwtAuth, requireAdminAccess, requireLangfuseManage);
router.get('/connection', handlers.getConnection);
+router.get('/connection/session/:conversationId', handlers.getSessionLink);
router.put('/connection', handlers.updateConnection);
router.post('/connection/test', handlers.testConnection);
diff --git a/api/server/routes/admin/langfuse.test.js b/api/server/routes/admin/langfuse.test.js
index 76221f05cd..10b4d26629 100644
--- a/api/server/routes/admin/langfuse.test.js
+++ b/api/server/routes/admin/langfuse.test.js
@@ -19,6 +19,7 @@ const mockRequireCapability = jest.fn((capability) => (req, res, next) => {
});
const mockHandlers = {
getConnection: jest.fn((_req, res) => res.status(200).json({ handler: 'get' })),
+ getSessionLink: jest.fn((_req, res) => res.status(200).json({ handler: 'session' })),
updateConnection: jest.fn((_req, res) => res.status(200).json({ handler: 'update' })),
testConnection: jest.fn((_req, res) => res.status(200).json({ handler: 'test' })),
};
@@ -48,6 +49,7 @@ jest.mock('~/models', () => ({
findConfigByPrincipal: jest.fn(),
patchConfigFields: jest.fn(),
toggleConfigActive: jest.fn(),
+ getMessages: jest.fn(),
}));
describe('admin Langfuse routes', () => {
@@ -85,24 +87,32 @@ describe('admin Langfuse routes', () => {
});
it.each([
+ ['GET', '/api/admin/langfuse/connection/session/conversation-1', 'getSessionLink'],
['PUT', '/api/admin/langfuse/connection', 'updateConnection'],
['POST', '/api/admin/langfuse/connection/test', 'testConnection'],
])('requires Langfuse manage access for %s %s', async (method, path, handlerName) => {
const app = createApp();
const response = await request(app)[method.toLowerCase()](path).send({}).expect(200);
+ const expectedHandlers = {
+ getSessionLink: 'session',
+ updateConnection: 'update',
+ testConnection: 'test',
+ };
- expect(response.body).toEqual({
- handler: handlerName === 'updateConnection' ? 'update' : 'test',
- });
+ expect(response.body).toEqual({ handler: expectedHandlers[handlerName] });
expect(middlewareCalls).toEqual(['jwt', 'access:admin']);
expect(mockHandlers[handlerName]).toHaveBeenCalledTimes(1);
});
- it('blocks updates when the user lacks Langfuse manage access', async () => {
+ it.each([
+ ['GET', '/api/admin/langfuse/connection/session/conversation-1', 'getSessionLink'],
+ ['PUT', '/api/admin/langfuse/connection', 'updateConnection'],
+ ['POST', '/api/admin/langfuse/connection/test', 'testConnection'],
+ ])('blocks %s %s without Langfuse manage access', async (method, path, handlerName) => {
canManageLangfuse = false;
- await request(createApp()).put('/api/admin/langfuse/connection').send({}).expect(403);
+ await request(createApp())[method.toLowerCase()](path).send({}).expect(403);
- expect(mockHandlers.updateConnection).not.toHaveBeenCalled();
+ expect(mockHandlers[handlerName]).not.toHaveBeenCalled();
});
});
diff --git a/client/src/components/Chat/Input/TokenUsage/Breakdown.spec.tsx b/client/src/components/Chat/Input/TokenUsage/Breakdown.spec.tsx
new file mode 100644
index 0000000000..cf5eff2c97
--- /dev/null
+++ b/client/src/components/Chat/Input/TokenUsage/Breakdown.spec.tsx
@@ -0,0 +1,58 @@
+import '@testing-library/jest-dom/extend-expect';
+import { render, screen } from '@testing-library/react';
+import type { TokenUsageView } from '~/hooks/Chat/useTokenUsage';
+import Breakdown from './Breakdown';
+
+jest.mock('~/hooks', () => ({
+ useLocalize: () => (key: string) => key,
+}));
+
+const view = {
+ usedTokens: 10,
+ percent: 0,
+ isEstimate: true,
+ snapshot: null,
+ snapshotActive: false,
+ branchTotals: {
+ input: 10,
+ output: 0,
+ counted: 1,
+ total: 1,
+ estTokens: 0,
+ tailEstTokens: 0,
+ containsAnchor: false,
+ summaryBaseline: 0,
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, costKnown: true },
+ tailId: null,
+ },
+ branchUsage: { input: 10, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, costKnown: true },
+ totalUsage: { input: 10, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, costKnown: true },
+ hasUsage: true,
+ branchCost: 0,
+ totalCost: 0,
+ liveTokens: 0,
+ estimatedTokens: 0,
+ overheadTokens: 0,
+ messageTokens: 10,
+ messagesPruned: false,
+} as TokenUsageView;
+
+describe('TokenUsage Breakdown', () => {
+ it('renders the Langfuse session as an external link when available', () => {
+ const url = 'https://cloud.langfuse.com/project/project-1/sessions/conversation-1';
+
+ render();
+
+ expect(screen.getByRole('link', { name: 'com_ui_langfuse_view_session' })).toHaveAttribute(
+ 'href',
+ url,
+ );
+ expect(screen.getByRole('link')).toHaveAttribute('target', '_blank');
+ });
+
+ it('omits the Langfuse session link when no traced message is available', () => {
+ render();
+
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx b/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx
index 6d1f4d9afb..ec9a7ee462 100644
--- a/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx
+++ b/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx
@@ -1,3 +1,5 @@
+import { Button } from '@librechat/client';
+import { ExternalLink } from 'lucide-react';
import type { TokenUsageView } from '~/hooks/Chat/useTokenUsage';
import type { CurrencyConfig } from '~/utils';
import { groupToolTokens, formatTokens, formatCost } from '~/utils';
@@ -30,9 +32,15 @@ interface BreakdownProps {
view: TokenUsageView;
showCost: boolean;
currency?: CurrencyConfig;
+ langfuseSessionUrl?: string;
}
-export default function Breakdown({ view, showCost, currency }: BreakdownProps) {
+export default function Breakdown({
+ view,
+ showCost,
+ currency,
+ langfuseSessionUrl,
+}: BreakdownProps) {
const localize = useLocalize();
const { usedTokens, maxTokens, percent, snapshot, snapshotActive, branchUsage, hasUsage } = view;
/** Show the all-branches total only when it (a) exceeds the active branch —
@@ -215,6 +223,18 @@ export default function Breakdown({ view, showCost, currency }: BreakdownProps)
>
)}
+
+ {langfuseSessionUrl && (
+ <>
+
+
+ >
+ )}
);
}
diff --git a/client/src/components/Chat/Input/TokenUsage/index.tsx b/client/src/components/Chat/Input/TokenUsage/index.tsx
index b8118e0c4b..21128dc78b 100644
--- a/client/src/components/Chat/Input/TokenUsage/index.tsx
+++ b/client/src/components/Chat/Input/TokenUsage/index.tsx
@@ -1,11 +1,12 @@
import { memo, useRef } from 'react';
import * as Ariakit from '@ariakit/react';
import { TooltipAnchor } from '@librechat/client';
+import { Constants } from 'librechat-data-provider';
import type { TConversation } from 'librechat-data-provider';
import type { CurrencyConfig } from '~/utils';
+import { useGetLangfuseSessionLinkQuery, useGetStartupConfig } from '~/data-provider';
import { formatTokens, formatCost, cn } from '~/utils';
import useTokenUsage from '~/hooks/Chat/useTokenUsage';
-import { useGetStartupConfig } from '~/data-provider';
import { useLocalize } from '~/hooks';
import Breakdown from './Breakdown';
import Gauge from './Gauge';
@@ -22,14 +23,29 @@ function TokenUsageIndicator({
isSubmitting,
showCost,
currency,
+ langfuseConnectionAccess,
}: TokenUsageProps & {
showCost: boolean;
currency?: CurrencyConfig;
+ langfuseConnectionAccess: boolean;
}) {
const localize = useLocalize();
const view = useTokenUsage({ index, conversation, isSubmitting });
const popover = Ariakit.usePopoverStore({ placement: 'top' });
+ const popoverOpen = Ariakit.useStoreState(popover, 'open');
const disclosureRef = useRef(null);
+ const conversationId = conversation?.conversationId ?? '';
+ const canResolveLangfuseSession =
+ langfuseConnectionAccess &&
+ popoverOpen &&
+ !isSubmitting &&
+ conversationId !== '' &&
+ conversationId !== Constants.NEW_CONVO &&
+ conversationId !== Constants.PENDING_CONVO;
+ const { data: langfuseSession } = useGetLangfuseSessionLinkQuery(
+ conversationId,
+ canResolveLangfuseSession,
+ );
/** Hide until the branch has data — keeps a fresh, message-less chat clean and
* lets the indicator animate into view once the first tokens land. */
@@ -104,7 +120,12 @@ function TokenUsageIndicator({
aria-label={localize('com_ui_context_usage')}
className="z-[200] rounded-xl border border-border-medium bg-surface-secondary p-3 shadow-lg focus:outline-none"
>
-
+
>
);
@@ -124,6 +145,7 @@ const TokenUsage = memo(function TokenUsage(props: TokenUsageProps) {
{...props}
showCost={startupConfig.interface?.contextCost === true}
currency={startupConfig.interface?.currency}
+ langfuseConnectionAccess={startupConfig.langfuseConnectionAccess === true}
/>
);
});
diff --git a/client/src/data-provider/Langfuse/index.ts b/client/src/data-provider/Langfuse/index.ts
index 75edb51236..7c267134ed 100644
--- a/client/src/data-provider/Langfuse/index.ts
+++ b/client/src/data-provider/Langfuse/index.ts
@@ -5,6 +5,7 @@ import type {
TUpdateLangfuseConnectionRequest,
TLangfuseConnectionTestRequest,
TLangfuseConnectionTestResponse,
+ TLangfuseSessionLinkResponse,
} from 'librechat-data-provider';
import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query';
@@ -17,6 +18,16 @@ export const useGetLangfuseConnectionQuery = (
{ enabled, refetchOnWindowFocus: false },
);
+export const useGetLangfuseSessionLinkQuery = (
+ conversationId: string,
+ enabled = true,
+): UseQueryResult =>
+ useQuery(
+ [QueryKeys.langfuseSessionLink, conversationId],
+ () => dataService.getLangfuseSessionLink(conversationId),
+ { enabled, refetchOnWindowFocus: false },
+ );
+
export const useUpdateLangfuseConnectionMutation = (): UseMutationResult<
TLangfuseConnectionStatus,
unknown,
@@ -29,6 +40,7 @@ export const useUpdateLangfuseConnectionMutation = (): UseMutationResult<
mutationKey: [MutationKeys.updateLangfuseConnection],
onSuccess: (data) => {
queryClient.setQueryData([QueryKeys.langfuseConnection], data);
+ queryClient.removeQueries([QueryKeys.langfuseSessionLink]);
},
},
);
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index 8db48f731a..1685bfcb4b 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -1410,6 +1410,7 @@
"com_ui_langfuse_test_unexpected_response": "Langfuse returned an unexpected response",
"com_ui_langfuse_testing": "Testing connection",
"com_ui_langfuse_title": "Langfuse connection",
+ "com_ui_langfuse_view_session": "View session in Langfuse",
"com_ui_latest": "latest",
"com_ui_latest_activity": "Latest activity",
"com_ui_latest_footer": "Every AI for Everyone.",
diff --git a/packages/api/src/admin/langfuse.handler.spec.ts b/packages/api/src/admin/langfuse.handler.spec.ts
index c08b9af7f7..b316aca328 100644
--- a/packages/api/src/admin/langfuse.handler.spec.ts
+++ b/packages/api/src/admin/langfuse.handler.spec.ts
@@ -8,6 +8,7 @@ import type { ServerRequest } from '~/types/http';
// after CREDS_KEY is set above (encryptV3 reads the key at module load).
let encryptV3: typeof import('@librechat/data-schemas').encryptV3;
let createAdminLangfuseHandlers: typeof import('./langfuse').createAdminLangfuseHandlers;
+let getLangfuseDestinationId: typeof import('../langfuse/destinations').getLangfuseDestinationId;
const realFetch = global.fetch;
function projectResponse(projectId = 'project-1') {
@@ -21,6 +22,7 @@ function projectResponse(projectId = 'project-1') {
beforeAll(async () => {
({ encryptV3 } = await import('@librechat/data-schemas'));
({ createAdminLangfuseHandlers } = await import('./langfuse'));
+ ({ getLangfuseDestinationId } = await import('../langfuse/destinations'));
});
beforeEach(() => {
@@ -33,6 +35,7 @@ beforeEach(() => {
afterEach(() => {
delete process.env.LANGFUSE_FANOUT_ENABLED;
delete process.env.LANGFUSE_FANOUT_COLLECTOR_URL;
+ delete process.env.LANGFUSE_FANOUT_TENANT_EU_BASE_URL;
delete process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED;
delete process.env.LANGFUSE_PUBLIC_KEY;
delete process.env.LANGFUSE_SECRET_KEY;
@@ -101,6 +104,7 @@ function createHandlers(overrides = {}) {
isActive,
}),
),
+ getMessages: jest.fn().mockResolvedValue([]),
invalidateConfigCaches: jest.fn().mockResolvedValue(undefined),
...overrides,
};
@@ -297,6 +301,160 @@ describe('createAdminLangfuseHandlers', () => {
});
});
+ describe('getSessionLink', () => {
+ const storedConnection = {
+ enabled: true,
+ destination: 'eu',
+ projectId: 'project-1',
+ publicKey: 'pk-lf-1',
+ secretKey: 'encrypted-secret',
+ };
+
+ it('returns the session URL when this user has a sampled message for the project', async () => {
+ const { handlers, deps } = createHandlers({
+ findConfigByPrincipal: jest.fn().mockResolvedValue(baseConfigDoc(storedConnection)),
+ getMessages: jest.fn().mockResolvedValue([{ _id: 'message-1' }]),
+ });
+ const res = mockRes();
+
+ await handlers.getSessionLink(mockReq({ params: { conversationId: 'conversation-1' } }), res);
+
+ expect(res.statusCode).toBe(200);
+ expect(res.body).toEqual({
+ url: 'https://cloud.langfuse.com/project/project-1/sessions/conversation-1',
+ });
+ expect(deps.getMessages).toHaveBeenCalledWith(
+ {
+ user: 'u1',
+ conversationId: 'conversation-1',
+ langfuseSampled: true,
+ langfuseDestinationIds: getLangfuseDestinationId(
+ 'https://cloud.langfuse.com',
+ 'project-1',
+ ),
+ },
+ '_id',
+ { sort: false, limit: 1 },
+ );
+ });
+
+ it("links to the tenant project resolved from that tenant's API keys in fanout mode", async () => {
+ let persistedConfig: ReturnType | null = null;
+ const findConfigByPrincipal = jest
+ .fn()
+ .mockImplementation(() => Promise.resolve(persistedConfig));
+ const patchConfigFields = jest.fn().mockImplementation((_pt, _pid, _pm, fields) => {
+ persistedConfig = baseConfigDoc(rehydrate(fields));
+ return Promise.resolve(persistedConfig);
+ });
+ const getMessages = jest.fn().mockResolvedValue([{ _id: 'message-1' }]);
+ global.fetch = jest
+ .fn()
+ .mockResolvedValue(projectResponse('tenant-project-1')) as unknown as typeof fetch;
+ const { handlers } = createHandlers({
+ findConfigByPrincipal,
+ patchConfigFields,
+ getMessages,
+ });
+
+ const updateRes = mockRes();
+ await handlers.updateConnection(
+ mockReq({
+ body: {
+ enabled: true,
+ destination: 'eu',
+ publicKey: 'pk-lf-tenant',
+ secretKey: 'sk-lf-tenant',
+ },
+ }),
+ updateRes,
+ );
+
+ expect(updateRes.statusCode).toBe(200);
+ const [projectsUrl, projectsInit] = (global.fetch as unknown as jest.Mock).mock.calls[0];
+ expect(projectsUrl).toBe('https://cloud.langfuse.com/api/public/projects');
+ expect(
+ Buffer.from(projectsInit.headers.Authorization.replace('Basic ', ''), 'base64').toString(),
+ ).toBe('pk-lf-tenant:sk-lf-tenant');
+ expect(patchConfigFields.mock.calls[0][3]['langfuse.projectId']).toBe('tenant-project-1');
+
+ const linkRes = mockRes();
+ await handlers.getSessionLink(
+ mockReq({ params: { conversationId: 'conversation-1' } }),
+ linkRes,
+ );
+
+ expect(linkRes.body).toEqual({
+ url: 'https://cloud.langfuse.com/project/tenant-project-1/sessions/conversation-1',
+ });
+ expect(getMessages).toHaveBeenCalledWith(
+ expect.objectContaining({
+ langfuseDestinationIds: getLangfuseDestinationId(
+ 'https://cloud.langfuse.com',
+ 'tenant-project-1',
+ ),
+ }),
+ '_id',
+ { sort: false, limit: 1 },
+ );
+ });
+
+ it('preserves a destination base path in the session URL', async () => {
+ process.env.LANGFUSE_FANOUT_TENANT_EU_BASE_URL = 'https://langfuse.example/base/path';
+ const { handlers } = createHandlers({
+ findConfigByPrincipal: jest.fn().mockResolvedValue(baseConfigDoc(storedConnection)),
+ getMessages: jest.fn().mockResolvedValue([{ _id: 'message-1' }]),
+ });
+ const res = mockRes();
+
+ await handlers.getSessionLink(mockReq({ params: { conversationId: 'conversation-1' } }), res);
+
+ expect(res.body).toEqual({
+ url: 'https://langfuse.example/base/path/project/project-1/sessions/conversation-1',
+ });
+ });
+
+ it('returns 401 when the authenticated user is missing', async () => {
+ const { handlers } = createHandlers();
+ const res = mockRes();
+
+ await handlers.getSessionLink(
+ mockReq({ user: undefined, params: { conversationId: 'conversation-1' } }),
+ res,
+ );
+
+ expect(res.statusCode).toBe(401);
+ expect(res.body).toEqual({ error: 'Authentication required' });
+ });
+
+ it('does not link a conversation without a sampled message for the current project', async () => {
+ const { handlers, deps } = createHandlers({
+ findConfigByPrincipal: jest.fn().mockResolvedValue(baseConfigDoc(storedConnection)),
+ });
+ const res = mockRes();
+
+ await handlers.getSessionLink(mockReq({ params: { conversationId: 'conversation-1' } }), res);
+
+ expect(res.statusCode).toBe(200);
+ expect(res.body).toEqual({ url: null });
+ expect(deps.getMessages).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not query messages when the saved connection is disabled', async () => {
+ const { handlers, deps } = createHandlers({
+ findConfigByPrincipal: jest
+ .fn()
+ .mockResolvedValue(baseConfigDoc({ ...storedConnection, enabled: false })),
+ });
+ const res = mockRes();
+
+ await handlers.getSessionLink(mockReq({ params: { conversationId: 'conversation-1' } }), res);
+
+ expect(res.body).toEqual({ url: null });
+ expect(deps.getMessages).not.toHaveBeenCalled();
+ });
+ });
+
describe('updateConnection', () => {
it('requires destination', async () => {
const { handlers } = createHandlers();
diff --git a/packages/api/src/admin/langfuse.ts b/packages/api/src/admin/langfuse.ts
index 8ae3c6dbc9..f7906d34f4 100644
--- a/packages/api/src/admin/langfuse.ts
+++ b/packages/api/src/admin/langfuse.ts
@@ -8,8 +8,9 @@ import type {
TLangfuseConnectionTestErrorCode,
TLangfuseConnectionTestRequest,
TLangfuseConnectionTestResponse,
+ TLangfuseSessionLinkResponse,
} from 'librechat-data-provider';
-import type { IConfig } from '@librechat/data-schemas';
+import type { IConfig, MessageMethods } from '@librechat/data-schemas';
import type { Types, ClientSession } from 'mongoose';
import type { Response } from 'express';
import type { LangfuseTenantDestination } from '~/langfuse/tenantDestinations';
@@ -19,6 +20,7 @@ import {
resolveLangfuseTenantDestination,
} from '~/langfuse/tenantDestinations';
import { decryptConfigSecret, encryptConfigSecretFields } from './secrets';
+import { getLangfuseDestinationId } from '~/langfuse/destinations';
import { isLangfuseConnectionAvailable } from '~/langfuse/policy';
const DEFAULT_PRIORITY = 10;
@@ -46,6 +48,7 @@ export interface AdminLangfuseDeps {
isActive: boolean,
session?: ClientSession,
) => Promise;
+ getMessages: MessageMethods['getMessages'];
invalidateConfigCaches?: (tenantId?: string) => Promise;
}
@@ -223,11 +226,17 @@ async function verifyLangfuseCredentials(
*/
export function createAdminLangfuseHandlers(deps: AdminLangfuseDeps): {
getConnection: (req: ServerRequest, res: Response) => Promise;
+ getSessionLink: (req: ServerRequest, res: Response) => Promise;
updateConnection: (req: ServerRequest, res: Response) => Promise;
testConnection: (req: ServerRequest, res: Response) => Promise;
} {
- const { findConfigByPrincipal, patchConfigFields, toggleConfigActive, invalidateConfigCaches } =
- deps;
+ const {
+ findConfigByPrincipal,
+ patchConfigFields,
+ toggleConfigActive,
+ getMessages,
+ invalidateConfigCaches,
+ } = deps;
function findBaseConfig(options?: { includeInactive?: boolean }): Promise {
return options
@@ -250,6 +259,57 @@ export function createAdminLangfuseHandlers(deps: AdminLangfuseDeps): {
}
}
+ async function getSessionLink(req: ServerRequest, res: Response): Promise {
+ const disabledResponse = rejectWhenConnectionUnavailable(res);
+ if (disabledResponse) {
+ return disabledResponse;
+ }
+
+ const conversationId = (req.params as { conversationId?: string }).conversationId?.trim();
+ const userId = req.user?.id ?? req.user?._id?.toString();
+ if (!userId) {
+ return res.status(401).json({ error: 'Authentication required' });
+ }
+ if (!conversationId) {
+ return res.status(400).json({ error: 'conversationId is required' });
+ }
+
+ try {
+ const stored = readStoredLangfuse(await findBaseConfig());
+ const destination = resolveLangfuseTenantDestination(stored?.destination);
+ const projectId = stored?.projectId?.trim();
+ if (stored?.enabled !== true || !destination || !projectId) {
+ const response: TLangfuseSessionLinkResponse = { url: null };
+ return res.status(200).json(response);
+ }
+
+ const destinationId = getLangfuseDestinationId(destination.baseUrl, projectId);
+ const messages = await getMessages(
+ {
+ user: userId,
+ conversationId,
+ langfuseSampled: true,
+ langfuseDestinationIds: destinationId,
+ },
+ '_id',
+ { sort: false, limit: 1 },
+ );
+ if (messages.length === 0) {
+ const response: TLangfuseSessionLinkResponse = { url: null };
+ return res.status(200).json(response);
+ }
+
+ const sessionUrl = new URL(destination.baseUrl);
+ const basePath = sessionUrl.pathname.replace(/\/+$/, '');
+ sessionUrl.pathname = `${basePath}/project/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(conversationId)}`;
+ const response: TLangfuseSessionLinkResponse = { url: sessionUrl.toString() };
+ return res.status(200).json(response);
+ } catch (error) {
+ logger.error('[adminLangfuse] getSessionLink error:', error);
+ return res.status(500).json({ error: 'Failed to resolve Langfuse session' });
+ }
+ }
+
async function updateConnection(req: ServerRequest, res: Response): Promise {
const disabledResponse = rejectWhenConnectionUnavailable(res);
if (disabledResponse) {
@@ -418,5 +478,5 @@ export function createAdminLangfuseHandlers(deps: AdminLangfuseDeps): {
}
}
- return { getConnection, updateConnection, testConnection };
+ return { getConnection, getSessionLink, updateConnection, testConnection };
}
diff --git a/packages/api/src/langfuse/destinations.ts b/packages/api/src/langfuse/destinations.ts
index 40562198d1..d682347a69 100644
--- a/packages/api/src/langfuse/destinations.ts
+++ b/packages/api/src/langfuse/destinations.ts
@@ -29,7 +29,7 @@ export type LangfuseScoreDestination = {
authorization: string;
};
-function getDestinationId(baseUrl: string, projectId: string): string {
+export function getLangfuseDestinationId(baseUrl: string, projectId: string): string {
return createHash('sha256')
.update(`${baseUrl.replace(/\/+$/, '')}\n${projectId}`)
.digest('hex');
@@ -127,7 +127,7 @@ async function getCentralScoreDestination(
const baseUrl = getCentralEnvBaseUrl();
const projectId = await resolveCentralProjectId(baseUrl, publicKey, secretKey, waitForProjectId);
return {
- id: projectId ? getDestinationId(baseUrl, projectId) : undefined,
+ id: projectId ? getLangfuseDestinationId(baseUrl, projectId) : undefined,
name: 'central',
baseUrl,
authorization: toBasicAuthorization(publicKey, secretKey),
@@ -161,7 +161,9 @@ function getTenantScoreDestination(appConfig?: AppConfig): LangfuseScoreDestinat
}
return {
- id: config?.projectId ? getDestinationId(destination.baseUrl, config.projectId) : undefined,
+ id: config?.projectId
+ ? getLangfuseDestinationId(destination.baseUrl, config.projectId)
+ : undefined,
name: 'tenant',
baseUrl: destination.baseUrl,
authorization: toBasicAuthorization(tenantCredentials.publicKey, tenantCredentials.secretKey),
@@ -183,7 +185,9 @@ function getConfiguredScoreDestination(
}
return {
- id: config?.projectId ? getDestinationId(destination.baseUrl, config.projectId) : undefined,
+ id: config?.projectId
+ ? getLangfuseDestinationId(destination.baseUrl, config.projectId)
+ : undefined,
name: 'connection',
baseUrl: destination.baseUrl,
authorization: toBasicAuthorization(credentials.publicKey, credentials.secretKey),
diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts
index 000a9079e2..a286db2a24 100644
--- a/packages/data-provider/src/api-endpoints.ts
+++ b/packages/data-provider/src/api-endpoints.ts
@@ -443,6 +443,8 @@ export const skillStates = () => `${BASE_URL}/api/user/settings/skills/active`;
/* Langfuse connection (admin) */
export const adminLangfuseConnection = () => `${BASE_URL}/api/admin/langfuse/connection`;
export const adminLangfuseConnectionTest = () => `${adminLangfuseConnection()}/test`;
+export const adminLangfuseSessionLink = (conversationId: string) =>
+ `${adminLangfuseConnection()}/session/${encodeURIComponent(conversationId)}`;
/* Tool favorites (starred marketplace items) */
export const toolFavorites = () => `${BASE_URL}/api/user/settings/favorites/tools`;
diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts
index 9eea22588e..f132b8e90e 100644
--- a/packages/data-provider/src/data-service.ts
+++ b/packages/data-provider/src/data-service.ts
@@ -32,6 +32,12 @@ export function testLangfuseConnection(
return request.post(endpoints.adminLangfuseConnectionTest(), payload);
}
+export function getLangfuseSessionLink(
+ conversationId: string,
+): Promise {
+ return request.get(endpoints.adminLangfuseSessionLink(conversationId));
+}
+
export function revokeUserKey(name: string): Promise {
return request.delete(endpoints.revokeUserKey(name));
}
diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts
index f0a122da5d..aec853c863 100644
--- a/packages/data-provider/src/keys.ts
+++ b/packages/data-provider/src/keys.ts
@@ -9,6 +9,7 @@ export enum QueryKeys {
conversation = 'conversation',
searchEnabled = 'searchEnabled',
langfuseConnection = 'langfuseConnection',
+ langfuseSessionLink = 'langfuseSessionLink',
user = 'user',
name = 'name', // user key name
models = 'models',
diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts
index d9dbfe8390..3c54252012 100644
--- a/packages/data-provider/src/types.ts
+++ b/packages/data-provider/src/types.ts
@@ -940,3 +940,7 @@ export type TLangfuseConnectionTestErrorCode =
export type TLangfuseConnectionTestResponse =
| { success: true }
| { success: false; errorCode: TLangfuseConnectionTestErrorCode };
+
+export type TLangfuseSessionLinkResponse = {
+ url: string | null;
+};