Boring Observability GitHub

Prometheus metrics for the HTTP calls your Laravel app makes

Prometheus metrics for your application's outbound HTTP, with a Grafana dashboard to read them. Duration, host, endpoint and outcome of every transfer, and no bodies, headers or database.

$ composer require boring-o11y/httptheus

boring-o11y/httptheus · MIT · PHP 8.2+, Laravel 12 or 13, Guzzle 7 or 8

When a page gets slow, the cause is often a call your app made to somebody else's API. Your APM shows the request took four seconds. It rarely shows that 3.8 of them went to one vendor's search endpoint, or that the vendor has been timing out since nine this morning.

httptheus puts those calls in Prometheus. It hooks Laravel's HTTP client globally, so every Http::get() and Http::pool() is recorded without touching the code that makes them. The same middleware can be pushed onto a raw Guzzle handler stack, which covers the SDKs that build their own client.

Two metrics, and what each one answers

httptheus_client_request_duration_seconds is a histogram labelled by host, method, endpoint and status class. It answers "how slow is Stripe at p95" and "how many requests a minute go to this API", from the same series:

histogram_quantile(0.95, sum by (le) (
  rate(httptheus_client_request_duration_seconds_bucket{host="api.stripe.com"}[5m])
))

httptheus_client_request_errors_total counts only the transfers that never got a response, with a reason of timeout, DNS, connection refused, TLS or network. A 500 already has a status class. What a status cannot tell you is whether the other side is down or your DNS is broken, and that is what this counter is for.

Bodies and headers are never read, and nothing is written to your database. A Grafana dashboard for both metrics ships with the package and imports onto any Prometheus datasource.

Endpoints without a series explosion

A label per URL path would create a series for every user id you ever called. httptheus normalises paths before they become labels: numeric ids, UUIDs, ULIDs and long segments become :id, and only the first three segments are kept. So /v1/users/8134/orders/99 is recorded as /v1/users/:id/*. If that heuristic is not enough for an API you call, an allow-list of patterns puts everything else into a single other series. The dashboard has a panel counting distinct endpoint labels, so you see growth before Prometheus does.

Where the numbers are kept between requests

Under PHP-FPM each request is a fresh process, so counters need shared storage to survive until the scrape. httptheus uses APCu when it is available and can use Redis, which is what queue workers need because APCu is per process. If you already run spatie/laravel-prometheus, the metrics appear on its /prometheus endpoint with no configuration.

Otherwise httptheus serves its own scrape at /httptheus/metrics. A scrape lists every host your app talks to, so until you allow an IP range or add a gate it answers only in the local environment.

Two things to know

  • It counts transfers, not calls. A redirect chain, or a request retried with Laravel's retry(), records every hop, so the count can be higher than the number of calls your code made.
  • Aggregates only. When you need the individual request that failed, with its response body, that is a different tool.

Every configuration key, and how to run the test suite, is in the README on GitHub. Bugs and questions go in its issues.

Our other packages

  • Horizon Delayed Jobs

    A Retries page for the Horizon dashboard. It lists the jobs waiting out a backoff or a delay, which Horizon does not show, with a button to run one now.

  • wirestan

    PHPStan rules for Livewire. A public property the browser can set but the server trusts, like $tenantId seeded in mount(), fails CI until it is marked #[Locked].

  • Unique Job Middleware

    Checks a ShouldBeUnique job's lock again when a worker picks it up, and skips the job if another one holds it. Fires an event for each skip, so you can count them.