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
222 lines
5.9 KiB
Go
222 lines
5.9 KiB
Go
// 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
|
|
}
|