Skip to content

[Bug]: HttpKernelSpanSubscriber calls Router::getRouteCollection() on every request with route_naming: path #2515

Description

@bendavies

What happened?

HttpKernelSpanSubscriber names the request (SERVER) span after the route path template by default (route_naming: path, the OTEL semconv default). Resolving that template calls Router::getRouteCollection() on kernel.response, for every routed, non-excluded request:

private function routeValue(string $routeName): string
{
if ($this->routeNaming !== RouteNaming::Path || $this->router === null) {
return $routeName;
}
return $this->router->getRouteCollection()->get($routeName)?->getPath() ?? $routeName;
}

private function routeValue(string $routeName): string
{
    if ($this->routeNaming !== RouteNaming::Path || $this->router === null) {
        return $routeName;
    }

    return $this->router->getRouteCollection()->get($routeName)?->getPath() ?? $routeName;
}

routeValue() is reached from finalizeSpanName() on the kernel.response listener (priority -10000), so it runs on the response of every routed request that is being traced.

Router::getRouteCollection() is not the compiled cache. In prod, request matching is served from the dumped url_matching_routes.php (Router::getMatcher()), and getRouteCollection() is a separate, uncached path:

// Symfony\Component\Routing\Router
public function getRouteCollection(): RouteCollection
{
    return $this->collection ??= $this->loader->load($this->resource, $this->options['resource_type']);
}

The ??= memoizes only for the lifetime of the Router instance. Under PHP-FPM the router service is built once per request, so the memoization never survives a request boundary and the loader chain re-runs on every request: it re-reads every route source (YAML files, attribute-routed controllers via reflection, etc.) and rebuilds the whole RouteCollection in memory, just to look up one route's ->getPath(). The compiled matcher cache does nothing to help here because this call bypasses it entirely.

This is exactly what Symfony warns against. The interface docblock is explicit (and even names the fix):

// Symfony\Component\Routing\RouterInterface
/**
 * Gets the RouteCollection instance associated with this Router.
 *
 * WARNING: This method should never be used at runtime as it is SLOW.
 *          You might use it in a cache warmer though.
 */
public function getRouteCollection(): RouteCollection;

https://github.com/symfony/symfony/blob/8.2/src/Symfony/Component/Routing/RouterInterface.php#L29

And the routing docs, on checking whether a route exists:

In highly dynamic applications, it may be necessary to check whether a route exists before using it to generate a URL. In those cases, don't use the Symfony\Component\Routing\Router::getRouteCollection method because that regenerates the routing cache and slows down the application.

https://github.com/symfony/symfony-docs/blob/7.4/routing.rst#checking-if-a-route-exists

Because route_naming defaults to path, this hits every default install, not just those that opt in. The cost scales with the number of routes in the application and is paid on the hot path of every traced request.

How to reproduce?

This is a Symfony integration hot path, not an ETL DSL snippet, so the repro is a profiling observation rather than a runnable script:

  1. Install the telemetry bundle with its default config (route_naming defaults to path):
flow_telemetry:
    instrumentation:
        http_kernel:
            enabled: true
            # route_naming: path   # <-- default; no need to set it
  1. Warm the prod cache (bin/console cache:warmup --env=prod) so the compiled matcher (url_matching_routes.php) exists, then serve a routed request under PHP-FPM (or any request-per-process SAPI).

  2. Profile the request (Blackfire / Xdebug / a manual timer). On kernel.response the call stack is:

HttpKernelSpanSubscriber::onResponse
  -> finalizeSpanName
    -> routeValue
      -> Symfony\Component\Routing\Router::getRouteCollection
        -> loader->load(...)   # full route collection rebuild, uncached

The rebuild is absent when route_naming is set to name (routeValue() early-returns).

Data required to reproduce bug locally

None. Reproduces on any Symfony application that uses attribute/YAML routing, in prod mode, with the telemetry http_kernel instrumentation enabled on default config.

Version

flow-php/symfony-telemetry-bundle 1.x-dev (observed at 94989fb, 2026-07-04). The code is identical on flow-php/flow main at 220cf27a27e2c1fda8bad5d765d5afe1ed79f458.

Relevant error output

No error; this is a performance regression, not a crash. Observed behavior:

kernel.response spends a significant, route-count-proportional slice of wall time in
Router::getRouteCollection() -> loader->load() on every routed request in prod.

Suggested fix

Never call getRouteCollection() on the request path. A route's path template is static for a given deployment, so build a [route_name => path] map once and read it at runtime.

Build it with Symfony\Component\Config\ConfigCache rather than a bare file write. ConfigCache::write($content, $metadata) stores the map plus a .meta of the route collection's resources, and isFresh() uses that metadata to keep the map correct without a manual cache clear. This is the same mechanism the routing component uses for its own compiled matcher/generator (Router::getConfigCacheFactory()->cache(...)).

use Symfony\Component\Config\ConfigCache;
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
use Symfony\Component\Routing\RouterInterface;

final class RouteNamePathMap implements CacheWarmerInterface
{
    private ConfigCache $cache;

    /** @var array<string, string>|null */
    private ?array $paths = null;

    public function __construct(
        private RouterInterface $router,
        string $cacheDir,
        bool $debug,
    ) {
        $this->cache = new ConfigCache($cacheDir . '/flow_telemetry_route_paths.php', $debug);
    }

    public function isOptional(): bool
    {
        // pathFor() self-heals if warmUp() never ran, so the warmer is optional.
        return true;
    }

    public function warmUp(string $cacheDir, ?string $buildDir = null): array
    {
        $collection = $this->router->getRouteCollection();

        $paths = [];

        foreach ($collection->all() as $name => $route) {
            $paths[$name] = $route->getPath();
        }

        // getResources() is the metadata ConfigCache::isFresh() checks in debug, so an
        // edited or added route invalidates the map without a manual cache clear.
        $this->cache->write('<?php return ' . var_export($paths, true) . ';', $collection->getResources());
        $this->paths = $paths;

        return [];
    }

    public function pathFor(string $routeName): ?string
    {
        // In prod ConfigCache::isFresh() short-circuits to a single is_file() (no resource
        // scan); in debug it rebuilds when a route source changed.
        if (!$this->cache->isFresh()) {
            $this->warmUp('');
        }

        return ($this->paths ??= require $this->cache->getPath())[$routeName] ?? null;
    }
}

routeValue() then becomes an O(1) lookup, and the router is only ever touched at warm time:

private function routeValue(string $routeName): string
{
    if ($this->routeNaming !== RouteNaming::Path) {
        return $routeName;
    }

    return $this->routePaths->pathFor($routeName) ?? $routeName;
}

Notes:

  • In prod the per-request cost is one is_file() plus an array lookup: ConfigCache::isFresh() returns early on !$debug && is_file(...) and never scans resources. The freshness scan runs only in debug.
  • Register the service as a cache warmer (built at cache:warmup / deploy) and inject it into the subscriber in place of the router. Because pathFor() re-warms lazily when the file is missing or stale, this works under both PHP-FPM and long-running workers (FrankenPHP / RoadRunner / Swoole) without a separate per-instance memoization.
  • UrlGeneratorInterface::generate() is the fast, cached path for building a concrete URL and the right tool for existence checks (catch RouteNotFoundException), but it substitutes parameters and returns /orders/42, not the /orders/{id} template, so it cannot supply http.route.
  • route_naming: name remains a valid escape hatch for consumers who do not need the path-template form: it makes routeValue() return the route name and never builds the map.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Fields

    No fields configured for Bug.

    Projects

    Status
    Todo

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions