@@ -70,6 +70,7 @@ vi.mock('../../../../src/defaultScopes', () => ({
7070
7171const mockSpans : MockSpan [ ] = [ ] ;
7272beforeEach ( ( ) => ( mockSpans . length = 0 ) ) ;
73+ beforeEach ( ( ) => ( transactionNames . length = 0 ) ) ;
7374class MockSpan {
7475 ended = false ;
7576 status : { code : number ; message : string } = { code : 0 , message : 'OK' } ;
@@ -131,12 +132,34 @@ const checkSpans = (expectations: Partial<MockSpanJSON>[]) => {
131132} ;
132133
133134let hasActiveSpan = true ;
134- const parentSpan = { } ;
135+ // Stands in for the root `http.server` span so the route-to-root-span write can be asserted.
136+ const parentSpan = {
137+ name : 'GET' ,
138+ attributes : { 'sentry.op' : 'http.server' } as Record < string , unknown > ,
139+ updateName ( name : string ) {
140+ this . name = name ;
141+ return this ;
142+ } ,
143+ setAttribute ( key : string , value : unknown ) {
144+ this . attributes [ key ] = value ;
145+ return this ;
146+ } ,
147+ } ;
148+ beforeEach ( ( ) => {
149+ parentSpan . name = 'GET' ;
150+ parentSpan . attributes = { 'sentry.op' : 'http.server' } ;
151+ } ) ;
135152vi . mock ( '../../../../src/utils/spanUtils' , async ( ) => ( {
136153 ...( await import ( '../../../../src/utils/spanUtils' ) ) ,
137154 getActiveSpan ( ) {
138155 return hasActiveSpan ? parentSpan : undefined ;
139156 } ,
157+ getRootSpan ( span : unknown ) {
158+ return span ;
159+ } ,
160+ spanToJSON ( span : { attributes ?: Record < string , unknown > } ) {
161+ return { attributes : span . attributes ?? { } } ;
162+ } ,
140163} ) ) ;
141164
142165vi . mock ( '../../../../src/tracing' , ( ) => ( {
@@ -367,6 +390,90 @@ describe('patchLayer', () => {
367390 checkSpans ( [ ] ) ;
368391 } ) ;
369392
393+ it ( 'writes the resolved route onto the root http.server span when span streaming is enabled' , ( ) => {
394+ // Regression guard: with streaming the root span starts named `GET`, and nothing else on this
395+ // path renames it — a routed request would otherwise keep the method-only name.
396+ spanStreamingEnabled = true ;
397+
398+ const req = Object . assign ( new EventEmitter ( ) , {
399+ originalUrl : '/a/b/c/layerPath' ,
400+ method : 'get' ,
401+ } ) as unknown as ExpressRequest ;
402+ const res = Object . assign ( new EventEmitter ( ) , { } ) as unknown as ExpressResponse ;
403+ const layer = { name : 'handle' , handle : vi . fn ( ) } as unknown as ExpressLayer ;
404+
405+ storeLayer ( req , 'a' ) ;
406+ storeLayer ( req , '/:boo' ) ;
407+
408+ patchLayer ( ( ) => ( { } ) , layer ) ;
409+ layer . handle ( req , res ) ;
410+
411+ expect ( parentSpan . name ) . toBe ( 'GET /a/:boo' ) ;
412+ expect ( parentSpan . attributes [ 'http.route' ] ) . toBe ( '/a/:boo' ) ;
413+ expect ( parentSpan . attributes [ 'sentry.segment.name.source' ] ) . toBe ( 'route' ) ;
414+ } ) ;
415+
416+ it ( 'names the root route `GET /` rather than leaving the route empty' , ( ) => {
417+ // `getConstructedRoute` skips `/`, so the root handler must take its route from the matched route.
418+ spanStreamingEnabled = true ;
419+
420+ const req = Object . assign ( new EventEmitter ( ) , {
421+ originalUrl : '/' ,
422+ method : 'get' ,
423+ } ) as unknown as ExpressRequest ;
424+ const res = Object . assign ( new EventEmitter ( ) , { } ) as unknown as ExpressResponse ;
425+ const layer = { name : 'handle' , handle : vi . fn ( ) } as unknown as ExpressLayer ;
426+
427+ storeLayer ( req , '/' ) ;
428+
429+ patchLayer ( ( ) => ( { } ) , layer ) ;
430+ layer . handle ( req , res ) ;
431+
432+ expect ( parentSpan . name ) . toBe ( 'GET /' ) ;
433+ expect ( parentSpan . attributes [ 'http.route' ] ) . toBe ( '/' ) ;
434+ } ) ;
435+
436+ it ( 'applies the route from mounted middleware, not only from request handlers' , ( ) => {
437+ // `app.use('/trpc', handler)` matches a route without being a request handler.
438+ spanStreamingEnabled = true ;
439+
440+ const req = Object . assign ( new EventEmitter ( ) , {
441+ originalUrl : '/trpc/foo' ,
442+ method : 'get' ,
443+ } ) as unknown as ExpressRequest ;
444+ const res = Object . assign ( new EventEmitter ( ) , { } ) as unknown as ExpressResponse ;
445+ // A layer name other than `handle`/`bound dispatch`/`router` is treated as middleware.
446+ const layer = { name : 'trpcMiddleware' , handle : vi . fn ( ) } as unknown as ExpressLayer ;
447+
448+ storeLayer ( req , '/trpc' ) ;
449+
450+ patchLayer ( ( ) => ( { } ) , layer ) ;
451+ layer . handle ( req , res ) ;
452+
453+ expect ( parentSpan . name ) . toBe ( 'GET /trpc' ) ;
454+ expect ( parentSpan . attributes [ 'http.route' ] ) . toBe ( '/trpc' ) ;
455+ } ) ;
456+
457+ it ( 'leaves the root span name alone without span streaming' , ( ) => {
458+ spanStreamingEnabled = false ;
459+
460+ const req = Object . assign ( new EventEmitter ( ) , {
461+ originalUrl : '/a/b/c/layerPath' ,
462+ method : 'get' ,
463+ } ) as unknown as ExpressRequest ;
464+ const res = Object . assign ( new EventEmitter ( ) , { } ) as unknown as ExpressResponse ;
465+ const layer = { name : 'handle' , handle : vi . fn ( ) } as unknown as ExpressLayer ;
466+
467+ storeLayer ( req , 'a' ) ;
468+ storeLayer ( req , '/:boo' ) ;
469+
470+ patchLayer ( ( ) => ( { } ) , layer ) ;
471+ layer . handle ( req , res ) ;
472+
473+ expect ( parentSpan . name ) . toBe ( 'GET' ) ;
474+ expect ( parentSpan . attributes [ 'http.route' ] ) . toBeUndefined ( ) ;
475+ } ) ;
476+
370477 it ( 'sets tx name in isolation scope' , async ( ) => {
371478 DEBUG_BUILD = true ;
372479 expect (
0 commit comments