Skip to content

Configuration reference

Configuration reference

Publish with php artisan vendor:publish --tag=telemetry-config.

Env-var convention: every variable is prefixed TELEMETRY_ and mirrors its config path (otlp.spool.keyTELEMETRY_OTLP_SPOOL_KEY). The package additionally honors the OpenTelemetry-standard variables (OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS, OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTES) as fallbacks for interop — TELEMETRY_* wins when both are set.

Master switch

Key Env Default
enabled TELEMETRY_ENABLED true

Disabled: no-op instruments, no listeners, no routes, no providers.

Service resource

Attached to every exported signal (OTel resource conventions).

Key Env Default
service.name TELEMETRY_SERVICE_NAME APP_NAME
service.namespace TELEMETRY_SERVICE_NAMESPACE
service.version TELEMETRY_SERVICE_VERSION
service.environment TELEMETRY_SERVICE_ENVIRONMENT APP_ENV
service.deployment TELEMETRY_SERVICE_DEPLOYMENT auto — explicit value wins; otherwise the current git sha is detected from .git/HEAD (no exec). Becomes deployment.id on every signal
resource_detection TELEMETRY_RESOURCE_DETECTION true — auto-detect container/k8s/cloud attributes (container.id, k8s.pod.name, k8s.namespace.name, cloud.region, …) from cgroup facts, downward-API env vars and OTEL_RESOURCE_ATTRIBUTES. Config service.* keys always win
self_metrics TELEMETRY_SELF_METRICS true — emit the package's own health as metrics (telemetry.export.*, telemetry.spool.depth)

Container / Kubernetes / cloud detection

With resource_detection on, every signal carries where it ran:

  • From cgroups (via cboxdk/system-metrics): container.id, container.runtime.
  • From env vars (the common downward-API injections): k8s.pod.name (K8S_POD_NAME/POD_NAME, or the pod's HOSTNAME), k8s.namespace.name, k8s.node.name, cloud.region (AWS_REGION/GOOGLE_CLOUD_REGION/…), and more.
  • From OTEL_RESOURCE_ATTRIBUTES — the OpenTelemetry standard (key1=val1,key2=val2, percent-decoded). Operator-explicit, overrides the env-var conventions. This is the cleanest way to inject arbitrary attributes; k8s operators set it automatically.
  • From AWS Lambda's own runtime env vars (Vapor included — it deploys on Bref's PHP-FPM Lambda layer, so the same vars are always present): cloud.provider=aws, cloud.platform=aws_lambda, faas.name/.version/.instance/.max_memory, and a per-invocation faas.coldstart (true only on the first request served by a given execution environment — the PHP-FPM worker is reused across warm invocations, so this is re-evaluated on every call, unlike the rest of the detected resource which is memoized for the process). Vapor itself is flagged with vapor.detected (from VAPOR_SSM_PATH) — there's no standardized OTel value for "which PaaS wraps this Lambda".

Precedence: the package's own service.* config is authoritative; OTEL_RESOURCE_ATTRIBUTES beats the env-var conventions; both fill in around the config. Filter any of these in Tempo: { resource.k8s.namespace.name = "production" }, { resource.faas.name = "my-app-production-http" }.

Self-observability metrics

With self_metrics on, the package reports on itself (bounded labels):

Metric Type Labels Meaning
telemetry.export.duration histogram (ms) exporter, signal how long exports take
telemetry.export.count counter exporter, signal, outcome export attempts by outcome (ok/partial/retryable/failed/error)
telemetry.export.rejected counter exporter, signal data points the backend rejected (OTLP partial success)
telemetry.export.circuit_open gauge (0/1) OTLP breaker open (only when OTLP is a configured exporter)
telemetry.spool.depth gauge pending OTLP spool payloads (only when the spool is enabled)

The bundled System dashboard renders these under a "Telemetry health" row — alert on a sustained open circuit or a climbing spool.

Metric store

Key Env Default
store TELEMETRY_STORE redis
buffer_writes TELEMETRY_BUFFER_WRITES true — aggregate writes in memory, flush at terminate
stores.redis.connection TELEMETRY_REDIS_CONNECTION default
stores.redis.prefix TELEMETRY_REDIS_PREFIX telemetry
stores.apcu.prefix TELEMETRY_APCU_PREFIX telemetry

Exporters

Key Env Default
exporters TELEMETRY_EXPORTERS (comma-separated) []

Valid entries: otlp, null, or a fully-qualified class name implementing Cbox\Telemetry\Contracts\Exporter (resolved from the container).

OTLP

Key Env Default
otlp.endpoint TELEMETRY_OTLP_ENDPOINT http://localhost:4318 — also honors the OTel-standard OTEL_EXPORTER_OTLP_ENDPOINT as a fallback
otlp.headers TELEMETRY_OTLP_TOKEN sent as Authorization: Bearer <token> for an auth-gated endpoint; extra headers via the OTel-standard OTEL_EXPORTER_OTLP_HEADERS (k1=v1,k2=v2)
otlp.timeout TELEMETRY_OTLP_TIMEOUT 3.0 s
otlp.connect_timeout TELEMETRY_OTLP_CONNECT_TIMEOUT 1.0 s
otlp.compression TELEMETRY_OTLP_COMPRESSION true (gzip bodies > 1 KB)

| otlp.spool.enabled | TELEMETRY_OTLP_SPOOL | false — spans/events go to a Redis list instead of POSTing at terminate; telemetry:flush (cron or --daemon) ships merged batches | | otlp.spool.connection | TELEMETRY_OTLP_SPOOL_CONNECTION | default | | otlp.spool.key | TELEMETRY_OTLP_SPOOL_KEY | telemetry:spool | | otlp.spool.max_items | TELEMETRY_OTLP_SPOOL_MAX_ITEMS | 20000 (drop-oldest above) |

Daemon mode for high traffic: telemetry:flush --daemon --interval=1 --metrics-interval=15 --max-batch=200 (one process under supervisor; graceful SIGTERM drain).

After a retryable transport failure (429/5xx, network), an in-process circuit breaker skips exports for 30 s (or the server's Retry-After).

Prometheus

Key Env Default
prometheus.enabled TELEMETRY_PROMETHEUS_ENABLED true
prometheus.endpoints.*.path TELEMETRY_PROMETHEUS_PATH telemetry/metrics
prometheus.endpoints.*.middleware [AllowIps::class]
prometheus.endpoints.*.only null (all metrics)
prometheus.allowed_ips TELEMETRY_ALLOWED_IPS (comma-separated, CIDR ok) []
prometheus.token TELEMETRY_PROMETHEUS_TOKEN — bearer token accepted as an alternative to the IP allowlist, checked with hash_equals()

AllowIps (the default endpoint middleware) is closed outside local/testing unless allowed_ips or token is set — the same convention as Horizon/Telescope/Pulse.

Multiple named endpoints are supported — e.g. an internal endpoint with everything and a public endpoint filtered to a prefix list:

'endpoints' => [
    'internal' => ['path' => 'internal/metrics', 'middleware' => [AllowIps::class]],
    'public' => ['path' => 'metrics', 'middleware' => ['auth:metrics'], 'only' => ['http', 'queue']],
],

Exemplars

Every histogram observation made inside a sampled trace carries that trace's id as an exemplar — click a slow bucket in Grafana, land on the actual trace that landed in it. No config toggle: it follows traces.sample_rate automatically, and costs nothing when a trace isn't sampled (the exemplar is simply omitted).

Exemplars have no grammar in the classic Prometheus text format (text/plain; version=0.0.4), so they only render when the scraper negotiates OpenMetrics via its Accept header — content negotiation, not a setting. Prometheus does this itself when --enable-feature=exemplar-storage is set; scraping with curl always gets the classic format unless you pass -H 'Accept: application/openmetrics-text'.

Traces

Key Env Default
traces.sample_rate TELEMETRY_TRACES_SAMPLE_RATE 1.0
traces.max_buffer TELEMETRY_TRACES_MAX_BUFFER 5000
traces.continue_incoming TELEMETRY_TRACES_CONTINUE_INCOMING true
traces.trust_incoming_sampling TELEMETRY_TRACES_TRUST_INCOMING_SAMPLING true — disable on public edges so clients can't force sampling
traces.always_sample_errors TELEMETRY_TRACES_ALWAYS_SAMPLE_ERRORS true — error spans export even from unsampled traces
traces.share_context TELEMETRY_TRACES_SHARE_CONTEXT true — publishes trace_id into Laravel Context (Sentry/Flare/logs pick it up) + a Sentry scope tag
traces.response_header TELEMETRY_TRACES_RESPONSE_HEADER X-Trace-Id — the support reference id on every response; null disables. Skipped on publicly cacheable responses (Cache-Control: public/s-maxage) so caches never replay a stale id
traces.details.mode TELEMETRY_TRACES_DETAILS alwaystail keeps cache/query detail spans only for failing or slow traces
traces.details.slow_request_ms TELEMETRY_TRACES_SLOW_REQUEST_MS 1000
traces.details.slow_span_ms TELEMETRY_TRACES_SLOW_SPAN_MS 100

Redaction engine

Key Env Default
redaction.enabled TELEMETRY_REDACTION true
redaction.keys Redactor::defaultKeys() — password, secret, token, api_key, authorization, cvv, ssn, … (whole key segments)
redaction.patterns Redactor::defaultPatterns() — JWTs, Bearer/Basic credentials, url userinfo (regex ⇒ replacement)
redaction.safe_keys Redactor::defaultSafeKeys() — exact keys exempt from key-based redaction (session.driver, session.hash); patterns/hook still apply
redaction.replacement [REDACTED]

Applied to span attributes, span events, telemetry events AND log records (message + context) at flush. Custom last-pass hook: Telemetry::redactUsing(fn ($key, $value) => ...) — log messages arrive with the key log.message.

Events

Key Env Default
events.max_buffer TELEMETRY_EVENTS_MAX_BUFFER 5000 (force-flush above)

Histograms

Key Default
default_buckets [1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000] (ms)

Analytics (optional)

An additive layer on top of the telemetry already collected — a shared session.id across browser + server so a whole visit, not just one trace, can be analysed. Off by default; changes nothing when disabled. See Analytics.

Key Env Default
analytics.enabled TELEMETRY_ANALYTICS false
analytics.page_views TELEMETRY_ANALYTICS_PAGE_VIEWS true — unsampled analytics.page_view event per top-level document load
analytics.session.salt TELEMETRY_ANALYTICS_SALT — the daily-rotating, salted hash that keeps the built-in session.id cookieless; override entirely via Telemetry::resolveSessionUsing()
analytics.geo.enabled TELEMETRY_ANALYTICS_GEO false — resolve client.geo.country at collection time. Precedence: Telemetry::resolveClientGeoUsing() hook → Cloudflare CF-IPCountry → MaxMind database. Applies to server page views and the browser ingest endpoint
analytics.geo.cloudflare TELEMETRY_ANALYTICS_GEO_CF true — prefer Cloudflare's CF-IPCountry header (free, no database). Only trusted when the request is from a trusted proxy — set Laravel TrustProxies to the immediate hop (CF ranges if CF connects directly, or your load balancer in a CF→LB→app chain); spoofable and ignored otherwise
analytics.geo.database TELEMETRY_ANALYTICS_GEO_DB — path to the MaxMind .mmdb file (composer require geoip2/geoip2)
analytics.user_agent TELEMETRY_ANALYTICS_UA false — parse user_agent.original into low-cardinality user_agent.name/os.name/device.type (families only, never versions)
analytics.utm TELEMETRY_ANALYTICS_UTM false — capture campaign attribution from the landing URL: analytics.utm.source/medium/campaign/content/term, plus a low-cardinality analytics.click_id (the paid ad-network param NAME — gclid/msclkid/… — never its value; fbclid excluded). Applies to server and browser page views

Browser span ingest (optional)

Key Env Default
ingest.spans.enabled TELEMETRY_INGEST_SPANS false — a POST endpoint the browser sends its spans to (RUM → distributed tracing)
ingest.spans.path TELEMETRY_INGEST_SPANS_PATH telemetry/spans
ingest.spans.middleware ['throttle:300,1'] — add auth etc. for logged-in apps
ingest.spans.max_spans 128 per batch (excess dropped)
ingest.spans.max_attributes 32 per span
ingest.spans.sample_rate 1.0 — server-side head sampling (0–1) to cap volume
ingest.spans.asset_path TELEMETRY_INGEST_SPANS_ASSET telemetry/browser.js — the bundled RUM script @telemetryBrowser loads
ingest.spans.browser.{fetch,errors,vitals,sample} true/true/true/1.0 — what the bundled script captures. vitals reports a web-vitals span (LCP/CLS/simplified INP via PerformanceObserver) at page hide/unload, when supported

Add @telemetryBrowser to your layout for turnkey RUM (or @telemetryTraceparent + your own script). See Browser tracing.

Source map upload (optional)

A POST endpoint the build pipeline uploads source maps to (via the @cboxdk/telemetry-browser uploader / Vite plugin), keyed by release, so minified browser stacks can be symbolicated back to original source. Uploads come from CI — which can hold a secret — so unlike the browser span ingest this endpoint is bearer-token gated: without a configured token every upload is rejected (403), so it is never accidentally open. Only valid v3 source maps within the size limit are stored. See Browser tracing.

Key Env Default
sourcemaps.enabled TELEMETRY_SOURCEMAPS false
sourcemaps.token TELEMETRY_SOURCEMAPS_TOKEN — required bearer token, checked with hash_equals(); uploads are rejected until one is set
sourcemaps.path TELEMETRY_SOURCEMAPS_PATH telemetry/sourcemaps — the upload route
sourcemaps.disk TELEMETRY_SOURCEMAPS_DISK local — the filesystem disk maps are stored on
sourcemaps.prefix telemetry/sourcemaps — storage path prefix; maps land at {prefix}/{release}/{name}
sourcemaps.middleware [] — extra route middleware for the upload endpoint
sourcemaps.max_bytes 20971520 (20 MB) — larger uploads are dropped

Automatic instrumentation

Key Env Default
instrument.requests TELEMETRY_INSTRUMENT_REQUESTS true
instrument.host_label TELEMETRY_INSTRUMENT_HOST_LABEL trueserver.address label on http.server.* metrics; routes with a domain pattern report the pattern ({tenant}.app.example), keeping wildcard cardinality bounded
instrument.request_headers accept, accept-language, content-type, origin, referer, x-forwarded-for, x-requested-with — span attrs http.request.header.*; credentials/session headers are denylisted and never captured
instrument.response_headers content-type, cache-control — span attrs http.response.header.*
instrument.jobs TELEMETRY_INSTRUMENT_JOBS true
instrument.queue_retry_links TELEMETRY_INSTRUMENT_QUEUE_RETRY_LINKS true — OTel span links between a retried job's attempts (a link, never a parent — attempt N+1 is a sibling of attempt N, both children of the original dispatch). Bridged via the app's own cache, since a retry can land on a different worker (queue.retry_link_store/queue.retry_link_ttl, default TTL 86400s); a null/array cache driver just means retries go unlinked
instrument.queries TELEMETRY_INSTRUMENT_QUERIES true
instrument.queries_min_duration TELEMETRY_INSTRUMENT_QUERIES_MIN_DURATION 0 ms (record everything; raise as a noise floor)
instrument.query_duplicates TELEMETRY_INSTRUMENT_QUERY_DUPLICATES true — flags a query that runs identically more than once in the same trace (the actual N+1 smell): db.query.duplicate.count root-span tally, db.queries.duplicated{connection} counter, and a db.query.duplicate_detected OTLP log event carrying the query text. Fires once per distinct query, at the threshold crossing
instrument.query_duplicates_threshold TELEMETRY_INSTRUMENT_QUERY_DUPLICATES_THRESHOLD 3 repeats before flagging
instrument.commands TELEMETRY_INSTRUMENT_COMMANDS false
instrument.gates TELEMETRY_INSTRUMENT_GATES trueauthorization.checks{ability,result} counter (gates AND policies) + gate.check.count/gate.denied.count root-span tallies
instrument.auth TELEMETRY_INSTRUMENT_AUTH trueauth.events{event,guard} (login/logout/failed/lockout/password_reset/registered/verified)
instrument.transactions TELEMETRY_INSTRUMENT_TRANSACTIONS true — DB transaction spans (nested via savepoints) + db.transactions.rolled_back
instrument.models TELEMETRY_INSTRUMENT_MODELS truemodel.hydrations root tally (N+1 smell) + models.events{model,event} writes + models.pruned
instrument.batches TELEMETRY_INSTRUMENT_BATCHES truebus.batches{event,name} job-batch lifecycle
instrument.redis TELEMETRY_INSTRUMENT_REDIS false — Redis command spans (key only, never values) + redis.commands counter; telemetry's own connections auto-ignored
instrument.redis_ignore_connections null → auto (metric-store + spool connections); set a list to override
instrument.user TELEMETRY_INSTRUMENT_USER true — tag request spans with enduser.id + enduser.type (model) + enduser.guard (multi-guard safe; never PII)
instrument.resources TELEMETRY_INSTRUMENT_RESOURCES true — peak memory + CPU per request/job/task; with cboxdk/system-metrics also real RSS + CPU utilization
instrument.profiling TELEMETRY_INSTRUMENT_PROFILING true — CPU profiling via ext-excimer (PECL, not bundled); a silent no-op without the extension. See Profiling below
instrument.scheduled_tasks TELEMETRY_INSTRUMENT_SCHEDULED_TASKS true — task spans + processed/failed/skipped counters
instrument.views TELEMETRY_INSTRUMENT_VIEWS true — nested render spans per Blade/PHP view/partial/component (detail-marked); view.render.count tally on the root span
instrument.session TELEMETRY_INSTRUMENT_SESSION truesession.driver + session.hash (truncated sha256, never the raw id) on request spans; journey queries via TraceQL
instrument.cache TELEMETRY_INSTRUMENT_CACHE false — cache.operations counters (hit/miss/write/forget)
instrument.cache_spans TELEMETRY_INSTRUMENT_CACHE_SPANS false — timeline spans per cache op with key/store/duration
instrument.cache_ignore_stores [] — stores never recorded (counters or spans); key-level control via Telemetry::classifyCacheKeysUsing()
instrument.mail TELEMETRY_INSTRUMENT_MAIL true — mail.send spans + counter
instrument.notifications TELEMETRY_INSTRUMENT_NOTIFICATIONS true — notification.send spans + counter
instrument.http_client TELEMETRY_INSTRUMENT_HTTP_CLIENT true — outgoing Http-client spans + duration by host/method/status
instrument.exceptions TELEMETRY_INSTRUMENT_EXCEPTIONS trueexceptions.reported counter + a structured, fingerprinted exception record (OTLP log) on every report(), incl. handled ones
instrument.exception_source TELEMETRY_INSTRUMENT_EXCEPTION_SOURCE false — attach the source lines around the throw site (exception.source); reads the file, so opt-in
instrument.pennant TELEMETRY_INSTRUMENT_PENNANT truefeature.checks{feature,result} + feature.unknown{feature} via laravel/pennant's own events; auto-activates when the package is installed
instrument.horizon TELEMETRY_INSTRUMENT_HORIZON true — supervisor/master state, long-wait detection, process restarts and OOM via laravel/horizon's own events; auto-activates when installed. Job-level tracing already works without this
instrument.reverb TELEMETRY_INSTRUMENT_REVERB truereverb.messages{direction}, reverb.channels{event,type}, reverb.connections.pruned via laravel/reverb's own events, plus live occupancy (reverb.connections.active, reverb.channels.subscribers{type}) sampled every 15s off message/connection traffic. Auto-activates when installed. Channel names and connection ids are never used as labels
instrument.inertia TELEMETRY_INSTRUMENT_INERTIA trueinertia.request span attribute from the X-Inertia header, plus inertia.version_mismatches counter + inertia.version_mismatch span attribute when the response carries X-Inertia-Location (Inertia forcing a full reload after an asset-version bump). Pure response inspection — no inertiajs/inertia-laravel dependency needed
instrument.rate_limiting TELEMETRY_INSTRUMENT_RATE_LIMITING truerate_limit.exceeded{limiter} counter from a 429 response, the driver-agnostic signal (Laravel's RateLimiter fires no event). Labeled by the throttle:<name> route middleware's limiter name when present, default for an inline throttle:60,1 spec, unknown with no throttle middleware at all
instrument.baggage TELEMETRY_INSTRUMENT_BAGGAGE true — inherit the caller's Telemetry::context() dimensions from an incoming W3C baggage header (traceparent's standard sibling). Gated on traces.continue_incoming too, since baggage is caller-supplied, unvalidated data. Outbound: Http::withTraceparent() attaches both headers
instrument.livewire TELEMETRY_INSTRUMENT_LIVEWIRE true — Livewire component lifecycle via livewire/livewire's own ComponentHook API, auto-activates when installed. livewire.components.mounted/.hydrated counters (mount/hydrate have no "after" phase in that API, so they're counted, not timed); livewire.render/.update/.call detail spans (these DO wrap the real work) carrying livewire.component + livewire.property/.method. The request middleware also names Livewire update requests after their component — livewire:{component} (livewire:batch for multi-component batches) becomes the span name and http.route label, with the component list on the root span as livewire.components
instrument.broadcasting TELEMETRY_INSTRUMENT_BROADCASTING truebroadcast.count root-span tally + a broadcast {event} detail span per Broadcaster::broadcast() call, driver-agnostic (Pusher, Ably, Reverb, Redis, Log, …) via a Factory/Broadcaster decorator. Carries broadcasting.driver, broadcasting.event, broadcasting.channel.count and a bounded broadcasting.channels shape (public/private/presence) — never raw channel names. Reverb's own richer connection/channel-occupancy metrics (instrument.reverb) are separate and unaffected
instrument.filesystem TELEMETRY_INSTRUMENT_FILESYSTEM truestorage.operations{disk,operation} counter + a storage {operation} detail span per disk operation (put, get, delete, copy, move, …), driver-agnostic (local, S3, whatever Flysystem supports) via a Factory/Filesystem decorator. Instruments both Storage::disk('x')->put(...) and the Storage::put(...) default-disk shorthand. Paths are safe on spans (per-occurrence) but never metric labels — same rule as query text
queue.propagate TELEMETRY_QUEUE_PROPAGATE true
queue.retry_link_store TELEMETRY_QUEUE_RETRY_LINK_STORE null — the app's default cache store
queue.retry_link_ttl TELEMETRY_QUEUE_RETRY_LINK_TTL 86400 seconds

Host & process monitor

Key Env Default
monitor.interval TELEMETRY_MONITOR_INTERVAL 15 s (daemon mode)
monitor.processes [] — name => pgrep pattern (e.g. 'reverb' => 'reverb:start')

telemetry:monitor --once from the scheduler (cron mode) or without --once under supervisor (daemon mode). Queue workers additionally self-report worker.memory.{php,rss}{queue,pid} (By) after every job — no monitor required for worker leak tracking.

Profiling (ext-excimer)

Key Env Default
profiling.period TELEMETRY_PROFILING_PERIOD 0.001 s sampling interval
profiling.min_duration_ms TELEMETRY_PROFILING_MIN_DURATION_MS 500 — only slower requests/jobs keep their profile (tail-based, like traces.details.slow_request_ms)
profiling.top_functions TELEMETRY_PROFILING_TOP_FUNCTIONS 20 functions kept per profile

Requires the PECL excimer extension (pecl install excimer or your distro's php-excimer package) — a statistical sampling profiler, not bundled, extension_loaded()-guarded everywhere. Without it, instrument.profiling is a silent no-op; telemetry:doctor reports whether it's active. Profiling always runs on a sampled trace (excimer's own sampling keeps the overhead low), but the result — a bounded "top functions by sample count" profile.captured event — is only kept for requests/jobs slower than min_duration_ms. This is not a full pprof export; the package has no opinion on a profiling backend.

Built-in providers

Key Env Default
providers.system.enabled TELEMETRY_SYSTEM_METRICS true
providers.system.cpu_interval TELEMETRY_SYSTEM_CPU_INTERVAL 0.1 s (0 disables CPU utilization)

The system provider only activates when cboxdk/system-metrics is installed.