motuslogistik / metrics
Laravel package for prometheus style metrics
Requires
- php: ^8.4
- illuminate/contracts: ^11.0||^12.0||^13.0
- open-telemetry/api: ^1.0
- open-telemetry/sdk: ^1.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^10.0.0||^9.0.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
Suggests
- ext-opentelemetry: Required for MetricHook::observe() — auto-instrument class methods with latency histograms.
This package is auto-updated.
Last update: 2026-08-31 09:15:13 UTC
README
Internal package — proprietary to Motus Logistik. Not licensed for external use. See LICENSE.md.
A thin Laravel-friendly facade over the OpenTelemetry PHP SDK. Counters, gauges and timing helpers that emit OTel instruments — the recording layer, exporter, and aggregation are handled by OTel.
Architecture
Laravel app ──(this package)──> OTel SDK ──OTLP──> OTel Collector ──> Prometheus / Grafana / …
In classic PHP-FPM (or any forking model) each request is a fresh process, so per-process metric state cannot be scraped meaningfully. You must run an OpenTelemetry Collector somewhere reachable (sidecar container, host-level daemon, …) — that's where requests push to, and that's what Prometheus scrapes.
This is a breaking change from the pre-1.x line, which used APCu / Redis / Swoole shared memory and exposed /metrics directly from PHP. See CHANGELOG.md.
Installation
composer require motuslogistik/metrics
You also need an OpenTelemetry SDK bootstrap. The most common setup is environment-driven autoload via open-telemetry/opentelemetry-auto-laravel, or manual bootstrap in a service provider. At minimum the SDK needs:
OTEL_PHP_AUTOLOAD_ENABLED=true OTEL_SERVICE_NAME=your-app OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 OTEL_METRICS_EXPORTER=otlp OTEL_LOGS_EXPORTER=none OTEL_TRACES_EXPORTER=none # unless you also want traces
Recommended OTel env vars
For PHP-FPM (and any forking model), set delta temporality:
# Delta temporality. Each export reports the delta since the last export # instead of a cumulative total. This avoids the "stuck-at-1" symptom where # Laravel re-bootstraps the container per request and the meter state is # reset before the cumulative count can grow. Requires a backend that can # consume delta OTLP (most can; some need the Collector's # `deltatocumulative` processor in front for native PromQL `rate()` to work). OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta
Optional, but if you leave it unset you get cumulative temporality — fine for long-lived processes (CLI workers, daemons) but fragile under PHP-FPM. Set it.
Exponential histograms — not supported in PHP yet
OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION=base2_exponential_bucket_histogram is a no-op in the PHP SDK as of v1.14: MeterProviderFactory::create() carries a @todo to honor it, and no Base2ExponentialBucketHistogramAggregation class exists in open-telemetry/sdk. There's no programmatic workaround either — Views can route between explicit-bucket and sum/last-value aggregations, but there's no exponential aggregation class to route to.
In practice: stay on explicit buckets, and size the bucket layout per metric (see histogram_buckets config) for any histogram whose range you can't predict from the default [0.001 … 10] seconds-scale layout.
Package config
php artisan vendor:publish --tag="metrics-config"
// config/metrics.php return [ 'meter_name' => 'motuslogistik/metrics', // Prefix prepended to every metric name at instrument creation. Lets a // deployment namespace all its metrics without touching call sites. // Raw-concatenated, so include your own separator. Set via env: // METRICS_NAME_PREFIX=motus_ -> counter('orders_created') exports as motus_orders_created // WARNING: the name is baked into the OTel instrument identity and cached // per process — treat it as stable across deploys, not a runtime toggle. // Bucket overrides below are keyed by the *unprefixed* logical name. 'prefix' => env('METRICS_NAME_PREFIX', ''), // Default explicit bucket boundaries (seconds scale). Ignored if you've // switched to exponential histograms via the env var above. 'default_histogram_buckets' => [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], // Per-histogram bucket overrides, keyed by exact metric name. Use for // metrics on a different scale than seconds (byte sizes, item counts, // sub-millisecond timings, etc.). 'histogram_buckets' => [ // 'payload_size_bytes' => [256, 1024, 4096, 16384, 65536, 262144, 1048576], ], // Flush after each queue job, and on Octane request/task/tick termination // and worker shutdown. Both default to true; see the caveats below. 'flush_on_queue_job' => true, 'flush_on_octane' => true, ];
PHP-FPM, queue worker, and Octane caveats
A few sharp edges to be aware of:
- Instruments are cached by name per process. The OTel SDK creates an instrument on first
histogram($name)call per worker and reuses it for the worker's lifetime. Bucket boundaries set viadefault_histogram_buckets/histogram_bucketsare honored on that first creation only — changes require a worker restart (redeploy). - Bucket layout changes invalidate historical series. Old data lives in the backend under the old
lelabels; new exports use the new labels. Queries spanning the transition will mix two layouts. Either query strictly after the cutover, or rename the metric on the switch. - Worker recycling shows as counter resets. With cumulative temporality, a worker dying mid-window drops its accumulated count to 0 in the next worker. PromQL
rate()is reset-aware so this usually doesn't hurt, but it's another reason delta temporality is easier to reason about under FPM. - Queue workers auto-flush after every job. The OTel PHP SDK uses an
ExportingReaderwith no periodic export, so without this metrics from long-running workers would only flush on worker death. The package registersQueue::after/Queue::failinglisteners that callMetrics::flush()after each job. Setmetrics.flush_on_queue_jobtofalseto disable. - Octane works out of the box. Under Laravel Octane (Swoole/RoadRunner) a worker serves thousands of requests, so the process-death flush never arrives and HTTP-recorded metrics would buffer indefinitely. The package listens on Octane's
RequestTerminated,TaskTerminated,TickTerminated(all dispatched after the response is sent, so no added latency) andWorkerStoppingevents, flushing on each. No setup needed — install Octane and it just works. Setmetrics.flush_on_octanetofalseto disable.observe()timing is coroutine-safe, so it stays correct insideOctane::concurrently()and Swoole coroutines. - Long-running processes outside the queue need their own flush. AMQP consumers, custom daemons, scheduled-but-resident commands etc. never trigger the queue listeners. Either call
Metrics::flush()at a sensible point in your loop, or — if you're usingobserve()on the per-event method — chain->flushAfter()to flush after each recorded sample. SeeMetrics::flush()andobserve()->flushAfter().
Usage
Three metric types, three helpers:
counter('orders_created', ['status' => 'paid'])->incr(); gauge('cpu_usage', ['host' => 'web1'])->record(0.83); histogram('http_latency', ['path' => '/home'])->record(123);
Counters are monotonic — only incr(). Under the hood they emit to an OTel Counter, which surfaces in Prometheus/Groundcover with # TYPE counter so PromQL's rate() and increase() apply naturally:
counter('orders_created')->incr(); // +1 counter('orders_created')->incr(5); // +5
For up-and-down values like queue depth, use a gauge() instead — counters are for event counting only.
record() (no args) is kept as an alias for incr() so existing call sites keep working.
The label array is a shortcut; ->label() still works for dynamic labels or longer chains:
counter('orders_created') ->label('status', $order->status) ->label('channel', $channel) ->incr();
Builder style
metric() returns an untyped PendingMetric. It has no record() — pick a type first:
metric('orders_created') ->label('status', 'paid') ->counter() ->incr();
Labels accumulated on metric() carry over when you call ->counter(), ->gauge(), or ->histogram().
Timing closures
histogram('http_render') ->label('path', '/home') ->time(fn () => renderHomepage());
time() runs the closure, records the duration (seconds, float), and returns the closure's result. It lives on histogram() only — histograms are the right instrument for distribution-shaped data like latencies (you get count, sum, buckets, percentiles).
observe() — auto-instrument a method
For a class method you'd otherwise wrap by hand in every call site, the observe() helper hooks it once and emits a latency histogram for every invocation. Requires the opentelemetry PHP extension; without it, calls log a warning and no-op.
// In a service provider's register() observe(GetPersonalAccessTokenQuery::class, '__invoke') ->name('get_personal_access_token_query_seconds');
That emits get_personal_access_token_query_seconds (a histogram) with a status label (success / error / __error__) on every call to GetPersonalAccessTokenQuery::__invoke.
Labels from the invocation:
Label closures get named-argument injection from (instance, params, return, exception) — declare only what you need:
observe(Order::class, 'save') ->name('order_save_seconds') ->label('customer_id', fn ($instance) => $instance->customer_id) ->label('was_new', fn ($return) => $return === true);
Custom success/error logic:
By default, "success" means the method returned without throwing. Override with successResolver() when that's not enough — e.g. a method that returns false on validation failure:
observe(SomeJob::class, 'handle') ->name('some_job_seconds') ->successResolver(fn ($return, $exception) => $exception === null && $return !== false);
Status values you'll see in PromQL:
success—successResolverreturned truthy (or no exception by default)error—successResolverreturned falsy (or an exception was thrown)__error__— a label callback or the success resolver itself threw. Inspect logs.
Registering many at once:
Iterate a list:
foreach ([StepA::class, StepB::class, StepC::class] as $step) { observe($step, '__invoke') ->name('pipeline_step_seconds') ->label('step', fn ($instance) => class_basename($instance)); }
One metric, dimensional step label — PromQL aggregates across or drills into individual steps with sum by (step).
Flushing for long-running processes:
If the observed method runs inside a long-lived process that isn't a queue worker (an AMQP consumer, a custom daemon), the queue-job auto-flush doesn't apply and recordings sit in the SDK's ExportingReader until the process dies. Chain ->flushAfter() to force-flush after each invocation:
observe(TmsListen::class, 'handleEvent') ->name('tms_event_handle_seconds') ->flushAfter();
Caveats:
- The OTel SDK caches instruments by name per process, so the histogram's bucket layout is fixed at first call. If you change
default_histogram_buckets, restart workers. observe()registers the hook once at call time (typically in a service provider). The hook fires every time the target method is invoked thereafter.- The label value goes through OTel's attribute system; keep cardinality bounded. Don't put user IDs or request IDs as label values — use them in span attributes / logs instead.
Metrics::trackQueueJobs() — instrument every queue job
Hooks Laravel's queue lifecycle once and emits a latency histogram for every
processed job, instead of you wiring observe(...) on each job class. Call it
once, in a service provider's boot():
use motuslogistik\Metrics\Metrics; public function boot(): void { Metrics::trackQueueJobs(); }
That emits queue_job_seconds (a histogram) on every job, with labels:
job— the resolved job class namequeue— the queue the job ran onconnection— the queue connectionstatus—success, orerrorif the job threw / was marked failed
# p95 processing time per job class
histogram_quantile(0.95, sum by (job, le) (rate(queue_job_seconds_bucket[5m])))
Scoping:
Metrics::trackQueueJobs() ->except(HeartbeatJob::class) // skip noisy / high-frequency jobs ->name('job_runtime_seconds'); // override the metric name Metrics::trackQueueJobs() ->only(ImportJob::class, ChangeOrderJob::class); // allow-list instead
Zero-config alternative. If you just want every job tracked without touching
a service provider, flip the config flag instead of calling trackQueueJobs():
// config/metrics.php 'auto_track_jobs' => true, 'auto_track_jobs_except' => [ HeartbeatJob::class, ],
The package wires trackQueueJobs()->except(...) for you on boot. Use either
the config flag or a manual trackQueueJobs() call — doing both counts every
job twice.
What it measures — and what it doesn't. The histogram covers the whole
JobProcessing → JobProcessed/JobExceptionOccurred window, i.e. everything
inside the worker's $job->fire(): payload deserialization (including
SerializesModels model re-hydration — which can hit the DB), the job
middleware pipeline (WithoutOverlapping, RateLimited, …), the handle()
body, and post-handle chain/batch bookkeeping. It does not include queue
wait time or the payload pop.
That's usually what you want for "how long does this job take to process." If you
need strictly the handle() method body — excluding deserialization and
middleware — use observe($job, 'handle')
instead; the two measure deliberately different spans and can be used together.
Notes:
- Call once. It's idempotent only in the sense that each call registers a fresh
set of listeners — calling it twice double-counts. Once per worker (a provider
boot()) is correct under Octane. - Flushing is handled by the existing queue auto-flush (
flush_on_queue_job); noflushAfter()needed. - Keep
queuecardinality in mind if you generate per-tenant or per-id queue names —except()/only()won't help there since the explosion is in the label, not the job class.
Metrics::flush()
Force-flushes the OTel MeterProvider. The OTel PHP SDK uses an ExportingReader with no periodic export, so any long-running process that isn't a queue worker (the package handles those automatically) needs to flush manually:
use motuslogistik\Metrics\Metrics; while ($message = $consumer->next()) { handle($message); Metrics::flush(); }
Safe to call when nothing has been recorded — forceFlush() is a no-op in that case. Also safe when the OTel SDK is disabled (OTEL_SDK_DISABLED=true): the noop provider is detected and skipped.
gauge()->set()
set() is an alias for record() on Gauge, kept so call sites migrating from the old counter()->set($n) API need only swap the helper:
counter('orders_total')->set(42); // old (removed) gauge('orders_total')->set(42); // new
Backed enums
Everywhere a string is accepted (name, label name, label value), a BackedEnum works too:
enum Status: string { case Paid = 'paid'; } counter('orders_created') ->label('status', Status::Paid) ->incr();
Int-backed enums are coerced to string (Status::One = 1 → "1").
->global() is now a no-op
In the previous architecture ->global() routed a metric to a Redis store shared across hosts. With OTel that distinction disappears: every metric flushes via OTLP to the Collector, which is already global. The method is kept on PendingMetric so existing call sites don't break, but it does nothing.
Removed in the OTel migration
counter()->set($n)— OTel counters are delta-only. Use agauge()for absolute values you sample from elsewhere./metricsroute — the Collector exposes Prometheus now.Storecontract + all stores — OTel owns aggregation.ext-apcuis no longer required.- Reserved-character validation on names/labels (
|;=) — that was only needed for the old key format.
If you need any of these, pin to a pre-1.x version of this package.
Testing
composer test
Tests use OTel's InMemoryExporter + ExportingReader — no Collector or network required. See tests/TestCase.php for the wiring.
Changelog
Please see CHANGELOG for more information on what has changed recently.