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.duration. 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.
Redaction — config, or your own class
Two halves, because a name list is enough for context you set yourself and is not enough for auto-instrumentation.
Names. redaction.keys covers attribute keys; query parameters are
matched by name too, through every spelling the same parameter takes —
api_key, apiKey, x-api-key, %61pi_key, token[0].
Values. The URLs this package records are not yours. They belong to
whatever third-party API the application calls, and no list enumerates
?t=, ?sas=, or whatever the next vendor names its token. So a query
parameter whose NAME said nothing is judged by its VALUE:
'credential_prefixes' => Redactor::defaultCredentialPrefixes(), // sk_live_, ghp_, AKIA, …
'value_shape' => true, // 24+ chars, token alphabet, mixed case + digits
'value_min_length' => 24,
UUIDs are excluded by shape — a UUID in a URL is an id somebody needs to read — and so is anything with a space or a character no token generator emits. Over-redaction is its own failure: an operator who cannot read the URLs stops trusting the tool.
Personal identifiers — optional, and off. A separate layer for the structured ones, found in free text:
'pii' => true, // email, credit_card, iban, us_ssn, dk_cpr
'pii' => ['detectors' => ['email', 'ip']], // exactly these
It catches what leaked in by accident — an email in an exception message, a card number in a SQL string, a CPR in a URL path — which is how personal data actually reaches an observability pipeline. Nobody decides to put it there.
Every detector that can be checksummed is: Luhn for cards, mod-97 for
IBANs, the unissued ranges excluded for SSNs. Without that a sixteen-
digit number is a card far more often than it is an order id, and the
first person to see a primary key redacted switches the whole thing off
— which is worse than never having shipped it. ip and phone are not
in the defaults: an IP is also how you find the one host that is broken,
and a bare national phone number is indistinguishable from an order id.
It costs about 2µs a span on top of the redaction pass.
It is a control, not compliance. No configuration of it makes an application GDPR-, HIPAA- or SOC 2-compliant. Those are organisational, and most of what they cover cannot be pattern-matched: HIPAA names eighteen identifiers and this sees six, because a person's name, their address and a medical record number have no form to recognise. Those need the key rules above, and the discipline not to put them in a span.
Or replace the model. RedactsTelemetry is a contract, for an
organisation whose redaction policy is its own — a compliance list, a
shared internal package, a scanner that already exists elsewhere:
$this->app->bind(RedactsTelemetry::class, AcmeRedactor::class);
Bind it and the whole pipeline uses it. Reach for this when you need a different model, not a different list.
Queue name classification — classifyQueuesUsing()
queue is a metric label on every queue metric this package emits, and
Laravel lets you name a queue at dispatch time. ->onQueue("tenant-{$id}")
is an ordinary thing to write and a permanent series per tenant across
half a dozen metrics, several of them histograms. On a platform with
thousands of tenants that is not a slow dashboard; it is the metrics
backend falling over.
Telemetry::classifyQueuesUsing(fn (string $queue) =>
str_starts_with($queue, 'tenant-') ? 'tenant' : $queue);
The group replaces queue on the metrics only — spans keep the real
name, which is what you want when reading one trace.
Unlike the host classifier this one cannot drop a series: returning
null collapses the queue to other. Dropping an outgoing host is
reasonable, because you may genuinely not care about a customer's webhook
endpoint. Dropping a queue would silently remove work the application
actually did from its own throughput numbers.
Job name classification — classifyJobsUsing()
job.name is a metric label on the queue metrics, and it comes from the
payload's displayName — which Laravel lets a job set for itself. A
job class name is bounded by your code and needs nothing here. A
display name built at dispatch time is bounded only by your own
discipline, and "SyncTenant tenant-4812" is one permanent series per
tenant.
Telemetry::classifyJobsUsing(fn (string $job) => Str::before($job, ' '));
Like the queue classifier, it cannot drop a series: returning null
collapses the job to other, because work the application actually did
should not vanish from its own throughput numbers. Spans keep the real
name.
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 |
classifyQueuesUsing() |
queue metric label (bounded!) | fn (string $queue): ?string |
classifyJobsUsing() |
job metric label (bounded!) | fn (string $job): ?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.