Skip to content

[contrib-auto-symfony] SUB_REQUEST scope not cleaned up in handle post-hook, causing root HTTP spans to never be exported #1905

@BTC-Tim

Description

@BTC-Tim

Filing here as opentelemetry-php-contrib has issues disabled. This concerns open-telemetry/opentelemetry-auto-symfony 1.1.1.

Summary

When Symfony handles a request that results in a non-200 response, HttpKernel::handle() is called twice — once for the main request and once internally as a SUB_REQUEST to render the error page via ExceptionController. The handle post-hook returns early for both calls (because $exception is null for successful sub-requests), leaving two scopes on the OTEL context stack. When terminate() fires it pops only the topmost scope (the sub-request), ending that span. The main request scope (the root SERVER span) is never popped or ended — and therefore never exported.

Environment

  • open-telemetry/opentelemetry-auto-symfony: 1.1.1
  • Symfony: 6.4
  • PHP: 8.2
  • OTEL exporter: OTLP/gRPC → Tempo

Steps to Reproduce

Any request that triggers Symfony's error handling internally (e.g. a 404, 403, or any response that goes through ExceptionController) will reproduce this:

  1. Make a request that returns a non-200 response (e.g. hit a route that returns a 404)
  2. Check your trace backend (Jaeger, Tempo, etc.)

Result: Child spans (e.g. Doctrine queries) appear with rootServiceName: <root span not yet received>. The root HTTP span is never received.

Root Cause

After both handle() calls complete, the OTEL context stack looks like this:

[top]    scope 1 → sub-request span  (KIND_INTERNAL, child of scope 2)
[bottom] scope 2 → main request span (KIND_SERVER, root, no parent)

The handle post-hook:

post: static function (...) {
    $scope = Context::storage()->scope();
    if (null === $scope || null === $exception) {
        return;  // ← returns early for all successful requests, including sub-requests
    }
    // ...
}

Both the SUB_REQUEST post-hook (no exception) and the MAIN_REQUEST post-hook (no exception when error page renders successfully) return early. Both scopes remain on the stack.

terminate() then pops one scope (scope 1, the sub-request), ends it, and returns. Scope 2 (the main HTTP span) stays on the stack forever — never ended, never exported.

Note: this is distinct from the exception case fixed in PR #317. The bug occurs on normal request handling whenever Symfony renders an error response internally.

Expected Behavior

The root HTTP span (KIND_SERVER) should be ended and exported for every request, including those that produce error responses.

Proposed Fix

In the handle post-hook, detect SUB_REQUEST and end those spans immediately, since sub-requests never receive a terminate() call:

post: static function (
    HttpKernel $kernel,
    array $params,
    ?Response $response,
    ?\Throwable $exception
): void {
    $type = $params[1] ?? HttpKernelInterface::MAIN_REQUEST;
    $scope = Context::storage()->scope();

    if (null === $scope) {
        return;
    }

    // Main request with no exception: terminate() handles span ending
    if ($type === HttpKernelInterface::MAIN_REQUEST && null === $exception) {
        return;
    }

    $span = Span::fromContext($scope->context());
    $scope->detach();

    if (null !== $exception) {
        $span->recordException($exception, [TraceAttributes::EXCEPTION_ESCAPED => true]);
        if (null !== $response && $response->getStatusCode() >= Response::HTTP_INTERNAL_SERVER_ERROR) {
            $span->setStatus(StatusCode::STATUS_ERROR, $exception->getMessage());
        }
    }

    // Sub-requests don't get a terminate() call — end the span here
    if ($type === HttpKernelInterface::SUB_REQUEST) {
        $span->end();
    }
},

This ensures the sub-request scope is cleaned up immediately, so when terminate() fires only the main request scope remains on the stack and is properly ended and exported.

We are currently working around this with cweagans/composer-patches but would love to see this fixed upstream. Happy to open a PR if this direction looks good.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions