avto-dev/app-metrics-laravel
Laravel package for collecting and exposing application metrics, with built-in integrations for Prometheus and other backends. Adds middleware and service providers to track requests, responses, and custom counters, helping you monitor performance and health with minimal setup.
Install and confirm the version line first. This package is small (4 stars) but has had a semver-in-minor event: v2.10.0 raised the floor to Laravel ^11.0 while still being a 2.x release. Before anything else:
composer require avto-dev/app-metrics-laravel:^2.10
composer show avto-dev/app-metrics-laravel # confirm resolved version + requires
If you are on Laravel 10, do not run a bare composer update with a permissive constraint — pin to the last Laravel 10-compatible release and upgrade the framework first.
Auto-discovery means near-zero bootstrap. The service provider is discovered automatically, so the first thing to look at is not code but config:
php artisan vendor:publish --provider="AvtoDev\AppMetrics\Laravel\ServiceProvider" --tag=config
Open the published config/metrics.php and read four things, in order:
Verify the endpoint before writing any code. This is the fastest smoke test:
php artisan route:list | grep -i metrics
curl -s http://localhost:8000/metrics | head -50
You should see Prometheus exposition text (# HELP, # TYPE, name{label="value"} 1). If you see HTML, a login redirect, or a 419, jump to Gotchas — the endpoint is being swallowed by the web middleware group.
First real use case. Do not start with custom metrics. Start by enabling the request middleware so you get status-code counters and request-duration histograms for free, confirm they appear in the scrape output after a few requests, then add one business metric (e.g. orders_created_total) in the place that already owns that domain event. That sequence gives you a proven scraper → storage → dashboard path before you invest in instrumentation.
The underlying app-metrics library is built around metric objects that expose a name, labels, and a value computed at scrape time — not around an in-process counter you increment. Follow that model:
namespace App\Metrics;
use AvtoDev\AppMetrics\Metrics\MetricInterface; // confirm exact contract in vendor/
final class OrdersCreated implements MetricInterface
{
public function name(): string
{
return 'orders_created_total';
}
/** @return array<string, string> */
public function labels(): array
{
return ['channel' => 'web'];
}
public function value(): int|float
{
return Order::query()->where('created_at', '>=', now()->subDay())->count();
}
}
Register it either in the config's metric list (preferred — it survives config caching) or at runtime via the manager binding. Check src/ for the registration method name; both a metrics array in config and a Manager::addMetric()-style call typically exist.
Why value-at-scrape matters: in PHP-FPM each request is a fresh process, so an in-memory counter incremented in a controller resets to zero before the next scrape. Metrics whose value() reads from the source of truth (DB, Redis, cache) are correct across processes and across pod replicas. Reserve in-memory increments for long-lived processes (Octane, queue workers) and only when you understand the reset semantics.
Put custom instrumentation in events/listeners, jobs, or service-layer methods rather than sprinkling it across controllers. A single OrderCreated listener that records to Redis is testable and survives refactors of the HTTP layer. Controllers are the wrong seam for business metrics; they are the right seam for HTTP metrics, which the package's middleware already covers.
For values that are expensive to compute (queue depth, stale-cache rows), don't recompute on every scrape. Refresh into the cache on a schedule and have the metric read the cached value:
// app/Console/Kernel.php (or routes/console.php in Laravel 11+)
$schedule->call(fn () => Cache::put('metrics.queue_depth', Queue::size('default'), 120))->everyMinute();
Then value() is a Cache::get() — a scrape costs one Redis round-trip, not a SELECT COUNT(*) over a large table. With a 15s Prometheus scrape interval and 20 replicas, that difference is the difference between a metrics endpoint and a self-inflicted outage.
The /metrics route should be reachable by the scraper only. Practical options, in order of preference:
allowed_ips-style middleware or a network policy at the ingress.Feature-test the value, not the exposition format:
it('reports order count', function () {
Order::factory()->count(3)->create();
$this->get('/metrics')
->assertOk()
->assertSee('orders_created_total', false);
});
Because value() reads from the DB, tests with RefreshDatabase are deterministic. Assert on the metric name and label set rather than parsing the whole payload — the exposition format is the library's concern, not yours.
If you run several Laravel services, standardise three things in a shared internal package or a documented convention: metric naming (snake_case, base units — seconds not milliseconds, bytes not MB), a common service/env label injected via config, and a shared Grafana dashboard skeleton. The package gives you the plumbing; naming discipline is what makes the data joinable across services.
The endpoint is behind web middleware by default in many setups. Symptoms: a 419, a 302 to /login, or an HTML body in the scrape. Fix by moving the route out of the web group in config/metrics.php (or overriding the route definition). This is the single most common first-day failure with metrics packages in Laravel.
Cardinality is the real risk, not performance. A label with a user ID, an order ID, or a raw URI (/orders/12345) will create unbounded series and eventually take down Prometheus. Rules of thumb: label on route name not path; label on enum-like values (status, channel, queue); never label on anything a user can generate. If you need per-tenant breakdowns, cap the tenant count and bucket the rest into other.
Prometheus naming conventions are enforced by convention only. orders_created and orders_created_total are different metrics to Prometheus. Counters must end in _total, and once a name is in use in a backend, renaming it silently breaks dashboards and alerts. Decide names before the first deploy — migrating a metric name in Prometheus is a manual, historical-data-losing operation.
Config caching interacts with runtime registration. If you register metrics by calling the manager during a service provider's boot(), it still works under config:cache, but metrics registered by reading a dynamically-built config array will freeze at cache time. Prefer a static metrics array in config and let each metric class do its own work in value().
Long-running processes change the rules. Under Octane, Swoole, or a queue worker that stays alive, an in-memory counter accumulates across requests/jobs — and accumulates per worker process, so N workers each report a fraction of the truth. Either compute from a shared store, or make peace with per-process semantics and don't aggregate across replicas.
Scrape cost is a production concern. Every scrape executes every registered metric's value(). Ten metrics each doing a COUNT(*) on a million-row table, scraped every 15s by a HA Prometheus pair, is 8 queries/second of pure overhead per replica. Cache aggressively; keep the number of DB-backed metrics in the single digits.
Semver cannot be trusted here — read the changelog on every bump. Dropping Laravel 10 shipped as 2.10.0, a minor. Treat any 2.x update as potentially containing a breaking platform requirement: pin to ~2.10 (or exact), read the release notes, and validate in a branch. Set a Renovate/Dependabot rule that never auto-merges this package.
Laravel 13 support may be declarative. The release notes show a constraint bump plus CI image changes; there is no evidence of Laravel-13-specific code changes. Before adopting on 13, confirm the package's CI matrix actually runs against 13, and run your own integration test against the exact 13 minor you deploy.
PHP version drift between the package's Docker image (8.5) and your runtime. The composer.json runtime constraint is the authoritative one, not the CI container. Check composer show avto-dev/app-metrics-laravel for php requirements and confirm it matches or is below your production PHP.
Debugging a missing metric. Work outward: (1) curl the endpoint directly on the pod; (2) check the metric appears in the raw exposition text; (3) if it's absent, confirm the class is registered — a class that throws in value() is typically swallowed rather than fatal, so a broken metric looks identical to an unregistered one; (4) temporarily wrap value() in a try/catch with Log::warning to surface the exception; (5) confirm the scrape target's instance/job labels in Prometheus, since a successful scrape of the wrong pod looks like missing data downstream.
Extension points worth knowing: the config array (metrics, route, middleware), the manager binding in the container (swap it to add cross-cutting labels like service/env to every metric), custom metric classes implementing the library contract, and the middleware (subclass or replace it if you need route-name labels or want to exclude health-check and metrics paths from your own instrumentation to avoid feedback loops).
One self-referential trap to close early: exclude the /metrics and health-check routes from the request middleware's own counters, otherwise scraper traffic dominates your http_requests_total and every latency dashboard is polluted by 15-second-interval scrapes.
How can I help you explore Laravel packages today?