Skip to content

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-IPCountry is built in — just set TELEMETRY_ANALYTICS_GEO=true (see Analytics → Geo) and configure TrustProxies. 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.