Observability dashboard (opt-in)
An opt-in, customer-facing analytics UI over the delivery log. It reads; it records nothing.
Three steps, all required
# 1. The panels are Livewire components built from WireKit.
composer require livewire/livewire pushery/wirekit
// 2. Register the provider — it is NOT auto-registered.
// bootstrap/providers.php
Pushery\Webhooks\Dashboard\WebhooksDashboardServiceProvider::class,
// 3. config/webhooks.php — switch it on. It reads Platform's delivery log,
// so the Platform layer must stay enabled (or point 'source_model' at your own).
'dashboard' => ['enabled' => true, /* … */],
What it shows
The class-based Livewire 4 panels show KPI cards, a stacked hourly-activity chart (drawn server-side as SVG — no chart library, no compiled JS), latency percentiles (P50/P90/P95/P99), a live recent-delivery queue, top event types, an endpoint setup summary, and a sortable, filterable, paginated deliveries table with a detail drawer and one-click redelivery — on a tabbed full-page component.
Access is guarded by a view-webhook-dashboard gate and a WebhookDeliveryPolicy, and every
query is tenant-scoped.
The deliveries table is also bounded in time. dashboard.deliveries.window_days (30) is a
ceiling rather than merely a default: a reader may narrow the window and can never widen past
it, exactly as perPage is clamped and for the same reason — a public Livewire property is
writable from the browser, so a value the reader picks is a cost the reader picks.
webhook_deliveries is range-partitioned by month — the decision that makes retention a
DROP PARTITION rather than a DELETE. A read with no lower bound on created_at cannot be
pruned, so it visits every partition there is, on every render of a screen that stays open all
day. Nothing goes red about that: the page loads, it just loads with the whole history in the
plan, and the cost arrives with the data rather than with the change. Set window_days to 0 if
you would rather pay the scan than ever hide a row.
Authorization is fail-closed — you must grant access
The view-webhook-dashboard gate denies until your app defines a webhooks.view ability,
so registering the dashboard never silently exposes the operator surface to every
authenticated user. Grant it:
Gate::define('webhooks.view', fn ($user) => $user->isAdmin());
Tenant resolution
The per-tenant dashboard scopes to the owner returned by Pushery\Webhooks\Dashboard\DashboardScope,
whose default rule is the one the self-service portal uses — the
authenticated user, preferring a Jetstream currentTeam. It is the same rule, not the same
resolver: DashboardScope holds its own closure and re-implements that default rather than
borrowing it, so the two agree right up to the moment you override one of them.
For a custom Workspace/Account tenant model, override both, side by side:
use Pushery\Webhooks\Dashboard\DashboardScope;
use Pushery\Webhooks\Platform\Support\SubscriptionScope;
DashboardScope::resolveUsing(fn () => auth()->user()?->currentWorkspace);
SubscriptionScope::resolveUsing(fn () => auth()->user()?->currentWorkspace);
Overriding one alone is quiet, not loud. The surface you missed keeps scoping to the User,
whose (owner_type, owner_id) is on none of your rows, so it renders empty states — no error,
no log line, and the only hint is the other surface showing the rows this one says are absent.
Testing panels you have embedded
Every dashboard panel is #[Lazy], so a test renders its placeholder unless you say
otherwise — and Livewire::withoutLazyLoading() covers one component. Testing two panels
in a single test therefore needs it twice:
Livewire::withoutLazyLoading();
$table = Livewire::test(DeliveriesTable::class)->assertOk()->html();
Livewire::withoutLazyLoading(); // again — the first call covered the first component only
$queue = Livewire::test(RecentQueue::class)->assertOk()->html();
This is worth spelling out because of how it fails. The placeholder renders successfully:
assertOk() passes, the HTML is thousands of characters long, and the only symptom is that an
assertion about content finds none — so it reads as this panel does not work with my fixture
rather than this panel was never rendered. It is also positional, not specific to any panel:
swap the two calls above and the other one comes back empty.
If a panel still renders nothing after that, check the resolver — a panel scopes to
DashboardScope, and with a host tenant model that is not the authenticated user, seeded rows
whose owner_type/owner_id do not match what the resolver returns are correctly invisible.
The read model is an hourly materialized view refreshed by
php artisan webhooks:refresh-metrics (scheduled at dashboard.metrics.refresh). The page
mounts at dashboard.prefix behind dashboard.middleware; the Blade views are publishable
(--tag=webhooks-dashboard-views) for hosts on another UI kit — publishing and restyling them
is the supported escape hatch from WireKit.
For very high volume, dashboard.percentiles.driver = 'tdigest' reads percentiles from
per-bucket digests (requires the PostgreSQL tdigest extension — it is not available on
MySQL, nor on Neon, which is what Laravel Cloud's Postgres is). The default live driver
needs no extension, is the same complexity class on both engines, and returns identical
numbers — so this tier is an optimization for extreme volume, not a correctness or an
engine-choice matter.
Operator mode — observing your global endpoints
The dashboard is tenant-scoped by default: it reads the endpoints and deliveries owned by the
acting tenant. If instead you run a handful of global endpoints (subscriptions registered
with a null owner, which receive every event), set dashboard.operator = true
(WEBHOOKS_DASHBOARD_OPERATOR=true).
The dashboard then reads exactly those owner-less rows — no tenant is resolved, so the
resolver is not needed. It never shows one tenant's private rows to another: operator mode is
global rows only, not all rows. Because it shows those rows to everyone the
view-webhook-dashboard gate admits, gate that ability to your operators.
Cross-tenant mode — the support console
Operator mode answers "how are my global endpoints doing?". A support console asks a
different question — "what did we send to this customer's endpoint?" — and needs every
row, owner-less and tenant-owned alike. That is dashboard.all_tenants = true
(WEBHOOKS_DASHBOARD_ALL_TENANTS=true).
It is deliberately not part of dashboard.operator. Seeing your own global endpoints and
seeing every tenant's private delivery history are different permission levels, and folding
them together would silently widen every existing operator dashboard the moment it upgraded.
When both flags are on, cross-tenant wins — it is the strictly wider scope, so turning it on
never requires remembering to turn the other one off.
Because it crosses a tenant boundary it carries a second gate on top of the route's
view-webhook-dashboard middleware: the ability named by dashboard.all_tenants_ability
(default view-all-tenant-webhooks). Define it in your app and the acting user must pass it:
Gate::define('view-all-tenant-webhooks', fn (User $user): bool => $user->isSupportAgent());
This ability is required, unlike operator mode's. Turn the flag on without defining it and
the dashboard refuses to load, naming the ability in the error. That is deliberate: operator
mode can leave it undefined safely because it shows only the owner-less rows you registered,
while this scope shows every customer's history — and it sits behind
view-webhook-dashboard, which a per-tenant dashboard necessarily grants broadly, since every
customer needs it for their own deliveries. Left open, flipping one flag would hand every
customer every other customer's data, silently.
If you genuinely want no second gate, say so explicitly:
Gate::define('view-all-tenant-webhooks', fn (): bool => true);
That is greppable and reviewable; an absence is neither.
The delivery body is its own permission level
Opening the detail drawer shows a delivery's stored request body. In a real integration that body is the business record — the order, the customer, the shipping address — and the drawer puts a copy button next to it.
So the body does not inherit the dashboard's ability. Reading that a delivery failed and
reading whose order it was are different permissions, and view-webhook-dashboard is one a
per-tenant dashboard necessarily grants broadly, since every customer needs it for their own
deliveries. Letting one ability answer both questions is not a coarse permission level; it is
the absence of one.
The body is governed by dashboard.payload.ability (default view-webhook-payload), and it
fails closed: values are shown only when that ability is defined and the acting user
passes it.
Gate::define('view-webhook-payload', fn (User $user): bool => $user->isSupportAgent());
This default changed. Before, every user who could open the dashboard could read every body. If that is what you want, say so explicitly — it takes one line:
Gate::define('view-webhook-payload', fn (): bool => true);
What a denied read shows
Not an empty space. By default the drawer shows the body's structure with every value replaced by its type:
{
"order_id": "[string]",
"total": "[int]",
"customer": { "email": "[string]" },
"note": null
}
That is the useful half for debugging — when something is malformed, the shape is almost
always what you need, not the data. null stays null rather than becoming a marker, because
"the field was sent but empty" and "the field was never sent" is a distinction an operator
needs and redaction should not destroy.
Set dashboard.payload.denied to hidden to show nothing but the explanation instead. Either
way the drawer says why the values are missing: a panel that simply stops after its heading
reads as a bug, and the next person to look at it "fixes" it by deleting the guard.
Any other value for denied is read as hidden. A typo in a security setting must not be the
permissive reading.
A body that was offloaded says so
There is a third state, and it has nothing to do with permissions. Past
server.large_payload.threshold the delivery log keeps only a stub — the event type, or
nothing at all — and moves the body to a storage disk. Without a word about it the drawer would
render that stub as if it were the delivery, so the largest deliveries would look like the
smallest ones in the log.
The drawer therefore names the disk the body went to, above the body itself. The notice appears
even when the values are withheld, and that combination is the reason it exists: a redacted stub
renders as {"type":"[string]"}, which is indistinguishable from a delivery that really did
carry almost nothing. Two plausible readings of the same picture, both wrong.
The disk name is shown regardless of the payload ability. It is storage configuration rather than customer data, and withholding it would restore exactly the ambiguity the line removes.
The drawer does not fetch the body back from the disk. Doing so on every open would undo the
reason offloading was switched on, and it would add a second read path over the same data that
the payload ability would then have to cover as well. The model's rehydratedPayload() reads the
stored body back on demand — it is what a redelivery uses to re-send the exact event — if your
own code needs it.
It is a boundary, not a curtain
The drawer holds the delivery and the rendered body as Livewire computed properties, never public ones. Computed properties are not serialized into Livewire's snapshot, so a body you may not see is never sent to the browser at all — hiding it is not a matter of the template choosing not to print it. If you publish and modify this view, keep that shape.
When the ability is defined and the user fails it, the dashboard denies the request — it does not quietly fall back to a narrower scope. A support screen that renders successfully while showing a fraction of its rows reports no error and fails no test, which is precisely the failure this mode exists to prevent.
The display timezone
Every timestamp on the dashboard is rendered in the application's timezone and says so —
the absolute format ends in z, so a reader can always see which clock they are looking at.
That is enough for a single-tenant application. It is not enough for a back-office:
app.timezone is one process-wide setting, and in a multi-tenant app it is UTC — the
right choice for storage and the wrong one for display. The operator reading the delivery log
sits in Europe/Berlin, the next tenant sits somewhere else, and there is no single value the
application could set that is correct for both. Labeling the offset only tells them to do the
arithmetic themselves, on the one surface where they are comparing timestamps against their
own records.
dashboard.timezone is the seam. Unset, nothing changes:
// config/webhooks.php — one operator, or one tenant
'dashboard' => [
'timezone' => 'Europe/Berlin',
],
When the answer depends on who is reading, point it at a class instead. It is resolved per render, the same "the host decides, the package asks" shape as the payload seam above:
use Pushery\Webhooks\Dashboard\DashboardTimezoneResolver;
final class TenantDisplayZone implements DashboardTimezoneResolver
{
public function timezone(): ?string
{
return auth()->user()?->timezone ?? tenant()->timezone;
}
}
'dashboard' => ['timezone' => TenantDisplayZone::class],
Returning null is a real answer, not a failure: a reader with no preference sees the
application zone, which is what an unconfigured dashboard shows everyone.
It reaches every timestamp surface — the delivery table, the detail drawer and the hourly activity axis. Two columns of one screen showing two different clocks would be worse than one clock that is not yours, because nothing on the page would say it is happening.
A typo in a display setting must not take a dashboard down — the same rule the theme
setting follows. The fallback is self-revealing here in a way most fallbacks are not: the
absolute format ends in z, so you see the application's zone named beside the value instead
of a plausible wrong number. A class that does not implement DashboardTimezoneResolver
is a different matter and throws: a bad zone string is data, and data gets mistyped; a class
that does not implement the contract is wiring, and wiring is wrong.
JSON metrics endpoint (opt-in)
Set dashboard.expose_json_api to true and the same read model is served as JSON, so you
can drive your own charts, a status page or an alerting rule from the dashboard's numbers.
While the flag is false (the default) the route is not registered at all.
GET /webhooks/api/metrics?window=24h # route name: webhooks.dashboard.metrics
It mounts at dashboard.api_path (default api/metrics) under dashboard.prefix, behind the
same dashboard.middleware and the same view-webhook-dashboard gate as the page, and every
read is scoped to the acting tenant — nobody ever reads another tenant's numbers.
| Query parameter | Values | Default |
|---|---|---|
window | any token from dashboard.windows | the first one (24h) |
A window the host does not offer is rejected with 422 — it never falls back silently. The
response carries aggregates only: no delivery rows, payloads, headers or signing material
are exposed. Latencies are milliseconds; retry_rate is a percentage.
{
"window": "24h",
"generated_at": "2026-01-31T09:15:00+00:00",
"kpis": {
"total": 5,
"delivered": 3,
"pending": 1,
"failed": 1,
"retried": 1,
"retry_rate": 20.0,
"p50_ms": 20.0,
"p90_ms": 20.0,
"p95_ms": 20.0,
"p99_ms": 20.0
},
"hourly": [
{
"bucket": "2026-01-31T09:00:00+00:00",
"total": 5,
"delivered": 3,
"pending": 1,
"failed": 1,
"retried": 1,
"p50_ms": 20.0,
"p95_ms": 20.0
}
],
"top_events": [{ "event_type": "invoice.paid", "total": 5 }]
}
A Laravel Pulse card for your own engineers
A separate, single-view Laravel Pulse card is available under pulse.enabled — throughput,
failure rate and latency of outbound deliveries by event type.
It takes the same three steps as the dashboard above, and the middle one is the one that is easy
to miss: laravel/pulse is a suggestion rather than a dependency, and the card's provider is not
auto-registered either.
# 1. Pulse itself is not a dependency of this package.
composer require laravel/pulse
// 2. Register the provider — it is NOT auto-registered.
// bootstrap/providers.php
Pushery\Webhooks\Pulse\WebhookPulseServiceProvider::class,
// 3. config/webhooks.php — switch it on.
'pulse' => ['enabled' => true, /* … */],
Then mount the card on your Pulse dashboard:
<livewire:webhooks.pulse.deliveries />
With the flag on and the provider missing, nothing registers the card and nothing says so: Pulse renders the dashboard it was given, minus a card nobody asked it for.