@@ -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
@@ -477,6 +486,70 @@ describe('Langfuse integration', () => {
477486 } )
478487 } )
479488
489+ describe ( 'createTrace with username' , ( ) => {
490+ test ( 'sets user.id attribute when username is provided' , async ( ) => {
491+ process . env . LANGFUSE_PUBLIC_KEY = 'pk-test'
492+ process . env . LANGFUSE_SECRET_KEY = 'sk-test'
493+ mockSetAttribute . mockClear ( )
494+ const { createTrace } = await import ( '../tracing.js' )
495+ const span = createTrace ( {
496+ sessionId : 's1' ,
497+ model : 'claude-3' ,
498+ provider : 'firstParty' ,
499+ username : 'user@example.com' ,
500+ } )
501+ expect ( span ) . not . toBeNull ( )
502+ expect ( mockSetAttribute ) . toHaveBeenCalledWith ( 'user.id' , 'user@example.com' )
503+ } )
504+
505+ test ( 'falls back to LANGFUSE_USER_ID env when username not provided' , async ( ) => {
506+ process . env . LANGFUSE_PUBLIC_KEY = 'pk-test'
507+ process . env . LANGFUSE_SECRET_KEY = 'sk-test'
508+ process . env . LANGFUSE_USER_ID = 'env-user@test.com'
509+ mockSetAttribute . mockClear ( )
510+ const { createTrace } = await import ( '../tracing.js' )
511+ const span = createTrace ( {
512+ sessionId : 's1' ,
513+ model : 'claude-3' ,
514+ provider : 'firstParty' ,
515+ } )
516+ expect ( span ) . not . toBeNull ( )
517+ expect ( mockSetAttribute ) . toHaveBeenCalledWith ( 'user.id' , 'env-user@test.com' )
518+ delete process . env . LANGFUSE_USER_ID
519+ } )
520+
521+ test ( 'falls back to deviceId when neither username nor env is provided' , async ( ) => {
522+ process . env . LANGFUSE_PUBLIC_KEY = 'pk-test'
523+ process . env . LANGFUSE_SECRET_KEY = 'sk-test'
524+ delete process . env . LANGFUSE_USER_ID
525+ mockSetAttribute . mockClear ( )
526+ const { createTrace } = await import ( '../tracing.js' )
527+ createTrace ( { sessionId : 's1' , model : 'claude-3' , provider : 'firstParty' } )
528+ // Falls back to getCoreUserData().deviceId (mocked as 'test-device-id')
529+ expect ( mockSetAttribute ) . toHaveBeenCalledWith ( 'user.id' , 'test-device-id' )
530+ } )
531+
532+ test ( 'username takes precedence over LANGFUSE_USER_ID env' , async ( ) => {
533+ process . env . LANGFUSE_PUBLIC_KEY = 'pk-test'
534+ process . env . LANGFUSE_SECRET_KEY = 'sk-test'
535+ process . env . LANGFUSE_USER_ID = 'env-user@test.com'
536+ mockSetAttribute . mockClear ( )
537+ const { createTrace } = await import ( '../tracing.js' )
538+ createTrace ( {
539+ sessionId : 's1' ,
540+ model : 'claude-3' ,
541+ provider : 'firstParty' ,
542+ username : 'param-user@test.com' ,
543+ } )
544+ const userIdCalls = mockSetAttribute . mock . calls . filter (
545+ ( call : unknown [ ] ) => Array . isArray ( call ) && call [ 0 ] === 'user.id' ,
546+ )
547+ expect ( userIdCalls . length ) . toBe ( 1 )
548+ expect ( ( userIdCalls [ 0 ] as unknown [ ] ) [ 1 ] ) . toBe ( 'param-user@test.com' )
549+ delete process . env . LANGFUSE_USER_ID
550+ } )
551+ } )
552+
480553 describe ( 'nested agent scenario' , ( ) => {
481554 test ( 'sub-agent trace shares sessionId with parent' , async ( ) => {
482555 process . env . LANGFUSE_PUBLIC_KEY = 'pk-test'
0 commit comments