66
77use OpenTelemetry \API \Trace \SpanKind ;
88use OpenTelemetry \API \Trace \StatusCode ;
9+ use OpenTelemetry \Contrib \Propagation \TraceResponse \TraceResponsePropagator ;
910use OpenTelemetry \SemConv \TraceAttributes ;
1011use Symfony \Component \EventDispatcher \EventDispatcher ;
1112use Symfony \Component \EventDispatcher \EventDispatcherInterface ;
@@ -143,8 +144,9 @@ public function test_http_kernel_handle_subrequest(): void
143144 $ request = new Request ();
144145 $ request ->attributes ->set ('_controller ' , 'ErrorController ' );
145146
146- $ response = $ kernel ->handle ($ request , HttpKernelInterface::SUB_REQUEST );
147- $ kernel ->terminate ($ request , $ response );
147+ // Sub-requests are never passed to terminate() by Symfony; their span is ended
148+ // by the handle post-hook, so it is exported as soon as handle() returns.
149+ $ kernel ->handle ($ request , HttpKernelInterface::SUB_REQUEST );
148150
149151 $ this ->assertCount (1 , $ this ->storage );
150152
@@ -160,25 +162,22 @@ public function test_http_kernel_handle_subrequest_with_various_controller_types
160162 // String controller
161163 $ request = new Request ();
162164 $ request ->attributes ->set ('_controller ' , 'SomeController::index ' );
163- $ response = $ kernel ->handle ($ request , \Symfony \Component \HttpKernel \HttpKernelInterface::SUB_REQUEST );
164- $ kernel ->terminate ($ request , $ response );
165+ $ kernel ->handle ($ request , \Symfony \Component \HttpKernel \HttpKernelInterface::SUB_REQUEST );
165166 $ this ->assertSame ('GET SomeController::index ' , $ this ->storage [0 ]->getName ());
166167 $ this ->storage ->exchangeArray ([]);
167168
168169 // Array: [object, method]
169170 $ controllerObj = new class () {};
170171 $ request = new Request ();
171172 $ request ->attributes ->set ('_controller ' , [$ controllerObj , 'fooAction ' ]);
172- $ response = $ kernel ->handle ($ request , \Symfony \Component \HttpKernel \HttpKernelInterface::SUB_REQUEST );
173- $ kernel ->terminate ($ request , $ response );
173+ $ kernel ->handle ($ request , \Symfony \Component \HttpKernel \HttpKernelInterface::SUB_REQUEST );
174174 $ this ->assertSame ('GET ' . get_class ($ controllerObj ) . '::fooAction ' , $ this ->storage [0 ]->getName ());
175175 $ this ->storage ->exchangeArray ([]);
176176
177177 // Array: [class, method]
178178 $ request = new Request ();
179179 $ request ->attributes ->set ('_controller ' , ['SomeClass ' , 'barAction ' ]);
180- $ response = $ kernel ->handle ($ request , \Symfony \Component \HttpKernel \HttpKernelInterface::SUB_REQUEST );
181- $ kernel ->terminate ($ request , $ response );
180+ $ kernel ->handle ($ request , \Symfony \Component \HttpKernel \HttpKernelInterface::SUB_REQUEST );
182181 $ this ->assertSame ('GET SomeClass::barAction ' , $ this ->storage [0 ]->getName ());
183182 $ this ->storage ->exchangeArray ([]);
184183 }
@@ -194,15 +193,14 @@ public function test_http_kernel_handle_subrequest_with_null_and_object_controll
194193 $ controllerObj2 = new class () {};
195194 $ request = new Request ();
196195 $ request ->attributes ->set ('_controller ' , $ controllerObj2 );
197- $ response = $ kernel ->handle ($ request , \Symfony \Component \HttpKernel \HttpKernelInterface::SUB_REQUEST );
198- $ kernel ->terminate ($ request , $ response );
196+ $ kernel ->handle ($ request , \Symfony \Component \HttpKernel \HttpKernelInterface::SUB_REQUEST );
199197 $ this ->assertSame ('GET sub-request ' , $ this ->storage [0 ]->getName ());
198+ $ this ->storage ->exchangeArray ([]);
200199
201200 // Null/other controller (should fallback to 'sub-request')
202201 $ request = new Request ();
203202 $ request ->attributes ->set ('_controller ' , null );
204- $ response = $ kernel ->handle ($ request , \Symfony \Component \HttpKernel \HttpKernelInterface::SUB_REQUEST );
205- $ kernel ->terminate ($ request , $ response );
203+ $ kernel ->handle ($ request , \Symfony \Component \HttpKernel \HttpKernelInterface::SUB_REQUEST );
206204 $ this ->assertSame ('GET sub-request ' , $ this ->storage [0 ]->getName ());
207205 }
208206
@@ -237,6 +235,64 @@ public function test_http_kernel_handle_5xx_response_is_error(): void
237235 $ this ->assertSame (500 , $ span ->getAttributes ()->get (TraceAttributes::HTTP_RESPONSE_STATUS_CODE ));
238236 }
239237
238+ public function test_http_kernel_sub_request_scope_does_not_leak_main_span (): void
239+ {
240+ // Simulates Symfony's internal error-controller dispatch: a MAIN_REQUEST handle()
241+ // call whose exception is caught internally, followed by a nested SUB_REQUEST
242+ // handle() call that renders the error response successfully (no exception).
243+ $ kernel = $ this ->getHttpKernel (new EventDispatcher (), fn () => new Response ('error page ' ));
244+ $ this ->assertCount (0 , $ this ->storage );
245+
246+ $ mainRequest = new Request ();
247+ $ mainRequest ->attributes ->set ('_route ' , 'main_route ' );
248+ $ kernel ->handle ($ mainRequest , HttpKernelInterface::MAIN_REQUEST , false );
249+
250+ // The sub-request is dispatched while the main request span's scope is still on
251+ // the context storage stack, exactly as Symfony's ErrorListener does it.
252+ $ subRequest = new Request ();
253+ $ subRequest ->attributes ->set ('_controller ' , 'ErrorController ' );
254+ $ subResponse = $ kernel ->handle ($ subRequest , HttpKernelInterface::SUB_REQUEST );
255+
256+ // The sub-request span must be ended and exported immediately: it never reaches
257+ // `terminate()`, so leaving its scope attached would leak it and block the main
258+ // request span from ever being reached again.
259+ $ this ->assertCount (1 , $ this ->storage );
260+ $ this ->assertSame ('GET ErrorController ' , $ this ->storage [0 ]->getName ());
261+ $ this ->assertSame (SpanKind::KIND_INTERNAL , $ this ->storage [0 ]->getKind ());
262+
263+ // `terminate()` must now see the main request's scope, not a leaked sub-request one.
264+ $ kernel ->terminate ($ mainRequest , $ subResponse );
265+
266+ $ this ->assertCount (2 , $ this ->storage );
267+ $ mainSpan = $ this ->storage [1 ];
268+ $ this ->assertSame ('GET main_route ' , $ mainSpan ->getName ());
269+ $ this ->assertSame (SpanKind::KIND_SERVER , $ mainSpan ->getKind ());
270+ }
271+
272+ public function test_http_kernel_records_exception_on_main_span_during_sub_request_error_handling (): void
273+ {
274+ $ kernel = $ this ->getHttpKernel (new EventDispatcher (), fn () => new Response ('error page ' ));
275+
276+ $ mainRequest = new Request ();
277+ $ kernel ->handle ($ mainRequest , HttpKernelInterface::MAIN_REQUEST , false );
278+
279+ // Symfony's HttpKernel::handleThrowable() records the exception on whatever span
280+ // is currently active before it dispatches the internal error sub-request; at
281+ // that point the only span on the stack is the main request's.
282+ \OpenTelemetry \API \Trace \Span::getCurrent ()->recordException (new \RuntimeException ('boom ' ));
283+
284+ $ subRequest = new Request ();
285+ $ subRequest ->attributes ->set ('_controller ' , 'ErrorController ' );
286+ $ subResponse = $ kernel ->handle ($ subRequest , HttpKernelInterface::SUB_REQUEST );
287+ $ kernel ->terminate ($ mainRequest , $ subResponse );
288+
289+ $ this ->assertCount (2 , $ this ->storage );
290+ $ mainSpan = $ this ->storage [1 ];
291+ $ this ->assertSame (SpanKind::KIND_SERVER , $ mainSpan ->getKind ());
292+ $ this ->assertCount (1 , $ mainSpan ->getEvents ());
293+ $ this ->assertSame ('exception ' , $ mainSpan ->getEvents ()[0 ]->getName ());
294+ }
295+
240296 private function getHttpKernel (EventDispatcherInterface $ eventDispatcher , $ controller = null , ?RequestStack $ requestStack = null , array $ arguments = []): HttpKernel
241297 {
242298 $ controller ??= fn () => new Response ('Hello ' );
0 commit comments