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:
- 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
-
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).
-
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.
What happened?
HttpKernelSpanSubscribernames the request (SERVER) span after the route path template by default (route_naming: path, the OTEL semconv default). Resolving that template callsRouter::getRouteCollection()onkernel.response, for every routed, non-excluded request:flow/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/HttpKernelSpanSubscriber.php
Lines 99 to 106 in 220cf27
routeValue()is reached fromfinalizeSpanName()on thekernel.responselistener (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 dumpedurl_matching_routes.php(Router::getMatcher()), andgetRouteCollection()is a separate, uncached path:The
??=memoizes only for the lifetime of theRouterinstance. 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 wholeRouteCollectionin 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):
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:
https://github.com/symfony/symfony-docs/blob/7.4/routing.rst#checking-if-a-route-exists
Because
route_namingdefaults topath, 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:
route_namingdefaults topath):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).Profile the request (Blackfire / Xdebug / a manual timer). On
kernel.responsethe call stack is:The rebuild is absent when
route_namingis set toname(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_kernelinstrumentation enabled on default config.Version
flow-php/symfony-telemetry-bundle1.x-dev(observed at94989fb, 2026-07-04). The code is identical onflow-php/flowmainat220cf27a27e2c1fda8bad5d765d5afe1ed79f458.Relevant error output
No error; this is a performance regression, not a crash. Observed behavior:
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\ConfigCacherather than a bare file write.ConfigCache::write($content, $metadata)stores the map plus a.metaof the route collection's resources, andisFresh()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(...)).routeValue()then becomes an O(1) lookup, and the router is only ever touched at warm time:Notes:
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.cache:warmup/ deploy) and inject it into the subscriber in place of the router. BecausepathFor()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 (catchRouteNotFoundException), but it substitutes parameters and returns/orders/42, not the/orders/{id}template, so it cannot supplyhttp.route.route_naming: nameremains a valid escape hatch for consumers who do not need the path-template form: it makesrouteValue()return the route name and never builds the map.