4ed831d9c3
New diagnostic_events table + per-account users.debug_mode_enabled flag.
When an account's flag is on, its client(s) POST a batch timeseries of
connectivity / UPnP-sync / power / lifecycle events to /api/diagnostics
(no-op 204 when off, kind whitelist mirrors the CHECK constraint).
Admin surface: GET /api/admin/diagnostics (optional account/device/kind/
time-window filters, RFC3339-or-epoch-ms, export-sized paging) + a
/diagnostics/devices overview + PUT /api/admin/users/{id}/debug-mode to
flip an account remotely while a bug is live. debug_mode_enabled is now
exposed on /api/me (client gate) and the admin user views.
Retention: a 30-day gc-worker sweep (GcPruneDiagnostics), keyed on the
server clock so a skewed device clock can't keep rows alive.
Refs Scribe M9 (#119), tasks #1172 #1173.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
67 lines
3.0 KiB
SQL
67 lines
3.0 KiB
SQL
-- name: InsertDiagnosticEvent :one
|
|
-- Records one client-reported diagnostic event. The writer validates the
|
|
-- `kind` category against its whitelist before this runs; the CHECK
|
|
-- constraint is the belt-and-braces guard. payload carries the finer
|
|
-- event discriminator + all event-specific fields. Returns received_at
|
|
-- so the caller can confirm ingest.
|
|
INSERT INTO diagnostic_events (
|
|
user_id, client_id, app_version, os_version, kind, payload, occurred_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING id, received_at;
|
|
|
|
-- name: ListAdminDiagnostics :many
|
|
-- Admin timeline query. All filters are optional (NULL = no filter):
|
|
-- account (user_id), device (client_id), category (kind), and the
|
|
-- occurred_at window (from_ts/to_ts). Newest-first; the SPA reverses for
|
|
-- a chronological timeline / export. Joined with users for the username
|
|
-- so the view renders without a second round-trip.
|
|
SELECT
|
|
de.id AS id,
|
|
de.user_id AS user_id,
|
|
u.username AS username,
|
|
de.client_id AS client_id,
|
|
de.app_version AS app_version,
|
|
de.os_version AS os_version,
|
|
de.kind AS kind,
|
|
de.payload AS payload,
|
|
de.occurred_at AS occurred_at,
|
|
de.received_at AS received_at
|
|
FROM diagnostic_events de
|
|
JOIN users u ON u.id = de.user_id
|
|
WHERE (sqlc.narg(user_id)::uuid IS NULL OR de.user_id = sqlc.narg(user_id)::uuid)
|
|
AND (sqlc.narg(client_id)::text IS NULL OR de.client_id = sqlc.narg(client_id)::text)
|
|
AND (sqlc.narg(kind)::text IS NULL OR de.kind = sqlc.narg(kind)::text)
|
|
AND (sqlc.narg(from_ts)::timestamptz IS NULL
|
|
OR de.occurred_at >= sqlc.narg(from_ts)::timestamptz)
|
|
AND (sqlc.narg(to_ts)::timestamptz IS NULL
|
|
OR de.occurred_at <= sqlc.narg(to_ts)::timestamptz)
|
|
ORDER BY de.occurred_at DESC
|
|
LIMIT sqlc.arg(lim)::int OFFSET sqlc.arg(off)::int;
|
|
|
|
-- name: ListDiagnosticDevices :many
|
|
-- Distinct devices that have reported, for the admin device filter +
|
|
-- "who is currently reporting" overview. Optionally scoped to one account.
|
|
SELECT
|
|
de.client_id AS client_id,
|
|
de.user_id AS user_id,
|
|
u.username AS username,
|
|
max(de.app_version)::text AS app_version,
|
|
max(de.os_version)::text AS os_version,
|
|
max(de.occurred_at)::timestamptz AS last_seen,
|
|
count(*) AS event_count
|
|
FROM diagnostic_events de
|
|
JOIN users u ON u.id = de.user_id
|
|
WHERE (sqlc.narg(user_id)::uuid IS NULL OR de.user_id = sqlc.narg(user_id)::uuid)
|
|
GROUP BY de.client_id, de.user_id, u.username
|
|
ORDER BY last_seen DESC;
|
|
|
|
-- name: GcPruneDiagnostics :execrows
|
|
-- Retention sweep for the gc worker. Deletes diagnostic_events older
|
|
-- than 30 days by server clock (received_at) so a skewed client clock
|
|
-- can't keep rows alive. Matches the other gc lifecycle sweeps'
|
|
-- hardcoded-threshold style; diagnostics are debug telemetry, not user
|
|
-- data, and 30 days is ample to investigate a live incident after the
|
|
-- fact. Returns the affected row count for the sweep log line.
|
|
DELETE FROM diagnostic_events WHERE received_at < now() - INTERVAL '30 days';
|