Runtime hooks
Runtime hooks
Beyond providers (publish metrics) and exporters (ship signals), a set of resolver hooks lets an app — or a package building on top, like a CMS integration — shape what the built-in instrumentation records. All of them are guarded: a throwing resolver is reported and ignored, never breaking the request.
Request span naming — nameRequestsUsing()
Essential behind catch-all routes (Statamic, wildcard APIs), where the route pattern names every request identically:
Telemetry::nameRequestsUsing(function ($request, $response) {
$entry = $request->attributes->get('resolved.entry');
return $entry ? 'GET entry:'.$entry->collection : null; // null = default name
});
Keep names bounded — collections and types, never ids or slugs.
Precedence: an explicit updateName() on the span during the request
always wins; then this resolver; then the default METHOD <route>.
nameRequestsUsing shapes only the span name. To also fix the
http.route metric label (so dashboards and route tables group by the
logical route, not the catch-all), pair it with resolveRouteUsing().
Logical route — resolveRouteUsing()
For a catch-all framework the literal route template is useless — a CMS's
single /{segments?} is the http.route on every page, so every route
table and latency histogram collapses into one bucket. This hook supplies
the logical route, which replaces http.route on both the span
attribute and the metric label. Everything downstream — the UI route
table, Grafana, TraceQL — then groups by it:
Telemetry::resolveRouteUsing(function ($request, $response) {
$entry = $request->attributes->get('resolved.entry');
return $entry ? 'entry:'.$entry->collection : null; // null = keep the template
});
The return value MUST be bounded — it is a metric label, so a fixed,
small set (content types, collections), never an id or slug. When it
overrides the template, the raw pattern is preserved as the
http.route.template span attribute. This is the route counterpart to
nameRequestsUsing; a catch-all instrumentation usually sets both (often
to the same value — the name is METHOD + route).
Root-span enrichment — enrichRequestsUsing()
Extra attributes on the request root span at terminate, with the final response in hand (status-dependent enrichment works):
Telemetry::enrichRequestsUsing(fn ($request, $response) => [
'app.static_cache' => $response->headers->get('X-Cache', 'miss'),
]);
Runs before the tail-detail decision and the redaction engine. For
metric labels use labelRequestsUsing() instead — attributes are
per-span (unbounded ok), labels multiply cardinality (bounded only).
Cache key classification — classifyCacheKeysUsing()
Cache-heavy subsystems (a CMS content cache, an ORM cache) produce thousands of raw keys. Classify them into bounded groups — or drop them:
Telemetry::classifyCacheKeysUsing(function (string $store, string $key) {
if (str_starts_with($key, 'stache::indexes::')) {
return 'stache.index';
}
return str_starts_with($key, 'internal:') ? null : 'app'; // null = drop
});
With a classifier registered, kept operations carry the group as a
key_group counter label and a cache.key.group span attribute (the raw
key stays on the span). Whole stores can be excluded with
instrument.cache_ignore_stores.
Analytics session id — resolveSessionUsing()
Only active when telemetry.analytics.enabled is on. Overrides how the
shared session.id (the analytics keystone — one visit key across browser
and server spans) is derived from the request. The built-in default is a
cookieless, daily-rotating salted hash; a hook lets you source it from
Cloudflare, a first-party cookie, or your own logic:
Telemetry::resolveSessionUsing(fn ($request) =>
$request->header('CF-Ray') // Cloudflare's request id
?: $request->cookie('visit')); // or your own cookie
Return null to fall back to the cookieless default. Whatever it returns
is also propagated to the browser (via the @telemetryBrowser directive's
data-session), so the RUM SDK stamps the SAME session.id.
Client geo — resolveClientGeoUsing()
Only active when telemetry.analytics.enabled is on. Supplies
client.geo.* (and may override client.address) for the request span —
e.g. straight from Cloudflare's edge headers, so no geo database is needed
and the raw IP can be dropped:
Telemetry::resolveClientGeoUsing(fn ($request) => array_filter([
'client.geo.country' => $request->header('CF-IPCountry'),
'client.geo.region' => $request->header('CF-Region'),
'client.geo.city' => $request->header('CF-IPCity'),
]));
The full hook surface
| Hook | Shapes | Signature |
|---|---|---|
nameRequestsUsing() |
root span name | fn ($request, $response): ?string |
resolveRouteUsing() |
http.route (span + metric, bounded!) |
fn ($request, $response): ?string |
enrichRequestsUsing() |
root span attributes | fn ($request, $response): array |
labelRequestsUsing() |
request metric labels (bounded!) | fn ($request): array |
resolveUserUsing() |
user attribution | fn ($user, ?string $guard): array |
classifyCacheKeysUsing() |
cache grouping/dropping | fn (string $store, string $key): ?string |
redactUsing() |
last-pass redaction | fn (string $key, string $value): ?string |
handleExceptionsUsing() |
internal-failure reporting | fn (Throwable $e): void |
Telemetry::context() |
ambient dimensions on all signals | — |
Tracer::recordSpan() / bumpStat() / rootSpan() |
custom spans, backdated spans, root tallies | — |
Telemetry::contributes() |
conditional registration when telemetry exists | — |
A package integration typically combines these: a user resolver, a
context() listener for its ambient dimensions (site, tenant), a request
namer for its routing model, a cache classifier for its cache traffic,
and a TelemetryProvider for its gauges.