Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Telemetry Laravel Package

cboxdk/laravel-telemetry

Collector-free telemetry for Laravel: Prometheus metrics aggregated in Redis or APCu, plus OTLP traces, logs and events over plain HTTP JSON. Works under FPM, auto-instruments requests, queue jobs, DB queries and commands.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and publish.

composer require cboxdk/laravel-telemetry:3.8.1
php artisan vendor:publish --tag=telemetry-config

Pin the exact version rather than using a caret constraint — the adoption caveat at the bottom of this page explains why.

Set three things before anything else: TELEMETRY_ENABLED, a shared metric store (redis or apcu — array/null are for tests and single-writer runtimes), and a local trace sample rate of 1.0 so every request is kept while you learn.

Read in this order — the package ships unusually good docs for its age:

  1. llms.txt — the doc index; it's the fastest map of the tree.
  2. vendor/cboxdk/laravel-telemetry/docs/getting-started/api-reference.md — the public surface (it is a thin facade, you can read it in ten minutes).
  3. docs/core-concepts/traces.md — the sections "Query diagnostics", "Request phases" and "Directives" are the ones you will actually use.

First use case: one business counter + one scrape.

use Cbox\Telemetry\Facades\Telemetry;

Telemetry::counter('orders.created')->inc(1, ['plan' => $order->plan]);

Point Prometheus at your scrape route, or fire one curl and confirm the series appears as orders_created_total. That single round trip exercises the whole path — instrument registration, store write, render-time Prometheus derivation — and if the series shows up in a scrape from a different FPM worker than the one that incremented it, your store is configured correctly. That is the package's entire reason to exist, so verify it on day one rather than on the day you discover your counters are all 1.

Implementation Patterns

Pick the instrument by shape, not by habit

Intent Call
Something happened Telemetry::counter('orders.created')->inc(1, ['plan' => $plan])
A value read at scrape time Telemetry::gauge('queue.depth', fn () => Queue::size())
A value that moves up and down Telemetry::gauge('imports.in_flight')->increment() / ->decrement() / ->set($n)
A distribution Telemetry::histogram('checkout.duration', unit: 's')->record($s) or ->time(fn () => ...)
A decision you'll query later Telemetry::event('autoscale.decision', ['workers' => 7])
Timed, traced work Telemetry::span('import.customers', fn ($span) => ...)

Observable gauges (gauge('x', fn () => ...)) and push gauges (gauge('x')->set()) are deliberately distinct API shapes. Never mix them for one name — pick the shape at registration time and stay with it.

Check for existing instrumentation first

Requests, queue dispatch/wait, DB queries, cache, scheduler, mail, notifications, outgoing Http, reported exceptions, Livewire, Horizon, Reverb, Pennant and laravel/ai agents are already covered. Writing Telemetry::span('http.client') around a Http::get() gets you a duplicate, not a better trace. Instrument the business meaning instead: import.rows.processed, billing.invoices.overdue, checkout.abandoned.

Queue jobs are covered down to the attempt, not just the terminal outcome. As of 3.8.1 a job that throws and is released for retry carries the exception on that attempt's span — exception event, error.type, message as status — so a job that failed six times gives you six readable attempts instead of six red spans saying released for retry and nothing else. Don't write your own catch/report wrapper to get that; it's already there.

Name in OTel vocabulary, put units in the instrument

[a-z][a-z0-9._]*, dot-namespaced: orders.created, billing.invoices.overdue. Units belong to the instrument (unit: 's', 'By', '{jobs}'), never to the name, and durations are seconds. Prometheus names (orders_created_total) are derived at render time — never write them into code, dashboards snippets or alerts you generate.

Keep labels bounded; push high-cardinality facts onto spans

This is the single most common way to ruin a metrics backend, and the package gives you the right escape hatch:

// label: bounded set only — plan, status, queue, route pattern, region
Telemetry::counter('orders.created')->inc(1, ['plan' => $plan]);

// unbounded identity: span attribute, where it costs one trace, not a permanent series
$result = Telemetry::span('billing.recalculate', function ($span) use ($invoice) {
    $span->setAttribute('invoice.id', $invoice->id);
    return $this->recalculator->run($invoice);
}, attributes: ['billing.mode' => 'full']);

Every distinct label value is a permanent series in the store. User ids, emails, URLs, UUIDs, cache keys and free text never go in a label.

Debug a slow request through the trace, not through new logging

Get the id from the X-Trace-Id response header or Telemetry::traceId(). Read the root span first — its tallies survive even when detail spans are trimmed by tail sampling: db.query.count, db.query.time_ms, db.query.n_plus_one.count, db.query.duplicate.count, model.hydrations, laravel.routing_ms / middleware_ms / handler_ms / terminate_ms, and telemetry.overhead_ms (which tells you whether the package itself is the problem).

Then separate your two query pathologies:

  • N+1 — same SQL shape, different values. Event db.query.n_plus_one_detected. Fix with with() or whereIn.
  • Duplicate — same SQL, same values. Event db.query.duplicate_detected. Fix by memoizing.

Both carry laravel.db.query.shape plus code.file.path / code.line.number. Go to that file and line first — it's your loop or your Blade view, not Eloquent internals. The same pattern exists for outgoing calls (http.client.n_plus_one_detected, fixed by Http::pool() or a batch endpoint; pagination is never flagged).

For background work, read the attempt span rather than the job span: retries now each carry their own exception detail, and a terminal JobFailed still records exactly one exception event per attempt — not the same failure twice.

Sample surgically with directives, not by raising the global rate

php artisan telemetry:watch --route="orders.*" --minutes=30 --profile --reason="slow orders page"
php artisan telemetry:watch --user=42 --minutes=60
php artisan telemetry:watch --context=tenant.id=7
php artisan telemetry:watch --list

Kept traces carry telemetry.directive, and the directive expires on its own. In code: Telemetry::directives()->add(Directive::route('orders.*')->profile()->for(minutes: 30)). If a route is always trimmed, add a traces.details.keep_when rule instead of disabling tail mode.

Compare before/after with a label, not by copying trace ids

curl -H 'X-Telemetry-Label: before' https://app.test/orders
# fix
curl -H 'X-Telemetry-Label: after' https://app.test/orders

The value lands on every span as laravel.trace.label, so you can diff two root spans or query { span.laravel.trace.label = "after" }.

Prove the fix with a fake

Telemetry::fake();

$this->getJson('/orders')->assertOk();

$fake->assertNoNPlusOne();
$fake->assertQueryCount('<=', 10);
$fake->assertEventNotEmitted('http.client.n_plus_one_detected');

Telemetry::fake() is the testing seam, and it lets a performance fix become a regression test — which is far more valuable than a dashboard nobody checks.

Long-delayed jobs start a linked trace

A queued job is a child span of the dispatching request. For jobs delayed by hours or fanned out by the thousand, that produces a trace that spans a night. Implement Cbox\Telemetry\Contracts\StartsLinkedTrace (or list the class in queue.linked_traces) to start a new trace linked to the dispatch. The parent is linked, never silently dropped.

Prometheus pull vs OTLP push are different deployment stories

Prometheus metrics are rendered at scrape time; histograms are stored non-cumulative with an overflow slot and accumulated by the renderer. OTLP goes out over plain HTTP JSON on raw curl — no protobuf, no C extension, no sidecar. That is why it survives FPM. Send logs through Logging\TelemetryLogHandler and you get OTLP log records from the Monolog stack you already have.

Gotchas and Tips

The store is the whole design — get it right or everything is a lie. Metrics must live in state shared by every process that writes the series. On classic shared-nothing FPM with several workers, array and process-local apcu give you per-worker counters that look plausible and are wrong. Redis (with explicit indexes, no KEYS/SCAN) is the safe default. The decision doc, docs/decisions/0001-metric-state-on-single-process-runtimes.md, spells out exactly when a process-local store is legitimate — read it before you "optimize away" Redis.

Telemetry never throws — failures are silent by design. Recording and export paths run through FailSafe::guard; only instrument registration may throw (bad name, type conflict). Good for production, bad for you the first time a typo'd label quietly does nothing. Verify with Telemetry::fake() rather than assuming.

Zero cost when disabled is real, and it bites tests. Disabled means no listeners registered, no-op instruments, no providers booted. Consequence: if your test environment sets TELEMETRY_ENABLED=false, the query listeners are never attached and assertNoNPlusOne() is vacuously green. Keep telemetry enabled in the test env and disable only the exporters.

One name, one instrument type, forever. Renaming orders.created to shop.orders.created, or flipping a counter to a gauge, orphans every existing series and breaks dashboards silently. Names and types are a schema; treat changes as migrations.

Observable gauge callbacks execute at scrape time. gauge('queue.depth', fn () => Queue::size()) runs on every scrape. Keep it cheap, non-blocking, and free of queries — a slow callback makes the scrape time out and you lose all metrics on that endpoint, not just one.

Don't nest native units, and never run the native profiler alongside excimer. cbox_telemetry abandons the outer unit on a second begin(), so NativeProfiler refuses nesting: the outermost wins, and commands hosting their own units (queue:work, schedule:run) open none. See docs/decisions/0002-native-unit-of-work-ownership.md.

traces.continue_incoming_parent=false is narrower than it looks. It exists for an edge whose parent span id names no exported span — the trace id is kept and the edge span is linked. It is not a way to drop upstream context; the link is never silently dropped.

Job retry spans: know which behaviour you're on. On 3.8.1+ a released-for-retry attempt carries the thrown exception (exception event, error.type, message as status), sourced from JobExceptionOccurred on every supported Laravel version and from JobReleasedAfterException where that event exists (Laravel 13.31+). On older releases the attempt span is red with status released for retry and no exception attached, which reads like a failure with no cause — don't spend an afternoon chasing a bug that was fixed by a version bump. A release with no exception still legitimately says released for retry. And a terminal failure records one exception event per attempt, not two — if you see doubling after a framework upgrade, that's a regression report, not a config knob.

Config knobs worth knowing by name: traces.response_header, traces.server_timing (opt-in — puts db/cache/redis/telemetry/app time into the browser's DevTools Server-Timing), traces.details.keep_when, traces.details.slow_span_ms, instrument.query_duplicates (+ _threshold, default 3), instrument.query_origin (set to all locally to get the app line on every query span, not just flagged ones), queue.linked_traces, instrument.ai_pricing.models (per-1M-token prices, * for snapshots → gen_ai.usage.cost and the gen_ai.client.cost counter). Don't multiply token costs yourself, and don't hand-log per-user AI spend — turn on instrument.ai_usage_events.

Extension points, in rough order of usefulness: Contracts\MetricStore (pluggable store), Contracts\NativeRuntime (runtime seam for the optional extension), Logging\TelemetryLogHandler (Monolog → OTLP), and Testing\TelemetryFake. If you're instrumenting a package rather than an app, register instruments in a provider and let the disabled path no-op — do not guard on config() at every call site.

Contributing workflow if you vendor or fork: composer check (pint --test + phpstan level 9 + pest) before every commit; vendor/bin/pest --group=redis and --group=apcu need real infrastructure or they will silently skip; --group=benchmark backs the overhead numbers in docs/production/performance.md. Codebase conventions are strict — declare(strict_types=1), final classes, readonly value objects, and Support\Cast for narrowing mixed rather than raw casts. Public API changes require docs, a CHANGELOG.md entry and updates to resources/boost/guidelines/core.md + llms.txt.

Watch the release cadence when you pin. The assessment baseline was v3.6.1; v3.8.1 landed with a queue-tracing fix under a patch-version bump, and no notes covering the intervening 3.7/3.8 line were available for review. That is normal semantic versioning for a bug fix, but it means the changelog is your only reliable map between pinned versions — read it on every bump before you upgrade, and keep a note of which version your dashboards and tests were validated against.

Adoption risk is the honest caveat. Three stars, zero dependents, a single vendor, and a release date ahead of the current calendar — this is a package you should pin to an exact version and treat as internal infrastructure. The mitigation is structural rather than contractual: telemetry never throws into the app, disabling is genuinely zero-cost, and the public surface is a thin facade over OTel-shaped instruments. If you ever need to leave, you delete the Telemetry:: calls or swap them for the official OTel SDK — the names and units already comply, so your dashboards and alerts survive the migration. Adopt it for the FPM-correct metric store and the trace diagnostics; keep the blast radius small by confining the facade to a thin application-level telemetry service rather than sprinkling it through domain code.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource