@@ -331,3 +331,177 @@ test('setUserId logs when span attributes fail', () => {
331331 assert . equal ( errors [ 0 ] ?. msg , 'xray: setUserId failed' ) ;
332332 assert . equal ( errors [ 0 ] ?. fields ?. [ 'error' ] , 'setAttribute failed' ) ;
333333} ) ;
334+
335+ test ( 'setTag records tags in log and span' , async ( ) => {
336+ const spans : ReadableSpan [ ] = [ ] ;
337+ const xray = createEmitter (
338+ {
339+ serviceName : 'test' ,
340+ endpointUrl : 'https://collector' ,
341+ exporter : { spanProcessor : 'simple' } ,
342+ } ,
343+ createRecordingExporter ( spans ) ,
344+ ) ;
345+
346+ const ctx = xray . startRequest ( {
347+ method : 'GET' ,
348+ url : 'https://example.test/tags' ,
349+ headers : { } ,
350+ startTimeMs : 0 ,
351+ } ) ;
352+ ctx . setTag ( 'environment' , 'staging' ) ;
353+ ctx . setTag ( 'region' , 'us-east-1' ) ;
354+ ctx . setTag ( 'retries' , 3 ) ;
355+ ctx . setTag ( 'verbose' , true ) ;
356+
357+ const log = xray . endRequest ( ctx , {
358+ statusCode : 200 ,
359+ headers : { } ,
360+ endTimeMs : 5 ,
361+ } ) ;
362+
363+ await xray . flush ( ) ;
364+ assert . deepEqual ( log . tags , {
365+ environment : 'staging' ,
366+ region : 'us-east-1' ,
367+ retries : 3 ,
368+ verbose : true ,
369+ } ) ;
370+ const tagsAttr = spans [ 0 ] ?. attributes [ 'stainlessxray.internal.tags' ] ;
371+ assert . equal ( typeof tagsAttr , 'string' ) ;
372+ assert . deepEqual ( JSON . parse ( tagsAttr as string ) , {
373+ environment : 'staging' ,
374+ region : 'us-east-1' ,
375+ retries : 3 ,
376+ verbose : true ,
377+ } ) ;
378+ } ) ;
379+
380+ test ( 'setTag overwrites previous value for same key' , ( ) => {
381+ const xray = createEmitter (
382+ {
383+ serviceName : 'test' ,
384+ endpointUrl : 'https://collector' ,
385+ } ,
386+ createNoopExporter ( ) ,
387+ ) ;
388+
389+ const ctx = xray . startRequest ( {
390+ method : 'GET' ,
391+ url : 'https://example.test/tags-overwrite' ,
392+ headers : { } ,
393+ startTimeMs : 0 ,
394+ } ) ;
395+ ctx . setTag ( 'env' , 'dev' ) ;
396+ ctx . setTag ( 'env' , 'prod' ) ;
397+
398+ const log = xray . endRequest ( ctx , {
399+ statusCode : 200 ,
400+ headers : { } ,
401+ endTimeMs : 5 ,
402+ } ) ;
403+
404+ assert . deepEqual ( log . tags , { env : 'prod' } ) ;
405+ } ) ;
406+
407+ test ( 'tags omitted from log and span when empty' , async ( ) => {
408+ const spans : ReadableSpan [ ] = [ ] ;
409+ const xray = createEmitter (
410+ {
411+ serviceName : 'test' ,
412+ endpointUrl : 'https://collector' ,
413+ exporter : { spanProcessor : 'simple' } ,
414+ } ,
415+ createRecordingExporter ( spans ) ,
416+ ) ;
417+
418+ const ctx = xray . startRequest ( {
419+ method : 'GET' ,
420+ url : 'https://example.test/no-tags' ,
421+ headers : { } ,
422+ startTimeMs : 0 ,
423+ } ) ;
424+
425+ const log = xray . endRequest ( ctx , {
426+ statusCode : 200 ,
427+ headers : { } ,
428+ endTimeMs : 5 ,
429+ } ) ;
430+
431+ await xray . flush ( ) ;
432+ assert . equal ( log . tags , undefined ) ;
433+ assert . equal ( 'stainlessxray.internal.tags' in ( spans [ 0 ] ?. attributes ?? { } ) , false ) ;
434+ } ) ;
435+
436+ test ( 'setTag treats __proto__ and constructor as plain keys' , async ( ) => {
437+ const spans : ReadableSpan [ ] = [ ] ;
438+ const xray = createEmitter (
439+ {
440+ serviceName : 'test' ,
441+ endpointUrl : 'https://collector' ,
442+ exporter : { spanProcessor : 'simple' } ,
443+ } ,
444+ createRecordingExporter ( spans ) ,
445+ ) ;
446+
447+ const ctx = xray . startRequest ( {
448+ method : 'GET' ,
449+ url : 'https://example.test/proto-tags' ,
450+ headers : { } ,
451+ startTimeMs : 0 ,
452+ } ) ;
453+ ctx . setTag ( '__proto__' , 'poisoned' ) ;
454+ ctx . setTag ( 'constructor' , 'overwritten' ) ;
455+ ctx . setTag ( 'toString' , 42 ) ;
456+
457+ const log = xray . endRequest ( ctx , {
458+ statusCode : 200 ,
459+ headers : { } ,
460+ endTimeMs : 5 ,
461+ } ) ;
462+
463+ await xray . flush ( ) ;
464+ assert . equal ( log . tags ?. [ '__proto__' ] , 'poisoned' ) ;
465+ assert . equal ( log . tags ?. [ 'constructor' ] , 'overwritten' ) ;
466+ assert . equal ( log . tags ?. [ 'toString' ] , 42 ) ;
467+ assert . equal ( Object . keys ( log . tags ! ) . length , 3 ) ;
468+ const parsed = JSON . parse ( spans [ 0 ] ?. attributes [ 'stainlessxray.internal.tags' ] as string ) ;
469+ assert . equal ( parsed [ '__proto__' ] , 'poisoned' ) ;
470+ assert . equal ( parsed [ 'constructor' ] , 'overwritten' ) ;
471+ assert . equal ( parsed [ 'toString' ] , 42 ) ;
472+ } ) ;
473+
474+ test ( 'setTag filters non-JSON-safe values from span attribute' , async ( ) => {
475+ const spans : ReadableSpan [ ] = [ ] ;
476+ const xray = createEmitter (
477+ {
478+ serviceName : 'test' ,
479+ endpointUrl : 'https://collector' ,
480+ exporter : { spanProcessor : 'simple' } ,
481+ } ,
482+ createRecordingExporter ( spans ) ,
483+ ) ;
484+
485+ const ctx = xray . startRequest ( {
486+ method : 'GET' ,
487+ url : 'https://example.test/unsafe-tags' ,
488+ headers : { } ,
489+ startTimeMs : 0 ,
490+ } ) ;
491+ ctx . setTag ( 'safe' , 'kept' ) ;
492+ ctx . setTag ( 'bigint' as string , BigInt ( 42 ) as never ) ;
493+
494+ const log = xray . endRequest ( ctx , {
495+ statusCode : 200 ,
496+ headers : { } ,
497+ endTimeMs : 5 ,
498+ } ) ;
499+
500+ await xray . flush ( ) ;
501+ // The log retains the raw tags as-is
502+ assert . equal ( log . tags ?. [ 'safe' ] , 'kept' ) ;
503+ assert . equal ( log . tags ?. [ 'bigint' ] , BigInt ( 42 ) ) ;
504+ // The span attribute only contains the JSON-safe subset
505+ const parsed = JSON . parse ( spans [ 0 ] ?. attributes [ 'stainlessxray.internal.tags' ] as string ) ;
506+ assert . deepEqual ( parsed , { safe : 'kept' } ) ;
507+ } ) ;
0 commit comments