admin-timeseries.ts186 lines · main
1import { sql } from 'drizzle-orm';
2import { Hono } from 'hono';
3
4import { getDb } from '../db/client.js';
5import { env } from '../env.js';
6import { requireAdmin } from '../middleware/admin.js';
7import { requireAuth } from '../middleware/session.js';
8import { PROM_QUERIES } from '../services/platform-health.js';
9import type { AppEnv } from '../types/app-env.js';
10
11/**
12 * Admin activity timeseries — GET /v1/admin/timeseries. Feeds the cockpit
13 * overview's charts with REAL platform history, never fabricated:
14 *
15 * - signupsDaily / deploysDaily / invocationsDaily — 30 zero-filled daily
16 * buckets straight from the control DB (users.created_at,
17 * deploy_history.booted_at, function_logs.created_at).
18 * - apiRequests / hostCpu — last 24h at 5m resolution from Prometheus's
19 * range API. HARD honesty rule (same as services/platform-health.ts):
20 * when BRIVEN_PROMETHEUS_URL is unset, unreachable, or a query comes
21 * back empty, the field is null and the UI says "monitoring not
22 * connected" — a dead exporter never masquerades as a flat line.
23 *
24 * Guard chain mirrors routes/admin-agents.ts EXACTLY (session → admin
25 * bit). Read-only, so no step-up gate — same as the GET paths there.
26 */
27
28export const adminTimeseriesRouter = new Hono<AppEnv>();
29
30adminTimeseriesRouter.use('/v1/admin/timeseries', requireAuth());
31adminTimeseriesRouter.use('/v1/admin/timeseries', requireAdmin());
32
33/* ─── daily counts from the control DB ───────────────────────────────── */
34
35const DAYS = 30;
36
37export interface DailyPoint {
38 /** Calendar day, 'YYYY-MM-DD'. */
39 readonly day: string;
40 readonly count: number;
41}
42
43/**
44 * Count rows per calendar day over the last 30 days, zero-filled via
45 * generate_series so the chart always has 30 stable points. Table/column
46 * names come from a closed literal union (never user input) so sql.raw
47 * is injection-safe here.
48 */
49async function dailyCounts(
50 table: 'users' | 'deploy_history' | 'function_logs',
51 column: 'created_at' | 'booted_at',
52): Promise<DailyPoint[]> {
53 const db = getDb();
54 // ISO string, not Date: postgres.js can't serialize a raw Date param in
55 // sql`` templates under Bun ("string argument … Received an instance of
56 // Date") — the ::timestamptz cast makes the string unambiguous. Same
57 // blessed pattern as services/function-logs.ts getHourlyInvocations.
58 const since = new Date(Date.now() - (DAYS - 1) * 24 * 60 * 60 * 1000).toISOString();
59 const rows = (await db.execute(sql`
60 WITH days AS (
61 SELECT generate_series(
62 date_trunc('day', ${since}::timestamptz),
63 date_trunc('day', now()),
64 interval '1 day'
65 ) AS day
66 ),
67 src AS (
68 SELECT
69 date_trunc('day', ${sql.raw(column)}) AS day,
70 count(*)::int AS count
71 FROM ${sql.raw(table)}
72 WHERE ${sql.raw(column)} >= ${since}::timestamptz
73 GROUP BY 1
74 )
75 SELECT
76 to_char(days.day, 'YYYY-MM-DD') AS day,
77 coalesce(src.count, 0) AS count
78 FROM days
79 LEFT JOIN src ON src.day = days.day
80 ORDER BY days.day
81 `)) as Array<{ day: string; count: number | string }>;
82 return rows.map((r) => ({ day: r.day, count: Number(r.count) || 0 }));
83}
84
85/* ─── prometheus range queries (24h · 5m step) ───────────────────────── */
86
87// The api's real request counter — middleware/metrics.ts increments
88// `http_requests_total{method,status,route}` per request (lib/metrics.ts).
89// rate() is per-second, so ×60 yields requests per minute.
90const API_REQUESTS_PER_MIN = 'sum(rate(http_requests_total[5m])) * 60';
91
92interface RangePoint {
93 /** Sample timestamp, ISO 8601. */
94 readonly t: string;
95 readonly value: number;
96}
97
98/**
99 * Pure parser for a Prometheus range-query (`/api/v1/query_range`)
100 * matrix response — the range twin of platform-health's parsePromSample.
101 * Returns the FIRST series' samples, or null for any non-success status,
102 * empty result, or unparseable payload. Never throws, never fabricates.
103 */
104function parsePromMatrix(json: unknown): RangePoint[] | null {
105 if (!json || typeof json !== 'object') return null;
106 const root = json as { status?: unknown; data?: unknown };
107 if (root.status !== 'success' || !root.data || typeof root.data !== 'object') {
108 return null;
109 }
110 const result = (root.data as { result?: unknown }).result;
111 if (!Array.isArray(result) || result.length === 0) return null;
112 const first = result[0] as { values?: unknown };
113 if (!Array.isArray(first.values) || first.values.length === 0) return null;
114 const points: RangePoint[] = [];
115 for (const pair of first.values) {
116 if (!Array.isArray(pair) || pair.length < 2) continue;
117 const ts = Number(pair[0]);
118 const num = typeof pair[1] === 'number' ? pair[1] : Number.parseFloat(String(pair[1]));
119 if (!Number.isFinite(ts) || !Number.isFinite(num)) continue;
120 points.push({ t: new Date(ts * 1000).toISOString(), value: num });
121 }
122 return points.length > 0 ? points : null;
123}
124
125/**
126 * One PromQL expression against the range API — last 24h, 5m step.
127 * Mirrors platform-health's queryProm: any failure (network, timeout,
128 * non-2xx, bad JSON, empty matrix) collapses to null so one dead metric
129 * never poisons the other.
130 */
131async function queryPromRange(base: string, query: string): Promise<RangePoint[] | null> {
132 try {
133 const end = Math.floor(Date.now() / 1000);
134 const start = end - 24 * 60 * 60;
135 const url =
136 `${base}/api/v1/query_range?query=${encodeURIComponent(query)}` +
137 `&start=${start}&end=${end}&step=300`;
138 const res = await fetch(url, { signal: AbortSignal.timeout(4000) });
139 if (!res.ok) return null;
140 return parsePromMatrix(await res.json());
141 } catch {
142 return null;
143 }
144}
145
146/** Round to `digits` decimals; keeps the JSON payload compact. */
147function round(n: number, digits: number): number {
148 const f = 10 ** digits;
149 return Math.round(n * f) / f;
150}
151
152async function getPromSeries(): Promise<{
153 apiRequests: Array<{ t: string; perMin: number }> | null;
154 hostCpu: Array<{ t: string; pct: number }> | null;
155}> {
156 const base = env.BRIVEN_PROMETHEUS_URL;
157 if (!base) return { apiRequests: null, hostCpu: null };
158 const [req, cpu] = await Promise.all([
159 queryPromRange(base, API_REQUESTS_PER_MIN),
160 // Same expression platform-health uses for the instant host cpu%.
161 queryPromRange(base, PROM_QUERIES.cpuPercent),
162 ]);
163 return {
164 apiRequests: req ? req.map((p) => ({ t: p.t, perMin: round(p.value, 2) })) : null,
165 hostCpu: cpu ? cpu.map((p) => ({ t: p.t, pct: round(p.value, 1) })) : null,
166 };
167}
168
169/* ─── route ──────────────────────────────────────────────────────────── */
170
171adminTimeseriesRouter.get('/v1/admin/timeseries', async (c) => {
172 const [signupsDaily, deploysDaily, invocationsDaily, prom] = await Promise.all([
173 dailyCounts('users', 'created_at'),
174 dailyCounts('deploy_history', 'booted_at'),
175 // Platform-wide invocation volume — deliberately no project filter.
176 dailyCounts('function_logs', 'created_at'),
177 getPromSeries(),
178 ]);
179 return c.json({
180 signupsDaily,
181 deploysDaily,
182 invocationsDaily,
183 apiRequests: prom.apiRequests,
184 hostCpu: prom.hostCpu,
185 });
186});