Files
minstrel/internal/db/dbq/diagnostics.sql.go
T
bvandeusen 4ed831d9c3
test-go / test (push) Successful in 37s
test-go / integration (push) Successful in 4m44s
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
2026-06-29 18:38:56 -04:00

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
}