Skip to content

Design notes

Design notes

The rationale behind the non-obvious decisions. If you are extending the addon or debugging it, read this.

The invariant: telemetry never throws into Statamic

The base package guarantees this for its own capture paths (everything runs through FailSafe::guard). The addon must uphold it too, because it hooks two kinds of Statamic surface:

  1. Resolver hooks (nameRequestsUsing, enrichRequestsUsing, resolveUserUsing, classifyCacheKeysUsing) — the base package already wraps these in FailSafe, so a throw inside Support\Content or Support\CacheKeys is caught, reported and turned into "no name/attributes", never a broken request.
  2. Event listeners — these fire inside the dispatch of a real operation: an entry save, an asset upload, a login. A throwing listener would break that operation. So every listener extends GuardedListener, whose handle() wraps the subclass handleEvent() in the same FailSafe::guard. A Redis outage, a malformed event payload, a missing method — none of it can break the save.

When adding a listener, extend GuardedListener and implement handleEvent(object $event). Don't add a handle() — the base provides the guarded one.

Content-named request spans

Every Statamic frontend URL matches one catch-all route, so the base package names every frontend span identically. The fix is a two-part dance:

  • A ResponseCreated listener (CaptureResponseData) stashes the content object Statamic resolved onto the request.
  • The nameRequestsUsing and enrichRequestsUsing resolvers read it back at terminate — when the final response (and its status) is known — and derive a bounded name and attributes.

Structured collections (including the default skeleton's pages) resolve to a Structures\Page wrapping the entry, not the entry itself, so Content unwraps Page::entry() first. This was a real miss caught by the demo — unstructured-collection tests didn't exercise it.

Static cache: subclasses, not a decorator

Statamic's static-caching middleware and replacers make instanceof FileCacher / instanceof ApplicationCacher checks to choose code paths. A decorator around the Cacher contract would fail those checks and silently change caching behaviour. So the addon re-registers the file and application drivers as tracing subclasses (TracingFileCacher, TracingApplicationCacher). Custom third-party cacher drivers are not instrumented.

The swap is registered via afterResolving(StaticCacheManager::class) in the addon's register(), not bootAddon(). With a strategy configured at boot — every real app — Statamic's own boot subscribes the cache invalidator, which resolves and caches the driver before any booted() callback runs. Extending from bootAddon would hand back the untraced cacher. This was caught by the manual e2e, not the unit tests (which set the strategy after boot).

Recording is gated to the request being served

Statamic probes the cache with freshly built, synthetic Request objects too — error-page copies (copyError), warm jobs. Those must not inflate the hit/miss counters or overwrite the outcome attribute on the real request's root span. So StaticCacheTelemetry records an outcome only when the request is the container's current request (isCurrentRequest). Invalidations and flushes are not request-scoped and are always counted.

Stripping the trace id from cached responses

The base package exposes the trace id as an X-Trace-Id response header — the support-case reference. Statamic's half-measure (ApplicationCacher) snapshots response headers into the cache via its own ResponsePrepared listener, so without intervention one visitor's trace id would be baked into the cached page and replayed to everyone.

TracingApplicationCacher::cachePage flags the request; a boot-registered ResponsePrepared listener (StripTraceHeader) runs before the cacher's own header-snapshot listener and removes the header. The flag lives on the request (not a class static) so that under Octane a cachePage with no following ResponsePrepared can't leak the strip into the next request. Full-measure (FileCacher) keeps the header for the first, PHP-served visitor; dynamic responses always keep it.

http.route is the content route, not the catch-all

The content-aware span name fixes traces, but the request metrics and every consumer that groups by them (the UI route table, Grafana) key off the http.route label — which for Statamic's single catch-all route is /{segments?} on every frontend page. So route tables and latency histograms collapse the whole front end into one bucket.

The right fix is in the base package, not a parallel addon label: a resolveRouteUsing() hook (added in laravel-telemetry v0.1.0-alpha.4) lets an instrumentation supply the logical route, which replaces http.route on both the span attribute and the metric label. The addon registers it with Content::route — so http.route becomes entry:{collection}.{blueprint} / term:{taxonomy} / taxonomy:{taxonomy}, and everything downstream groups by content with no per-consumer change. The literal template is preserved as http.route.template.

The resolver is given the final response, not just the request, so it can also bucket frontend 404s as not_found: a URL that matched the catch-all but resolved to no content (broken links, bots, stale sitemaps) would otherwise sit in /{segments?} forever unseen. The fallback is scoped to FrontendController (via the matched route's action name), so a 404 on a real Laravel route keeps its own http.route. Cache hits are deliberately left on /{segments?}: they are served before the controller resolves any content, so nothing names them — but they are the fast path and carry statamic.static_cache: hit, so the catch-all bucket ends up meaning "cache hits" while every content-resolving render is named.

The base package couldn't do this itself: http.route is a metric label and must be bounded, and the base can't know a resolved name is bounded (an app's resolver might return per-id values). The addon does know — collections and taxonomies are a fixed, small set — so it takes responsibility via the hook. The span name still comes from the same route (METHOD + route), so no separate nameRequestsUsing is needed.

resolveRouteUsing is single-slot (like the other resolver hooks); an app that needs a different route identity composes with Content::routeLabel:

Telemetry::resolveRouteUsing(fn ($request, $response) =>
    $request->is('api/*') ? 'api' : Content::routeLabel($request)
);

Stache

The Stache fires no per-operation events but runs on the Laravel cache under the hood, so its traffic already flows through the base package's cache instrumentation — as thousands of raw keys. The addon's CacheKeys classifier (registered via classifyCacheKeysUsing) buckets them into bounded groups (stache.index, stache.item, stache.meta, static_cache, app) so counters and spans stay legible. Nothing is dropped — every operation keeps a group — so the key_group label set stays consistent. Warm/clear counters and warm duration come from StacheWarmed/StacheCleared.

Blink is Statamic's per-request memoization layer — where augmentation caching and repeated lookups live. It fires no events. TracingBlink is bound as a singleton over Statamic\Support\Blink (the class the facade resolves), handing out TallyingBlinkStore instances that count once() hits and misses onto the trace root span via bumpStat — the request's memoization effectiveness, not per-key noise. It only tallies inside an active trace, so Blink use in console commands and untraced jobs doesn't accumulate unattached counts.

Antlers

Two opt-in layers, both off by default because they add per-render cost:

  • instrument.views wraps the Antlers view engine (TracingEngine) for one view.render span per rendered view.
  • instrument.antlers registers a runtime tracer (AntlersNodeTracer) through Statamic's official RuntimeTracerContract, for a span per tag invocation. It forces statamic.antlers.tracing on, which is why it is opt-in — the runtime only consults tracers when tracing is enabled, and it costs per node.

Span names are the bare tag name (antlers:partial, antlers:collection) — bounded to Statamic's registered tag set. The method part (partial:components/hero, nav:main) is unbounded — one value per partial file or dynamic target — so it goes on the antlers.method attribute, never the span name (which Grafana groups on). Both layers no-op when there is no active root span, so a render in a console command or job doesn't mint one orphan trace per view or tag.

Composing with your own resolvers

The base package's resolver hooks are single-slot — the last registration wins. The addon registers during boot, so an app that registers its own resolver afterwards replaces the Statamic one. To compose instead of replace, delegate to the addon's public helpers:

use Cbox\StatamicTelemetry\Hooks;
use Cbox\StatamicTelemetry\Support\CacheKeys;
use Cbox\StatamicTelemetry\Support\Content;

Telemetry::resolveUserUsing(fn ($user, $guard) => [
    ...Hooks::userAttributes($user, $guard),
    'enduser.plan' => $user->plan ?? null,
]);

Telemetry::classifyCacheKeysUsing(fn (string $store, string $key) =>
    str_starts_with($key, 'tenant:') ? 'tenant' : CacheKeys::classify($store, $key)
);