Studio
Monitoring, forensics, and operator actions over the rows better-push already writes.
Every question an operator asks is already a row in your database.
bp_notification records what was created, bp_delivery records every attempt
with its status, error code, and timings, bp_device records the fleet and why
tokens died, bp_preference records opt-outs, and bp_job records queue depth,
retries, and dead letters.
The studio is the console over those rows. It is not an instrumentation problem - it is a query problem, which is why better-push can answer it without a metrics service, an agent, or a copy of your data anywhere else.
What it is for
The differentiated value is forensics and queue health - the two things an external APM cannot do, because it does not have these rows:
- "User 8f2a says the order-shipped push never arrived." Search the user, see
their devices, see that one was disabled three days ago with
expired_token, see the delivery row that says so. - "What is stuck, and why?" Queue depth, the age of the oldest pending job, dead-lettered jobs with their last error, and which worker holds which claim.
It is deliberately not a rebuild of Grafana. There are enough charts for context and no more; aggregate dashboards over long time ranges are what APM tools already do well.
Two ways to run it
| CLI studio | Mounted studio | |
|---|---|---|
| Command | npx @better-push/cli studio | studioHandler(push, { … }) |
| Runs on | your machine, localhost only | your app, wherever you mount it |
| Auth | none, by design | your own admin session |
| Content | always visible | requires the content permission |
| Writes | --allow-writes | allowWrites and the write permission |
Both serve the same prebuilt SPA and the same JSON API, so what you learn in one applies to the other.
Screens
- Overview - volume, success rate, failures by error code, delivery lag, queue depth, live workers, device fleet, latency percentiles, read rate. Selecting an error code opens it in the deliveries explorer.
- Deliveries - every attempt, with a detail pane beside the list showing the notification and its sibling deliveries.
- Queue - pending and dead jobs with retry and purge, and each job's payload
and last error. It reads through the configured backend's own inspector, so it
is identical on
dbQueue()andbullmq()and still works with no queue configured at all, where it shows thebp_jobtable. - Users - devices (including disabled ones), stored preference overrides, and recent notifications. This is the screen that answers a support ticket.
- Send test - go through the real
notify()path. - Audit - every privileged action, including each content reveal.
Three things apply across all of them:
- A global time window in the header (6 hours to 90 days) that scopes the overview and the deliveries explorer.
- A command palette on ⌘K. Paste a notification, delivery, device or user id and it resolves to whatever it is; it also jumps between screens, as does g followed by a letter.
- Addressable views. The screen, the window, and every list filter live in
the URL, so
…/deliveries?error=BadDeviceToken&range=7dis a link you can hand to whoever asked.
What it is built on
The UI talks to a StatsSource interface and nothing else:
import { databaseStats } from "@better-push/core/stats";
const stats = databaseStats(drizzleAdapter(db));
await stats.overview({ from: "2026-07-01T00:00:00Z" });Every method is time-bounded and paginated with a hard server-side cap, so no query the studio can be talked into issuing scans more of your history than the cap allows. Charts read rollups; forensic lists read raw rows through indexes added for exactly that purpose.
The UI reads only through this bounded interface; it does not query tables directly.
What it does not do
- No HTTP request logging. A feed polling every 30 seconds would write more
rows than the notification data itself. Use the
onEventhook to forward lifecycle events to OpenTelemetry or your APM instead. - No realtime. Screens poll and show "updated Ns ago".
- No alerting, and no cross-project views.