feat(server/diagnostics): device debug-reporting ingest + admin timeline + retention (M9)
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
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: diagnostics.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const gcPruneDiagnostics = `-- name: GcPruneDiagnostics :execrows
|
||||
DELETE FROM diagnostic_events WHERE received_at < now() - INTERVAL '30 days'
|
||||
`
|
||||
|
||||
// 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.
|
||||
func (q *Queries) GcPruneDiagnostics(ctx context.Context) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, gcPruneDiagnostics)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const insertDiagnosticEvent = `-- name: InsertDiagnosticEvent :one
|
||||
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
|
||||
`
|
||||
|
||||
type InsertDiagnosticEventParams struct {
|
||||
UserID pgtype.UUID
|
||||
ClientID string
|
||||
AppVersion *string
|
||||
OsVersion *string
|
||||
Kind string
|
||||
Payload []byte
|
||||
OccurredAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type InsertDiagnosticEventRow struct {
|
||||
ID pgtype.UUID
|
||||
ReceivedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (q *Queries) InsertDiagnosticEvent(ctx context.Context, arg InsertDiagnosticEventParams) (InsertDiagnosticEventRow, error) {
|
||||
row := q.db.QueryRow(ctx, insertDiagnosticEvent,
|
||||
arg.UserID,
|
||||
arg.ClientID,
|
||||
arg.AppVersion,
|
||||
arg.OsVersion,
|
||||
arg.Kind,
|
||||
arg.Payload,
|
||||
arg.OccurredAt,
|
||||
)
|
||||
var i InsertDiagnosticEventRow
|
||||
err := row.Scan(&i.ID, &i.ReceivedAt)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listAdminDiagnostics = `-- name: ListAdminDiagnostics :many
|
||||
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 ($1::uuid IS NULL OR de.user_id = $1::uuid)
|
||||
AND ($2::text IS NULL OR de.client_id = $2::text)
|
||||
AND ($3::text IS NULL OR de.kind = $3::text)
|
||||
AND ($4::timestamptz IS NULL
|
||||
OR de.occurred_at >= $4::timestamptz)
|
||||
AND ($5::timestamptz IS NULL
|
||||
OR de.occurred_at <= $5::timestamptz)
|
||||
ORDER BY de.occurred_at DESC
|
||||
LIMIT $7::int OFFSET $6::int
|
||||
`
|
||||
|
||||
type ListAdminDiagnosticsParams struct {
|
||||
UserID pgtype.UUID
|
||||
ClientID *string
|
||||
Kind *string
|
||||
FromTs pgtype.Timestamptz
|
||||
ToTs pgtype.Timestamptz
|
||||
Off int32
|
||||
Lim int32
|
||||
}
|
||||
|
||||
type ListAdminDiagnosticsRow struct {
|
||||
ID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
Username string
|
||||
ClientID string
|
||||
AppVersion *string
|
||||
OsVersion *string
|
||||
Kind string
|
||||
Payload []byte
|
||||
OccurredAt pgtype.Timestamptz
|
||||
ReceivedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (q *Queries) ListAdminDiagnostics(ctx context.Context, arg ListAdminDiagnosticsParams) ([]ListAdminDiagnosticsRow, error) {
|
||||
rows, err := q.db.Query(ctx, listAdminDiagnostics,
|
||||
arg.UserID,
|
||||
arg.ClientID,
|
||||
arg.Kind,
|
||||
arg.FromTs,
|
||||
arg.ToTs,
|
||||
arg.Off,
|
||||
arg.Lim,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListAdminDiagnosticsRow
|
||||
for rows.Next() {
|
||||
var i ListAdminDiagnosticsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Username,
|
||||
&i.ClientID,
|
||||
&i.AppVersion,
|
||||
&i.OsVersion,
|
||||
&i.Kind,
|
||||
&i.Payload,
|
||||
&i.OccurredAt,
|
||||
&i.ReceivedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listDiagnosticDevices = `-- name: ListDiagnosticDevices :many
|
||||
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 ($1::uuid IS NULL OR de.user_id = $1::uuid)
|
||||
GROUP BY de.client_id, de.user_id, u.username
|
||||
ORDER BY last_seen DESC
|
||||
`
|
||||
|
||||
type ListDiagnosticDevicesRow struct {
|
||||
ClientID string
|
||||
UserID pgtype.UUID
|
||||
Username string
|
||||
AppVersion string
|
||||
OsVersion string
|
||||
LastSeen pgtype.Timestamptz
|
||||
EventCount int64
|
||||
}
|
||||
|
||||
// Distinct devices that have reported, for the admin device filter +
|
||||
// "who is currently reporting" overview. Optionally scoped to one account.
|
||||
func (q *Queries) ListDiagnosticDevices(ctx context.Context, userID pgtype.UUID) ([]ListDiagnosticDevicesRow, error) {
|
||||
rows, err := q.db.Query(ctx, listDiagnosticDevices, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListDiagnosticDevicesRow
|
||||
for rows.Next() {
|
||||
var i ListDiagnosticDevicesRow
|
||||
if err := rows.Scan(
|
||||
&i.ClientID,
|
||||
&i.UserID,
|
||||
&i.Username,
|
||||
&i.AppVersion,
|
||||
&i.OsVersion,
|
||||
&i.LastSeen,
|
||||
&i.EventCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
Reference in New Issue
Block a user