Github|...

DevTools extension

The Chrome DevTools panel for Sp00ky apps: live queries and their timings, the mutation queue and failed writes, the client logs, the local cache, storage health, sessions, feature flags, impersonation and stack versions.

Sp00ky ships a Chrome DevTools extension. It adds a panel called 00 next to Elements and Network, and it inspects the live client in the page you have open: the queries it is running, the writes still on their way to the server, what the client logged, what is in the local cache, who the session belongs to, and what the backend it is talking to is running.

It works against a deployed app exactly as it does against localhost. Nothing is compiled out of a production build, so the panel that explains a slow query on your machine explains the same query on the deployment your users are on.

The Queries tab: every live query the page has registered.
The Queries tab: every live query the page has registered.

Installing it

The extension is published to the Chrome Web Store on every release. Search for Sp00ky DevTools and add it. To run the version in this repository instead:

pnpm --filter @spooky-sync/devtools build

Then open chrome://extensions, turn on Developer mode, choose Load unpacked, and select apps/devtools/dist.

Either way, open DevTools on a page running a Sp00ky app and pick the 00 tab.

Nothing to wire up, in dev or in production

There is no setup step, no config flag and no dev-only build. Every Sp00kyClient exposes window.__00__ when it is constructed, and the panel reads the client through it. Open DevTools on your staging URL, on your production URL, on a colleague’s deployment, and the panel is there.

That costs an uninspected app nothing. The push channel stays dormant until a panel (or the MCP bridge) handshakes with the page, so a production client nobody is looking at does no serialization work for it. Opening the panel is what turns the channel on, and closing it turns the channel back off.

Note

Inspecting production is not a privilege escalation. Every read goes through the client, which goes through SurrealDB as the signed-in user, so the panel sees exactly the rows that session is allowed to see and nothing more. The one exception is deliberate and admin-gated: impersonation, which the database authorizes and audits.

The toolbar

Every tab shares one toolbar.

  • The status dot on the left reports whether a Sp00ky client is reachable. A tab can run more than one (the main document plus any iframe that embeds the app) and the panel inspects exactly one at a time, so the dot grows into a picker as soon as there is a second client to choose between.
  • The heartbeat badge on the right is the scheduler’s end-to-end sync latency: one probe row written upstream, timed through ingest, broadcast and the SSP circuit. It is absent (not zero) when the probe is off or the scheduler is unreachable, and clicking it opens the Stack tab where the history lives.
  • Refresh re-reads what the tab you are on actually shows: the table list on Database, the flags on Access, version discovery on Stack. Shift-click refreshes everything.
  • Clear empties the log on Logs and the mutation history on Mutations. The other tabs show live state, so it is not there.

Queries

The queries the client has registered, newest first, with their status, how many updates each has received, how much data it is holding and when it last changed. The strip along the top is a timeline of registrations and updates, so a query that is updating far more often than the rest is visible without reading a single number.

Click a query to open its detail panel.

One query's detail panel: the SurrealQL it registered and the variables it was given.
One query's detail panel: the SurrealQL it registered and the variables it was given.

The detail panel has four sections, and hides the ones a query has nothing for:

SectionShows
OverviewHash, status, TTL, creation and last-update times, update count, payload size.
QueryThe registered SurrealQL and its variables.
DataThe rows themselves, plus the local and remote membership arrays.
TimingThe full per-phase breakdown for this one query.

Rows are fetched only when you open the Data section. The state the page pushes carries counts and capped ids, never the full result set of every live query.

Mutations

Every write the client made, from the moment it is queued until the server answers. A write is applied locally at once and drained to the server in the background; this tab is where you watch the second half.

Every write this session: queued, retrying, synced or rolled back, and how long the server took.
Every write this session: queued, retrying, synced or rolled back, and how long the server took.

The chips along the top switch the view:

ViewShows
AllThe outbox plus every write that left it since the panel attached, newest first.
Queuedpending (not answered yet) and retrying (a push failed and it is backing off).
SyncedAccepted by the server, with how long that took.
Rolled backRejected by the server. The local change was undone and the write moved to the failed tray.
Failed trayEvery rejected write still in the tray, from any session, with Retry and Discard.
Debounced{ debounced: true } updates still waiting for their flush, and whether they survive a reload.

Click a write for its detail: the mutation id, timing, push attempts, the server’s error for a rejected one, and the payload with the row it reverts to. A queued write’s payload is read from the outbox only when you open it; a synced one’s is gone, so look at the record itself on the Database tab.

A rejected write in the failed tray: the server's error, the revert, Retry and Discard.
A rejected write in the failed tray: the server's error, the revert, Retry and Discard.

The status bar says first whether anything can drain at all: a follower tab never pushes (the leader tab does it for every tab), and a disconnected or degraded socket holds the queue until it recovers.

Retry re-applies a rejected write as a new optimistic write and drops it from the tray; Discard drops it and leaves the local change undone. These are the same retryFailedMutation and discardFailedMutation the client exposes to your app.

Logs

What the client’s own logger wrote, oldest first. Each line shows its time, level, category and message; click it for the structured fields it was logged with (an error keeps its message and stack).

The client's own log lines, recorded at debug while the console stays at info.
The client's own log lines, recorded at debug while the console stays at info.

The page keeps its last 500 lines whether or not the panel is open, so opening DevTools after a failed boot still shows the boot. Filter by level with the chips, by category with the dropdown, or by any text in the line.

At debug the log is also the client’s trace: every statement each store ran (the SurrealQL is the message; the category says local or remote, and the fields hold its variables, how long it took and whether it failed), each query’s status and membership changes, and the per-phase timings of every ingest (StreamUpdate). A view the server lost is logged at info.

Record sets what the page captures. The console keeps printing at the logLevel from your client config; lowering Record to debug captures the debug lines for the panel without flooding the console. The choice holds for the tab’s session, so a reload records its boot at that level too.

Timing

Every live query against every phase of the pipeline, slowest first. Each column is that phase’s p90, and the Total bar is the sum, so the offending query and the phase it is stuck in are one glance apart.

Per-phase p90 for every query. Click a column to sort by it.
Per-phase p90 for every query. Click a column to sort by it.

SSP is the server-side view step, Local the local cache read, Remote the round trip to the database when a query still needs one, and Frontend the reconcile into your components. upd and err are the update and error counts. Clicking a #hash jumps to that query in the Queries tab with its detail panel open.

Database

A table browser over the client’s own data, backed either by the local cache or by the remote database. The dropdown switches between them and the rest of the tab follows.

The Database tab reading the local cache, with the internal _00_ tables hidden.
The Database tab reading the local cache, with the internal _00_ tables hidden.

Rows are paginated, filterable, and editable in place; the row inspector shows one record as JSON, ready to copy. Internal _00_* sync tables are hidden behind the eye toggle, which remembers what you chose. The footer under the table list reports whether the local store is really persistent and, when tabs are shared, which role this one has.

Note

The local store is SQLite when the SQLite engine is enabled, and it speaks only a subset of SurrealQL. A statement it cannot translate fails with an explanation and a pointer to run the same query against Remote instead.

Storage

What the local cache actually is, and whether it is as durable as it claims.

Engine, shared-tab ownership and origin storage usage.
Engine, shared-tab ownership and origin storage usage.

The top of the tab answers the questions that are invisible from the outside: which engine and store are running, whether OPFS was requested and granted, whether workerSelect is really in effect or was downgraded at runtime, and, when sharedTabs is on, whether this tab owns the store or follows another one, with the leadership term and the number of batches it has relayed.

A store that asked to be persistent and silently fell back to memory is called out here rather than discovered after a reload loses writes.

The bucket file cache and the OPFS files behind it.
The bucket file cache and the OPFS files behind it.

Below that: the bucket file cache with its budget, hit and eviction counters; every OPFS file with its size; SQLite worker statistics (round trips, queue wait, parse time, batch sizes); and a per-table row count.

Access

Identity and what that identity is allowed to see, in one place.

The session, and the user picker for starting an impersonation.
The session, and the user picker for starting an impersonation.

Session is the signed-in user, their roles and the record id, the same id that spky admin add takes.

Impersonate starts and stops an admin impersonation from the panel. The page switches to the target’s session and sees exactly what they can. Admins cannot be impersonated, a reason is required and kept in the audit log, and sessions are time-limited. The section explains itself when the feature is off for the project or the signed-in user is not an admin. See admin impersonation.

Feature flags, with a browser-local override forcing one variant.
Feature flags, with a browser-local override forcing one variant.

Flags lists every flag the client has seen, with its assigned variant, its default, its rules, and whether you are allowlisted. Two kinds of control live on each card, and they are deliberately not the same thing:

  • Override forces a variant in this browser only. Nothing is sent to the server, it survives reloads, and clearing it restores whatever the server says.
  • Everything else, enabling a flag or allowlisting yourself or another user, applies to every user and takes effect live.

Creating flags, deleting them and percentage rollouts stay with spky flag.

Stack

Which versions are actually running, on both sides of the connection.

Frontend versus backend versions, the live entities, and the end-to-end heartbeat.
Frontend versus backend versions, the live entities, and the end-to-end heartbeat.

Frontend versions are baked into your bundle at build time; backend versions and the entity list come from fn::spooky::info() over the live connection. Rows that describe the same artifact on both sides are compared, so a client running a different build from its SSPs reads as drift instead of as two numbers you have to diff yourself.

Underneath, every entity in the deployment (each SSP, the scheduler, your backends) with its status, version, view count, uptime and address, plus the scheduler’s end-to-end latency history. A gap in that chart means not probed, never 0ms.

MCP

The panel can hand the same state to an AI assistant.

The MCP bridge, connected to a local MCP server.
The MCP bridge, connected to a local MCP server.

With the bridge enabled, @spooky-sync/devtools-mcp connects over a local WebSocket and exposes the tab’s state as MCP tools: get_state, run_query, list_tables, get_table_data, get_active_queries, get_mutations, get_logs, get_auth_state, update_table_row, delete_table_row and list_connections. The assistant then sees the exact state your tab has, the same local cache, the same live queries, the same auth, rather than a fresh connection to the database.

claude mcp add sp00ky-devtools npx @spooky-sync/devtools-mcp

The toggle is off by default; nothing listens until you turn it on.

What it changes

Most of the panel only reads. The exceptions are explicit, and worth knowing before you use them against something that matters:

ActionScope
Editing or deleting a row on DatabaseWrites to the selected source, local or remote.
Failed tray Retry / DiscardRe-queues the write, or drops it for good.
Logs Record levelWhat this tab captures for the session. The console is unchanged.
Flag OverrideThis browser only. Never sent to the server.
Flag enable / allowlistEvery user, live.
Start / stop impersonationThe page’s session, audited and time-limited.
MCP bridgeOff until enabled; grants an assistant the tools listed above.

Regenerating these screenshots

They are captured from the real panel driven by a fixed fixture, not by hand:

pnpm --filter @spooky-sync/devtools screenshots

See apps/devtools/screenshots/README.md for what to update when a tab is added or a state shape changes.