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.
Outgoing host classification — classifyHttpHostsUsing()
server.address is a metric label on http.client.request.duration
and http.client.connection_failures. That is safe while every outbound
host is one your app chose. It stops being safe the moment a host comes
from a user — an OAuth issuer pasted into a form, a customer webhook, a
tenant's own API — because each distinct hostname then becomes a
permanent series. The connection-failure counter is the worse half: a
hostname that never answered still creates one, so it needs no
cooperation from the host at all.
Telemetry::classifyHttpHostsUsing(fn (string $host) =>
str_ends_with($host, '.stripe.com') ? 'stripe' : 'other');
The returned group replaces server.address on the metrics only —
spans keep the real hostname, because per-occurrence it costs nothing and
it is what you need when reading a trace. Return null to drop the
metrics for that host entirely while still recording the span.
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
geo.* (and may override client.address) for the request span and
the browser ingest endpoint. This hook always wins over the built-in
resolution.
Plain Cloudflare
CF-IPCountryis built in — just setTELEMETRY_ANALYTICS_GEO=true(see Analytics → Geo) and configureTrustProxies. Reach for this hook only for a custom edge, extra fields (region/city), or your own logic:
Telemetry::resolveClientGeoUsing(function ($request) {
$country = $request->header('CF-IPCountry');
// XX (unknown) and T1 (Tor) are sentinels, not countries. Returning an
// EMPTY array falls through to the built-in resolvers, including MaxMind;
// returning a half-built one suppresses them.
if (! is_string($country) || in_array($country, ['', 'XX', 'T1'], true)) {
return [];
}
$region = $request->header('CF-Region-Code');
return array_filter([
'geo.country.iso_code' => strtoupper($country),
// ISO 3166-2, so country + region CODE — CF-Region is the region NAME,
// and a missing code must not mint "US-".
'geo.region.iso_code' => $region ? strtoupper($country.'-'.$region) : null,
'geo.locality.name' => $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 |
classifyHttpHostsUsing() |
outgoing-host metric label (bounded!) | fn (string $host): ?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.