@@ -29,6 +29,7 @@ const mockRootEnd = mock(() => {})
2929// Mock LangfuseOtelSpanAttributes (re-exported from @langfuse/core)
3030const mockLangfuseOtelSpanAttributes : Record < string , string > = {
3131 TRACE_SESSION_ID : 'session.id' ,
32+ TRACE_USER_ID : 'user.id' ,
3233 OBSERVATION_TYPE : 'observation.type' ,
3334 OBSERVATION_INPUT : 'observation.input' ,
3435 OBSERVATION_OUTPUT : 'observation.output' ,
@@ -74,6 +75,14 @@ mock.module('src/utils/debug.js', () => ({
7475 logForDebugging : mock ( ( ) => { } ) ,
7576} ) )
7677
78+ // Mock user data — resolveLangfuseUserId uses getCoreUserData().email and .deviceId
79+ mock . module ( 'src/utils/user.js' , ( ) => ( {
80+ getCoreUserData : mock ( ( ) => ( {
81+ email : 'test-device-id' ,
82+ deviceId : 'test-device-id' ,
83+ } ) ) ,
84+ } ) )
85+
7786describe ( 'Langfuse integration' , ( ) => {
7887 beforeEach ( ( ) => {
7988 // Reset env
@@ -275,6 +284,48 @@ describe('Langfuse integration', () => {
275284 } ) )
276285 expect ( mockRootEnd ) . toHaveBeenCalled ( )
277286 } )
287+
288+ test ( 'includes cache tokens in usageDetails when provided' , async ( ) => {
289+ process . env . LANGFUSE_PUBLIC_KEY = 'pk-test'
290+ process . env . LANGFUSE_SECRET_KEY = 'sk-test'
291+ const { createTrace, recordLLMObservation } = await import ( '../tracing.js' )
292+ const span = createTrace ( { sessionId : 's1' , model : 'claude-3' , provider : 'firstParty' } )
293+ mockStartObservation . mockClear ( )
294+ mockRootUpdate . mockClear ( )
295+ recordLLMObservation ( span , {
296+ model : 'claude-3' ,
297+ provider : 'firstParty' ,
298+ input : [ ] ,
299+ output : [ ] ,
300+ usage : { input_tokens : 10000 , output_tokens : 50 , cache_creation_input_tokens : 2000 , cache_read_input_tokens : 7000 } ,
301+ } )
302+ expect ( mockRootUpdate ) . toHaveBeenCalledWith ( expect . objectContaining ( {
303+ usageDetails : {
304+ input : 19000 , // 10000 + 2000 + 7000
305+ output : 50 ,
306+ cache_read : 7000 ,
307+ cache_creation : 2000 ,
308+ } ,
309+ } ) )
310+ } )
311+
312+ test ( 'omits cache fields when not provided' , async ( ) => {
313+ process . env . LANGFUSE_PUBLIC_KEY = 'pk-test'
314+ process . env . LANGFUSE_SECRET_KEY = 'sk-test'
315+ const { createTrace, recordLLMObservation } = await import ( '../tracing.js' )
316+ const span = createTrace ( { sessionId : 's1' , model : 'claude-3' , provider : 'firstParty' } )
317+ mockRootUpdate . mockClear ( )
318+ recordLLMObservation ( span , {
319+ model : 'claude-3' ,
320+ provider : 'firstParty' ,
321+ input : [ ] ,
322+ output : [ ] ,
323+ usage : { input_tokens : 100 , output_tokens : 20 } ,
324+ } )
325+ expect ( mockRootUpdate ) . toHaveBeenCalledWith ( expect . objectContaining ( {
326+ usageDetails : { input : 100 , output : 20 } ,
327+ } ) )
328+ } )
278329 } )
279330
280331 describe ( 'recordToolObservation' , ( ) => {
@@ -477,6 +528,70 @@ describe('Langfuse integration', () => {
477528 } )
478529 } )
479530
531+ describe ( 'createTrace with username' , ( ) => {
532+ test ( 'sets user.id attribute when username is provided' , async ( ) => {
533+ process . env . LANGFUSE_PUBLIC_KEY = 'pk-test'
534+ process . env . LANGFUSE_SECRET_KEY = 'sk-test'
535+ mockSetAttribute . mockClear ( )
536+ const { createTrace } = await import ( '../tracing.js' )
537+ const span = createTrace ( {
538+ sessionId : 's1' ,
539+ model : 'claude-3' ,
540+ provider : 'firstParty' ,
541+ username : 'user@example.com' ,
542+ } )
543+ expect ( span ) . not . toBeNull ( )
544+ expect ( mockSetAttribute ) . toHaveBeenCalledWith ( 'user.id' , 'user@example.com' )
545+ } )
546+
547+ test ( 'falls back to LANGFUSE_USER_ID env when username not provided' , async ( ) => {
548+ process . env . LANGFUSE_PUBLIC_KEY = 'pk-test'
549+ process . env . LANGFUSE_SECRET_KEY = 'sk-test'
550+ process . env . LANGFUSE_USER_ID = 'env-user@test.com'
551+ mockSetAttribute . mockClear ( )
552+ const { createTrace } = await import ( '../tracing.js' )
553+ const span = createTrace ( {
554+ sessionId : 's1' ,
555+ model : 'claude-3' ,
556+ provider : 'firstParty' ,
557+ } )
558+ expect ( span ) . not . toBeNull ( )
559+ expect ( mockSetAttribute ) . toHaveBeenCalledWith ( 'user.id' , 'env-user@test.com' )
560+ delete process . env . LANGFUSE_USER_ID
561+ } )
562+
563+ test ( 'falls back to deviceId when neither username nor env is provided' , async ( ) => {
564+ process . env . LANGFUSE_PUBLIC_KEY = 'pk-test'
565+ process . env . LANGFUSE_SECRET_KEY = 'sk-test'
566+ delete process . env . LANGFUSE_USER_ID
567+ mockSetAttribute . mockClear ( )
568+ const { createTrace } = await import ( '../tracing.js' )
569+ createTrace ( { sessionId : 's1' , model : 'claude-3' , provider : 'firstParty' } )
570+ // Falls back to getCoreUserData().deviceId (mocked as 'test-device-id')
571+ expect ( mockSetAttribute ) . toHaveBeenCalledWith ( 'user.id' , 'test-device-id' )
572+ } )
573+
574+ test ( 'username takes precedence over LANGFUSE_USER_ID env' , async ( ) => {
575+ process . env . LANGFUSE_PUBLIC_KEY = 'pk-test'
576+ process . env . LANGFUSE_SECRET_KEY = 'sk-test'
577+ process . env . LANGFUSE_USER_ID = 'env-user@test.com'
578+ mockSetAttribute . mockClear ( )
579+ const { createTrace } = await import ( '../tracing.js' )
580+ createTrace ( {
581+ sessionId : 's1' ,
582+ model : 'claude-3' ,
583+ provider : 'firstParty' ,
584+ username : 'param-user@test.com' ,
585+ } )
586+ const userIdCalls = mockSetAttribute . mock . calls . filter (
587+ ( call : unknown [ ] ) => Array . isArray ( call ) && call [ 0 ] === 'user.id' ,
588+ )
589+ expect ( userIdCalls . length ) . toBe ( 1 )
590+ expect ( ( userIdCalls [ 0 ] as unknown [ ] ) [ 1 ] ) . toBe ( 'param-user@test.com' )
591+ delete process . env . LANGFUSE_USER_ID
592+ } )
593+ } )
594+
480595 describe ( 'nested agent scenario' , ( ) => {
481596 test ( 'sub-agent trace shares sessionId with parent' , async ( ) => {
482597 process . env . LANGFUSE_PUBLIC_KEY = 'pk-test'
0 commit comments