Skip to main content

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.

Why a default window at all

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());
Upgrading from 1.7.x

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 zone the runtime does not know falls back rather than throwing

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 parameterValuesDefault
windowany token from dashboard.windowsthe 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.