@actuate-media/cms-core 0.25.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/api/stats-routes.test.d.ts +2 -0
- package/dist/__tests__/api/stats-routes.test.d.ts.map +1 -0
- package/dist/__tests__/api/stats-routes.test.js +251 -0
- package/dist/__tests__/api/stats-routes.test.js.map +1 -0
- package/dist/__tests__/auth/password.test.js +39 -12
- package/dist/__tests__/auth/password.test.js.map +1 -1
- package/dist/__tests__/content-health/scanner.test.d.ts +2 -0
- package/dist/__tests__/content-health/scanner.test.d.ts.map +1 -0
- package/dist/__tests__/content-health/scanner.test.js +151 -0
- package/dist/__tests__/content-health/scanner.test.js.map +1 -0
- package/dist/__tests__/diagnostics/api-metrics.test.d.ts +2 -0
- package/dist/__tests__/diagnostics/api-metrics.test.d.ts.map +1 -0
- package/dist/__tests__/diagnostics/api-metrics.test.js +153 -0
- package/dist/__tests__/diagnostics/api-metrics.test.js.map +1 -0
- package/dist/api/handler-factory.d.ts.map +1 -1
- package/dist/api/handler-factory.js +31 -1
- package/dist/api/handler-factory.js.map +1 -1
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +263 -0
- package/dist/api/handlers.js.map +1 -1
- package/dist/auth/password.d.ts +11 -0
- package/dist/auth/password.d.ts.map +1 -1
- package/dist/auth/password.js +13 -0
- package/dist/auth/password.js.map +1 -1
- package/dist/content-health/cron.d.ts +67 -0
- package/dist/content-health/cron.d.ts.map +1 -0
- package/dist/content-health/cron.js +167 -0
- package/dist/content-health/cron.js.map +1 -0
- package/dist/content-health/scanner.d.ts +118 -0
- package/dist/content-health/scanner.d.ts.map +1 -0
- package/dist/content-health/scanner.js +263 -0
- package/dist/content-health/scanner.js.map +1 -0
- package/dist/diagnostics/api-metrics.d.ts +135 -0
- package/dist/diagnostics/api-metrics.d.ts.map +1 -0
- package/dist/diagnostics/api-metrics.js +374 -0
- package/dist/diagnostics/api-metrics.js.map +1 -0
- package/package.json +1 -1
- package/prisma/migrations/0007_dashboard_metrics/migration.sql +74 -0
- package/prisma/schema.prisma +180 -73
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API request instrumentation that powers the Dashboard "Delivery API"
|
|
3
|
+
* tiles (request volume / error rate / avg latency / p95).
|
|
4
|
+
*
|
|
5
|
+
* ## Why per-minute aggregated buckets and not per-request rows
|
|
6
|
+
*
|
|
7
|
+
* A single 24-hour window at 100 req/s on 20 routes is ~17M raw rows,
|
|
8
|
+
* but the same window aggregated to (minute × route × status-bucket)
|
|
9
|
+
* is ≤ 1440 × 20 × 5 = 144k rows — and in practice far less because
|
|
10
|
+
* most route/minute combinations have zero traffic. The reader
|
|
11
|
+
* (`/stats/api-delivery`) folds the last 1440 minute buckets into
|
|
12
|
+
* four scalars; the writer does one atomic UPSERT per request via
|
|
13
|
+
* raw SQL.
|
|
14
|
+
*
|
|
15
|
+
* ## Why p95 via histogram and not from raw samples
|
|
16
|
+
*
|
|
17
|
+
* Storing a histogram per bucket gives us percentile estimates with
|
|
18
|
+
* ~10% error vs ground truth while keeping the table tiny. The
|
|
19
|
+
* `LATENCY_BUCKETS` boundary list is log-scale (powers of 2 in
|
|
20
|
+
* milliseconds) so it covers ~1 ms heartbeats through multi-second
|
|
21
|
+
* outliers in 12 columns. p95 falls out as a `findIndex` once the
|
|
22
|
+
* histogram is merged across all minute buckets in the window.
|
|
23
|
+
*
|
|
24
|
+
* ## Why fire-and-forget and not awaited
|
|
25
|
+
*
|
|
26
|
+
* Metric recording MUST NOT slow the user-facing response. The
|
|
27
|
+
* `record()` call dispatches the UPSERT to a `void`-discarded promise
|
|
28
|
+
* so a slow DB round-trip never blocks the request that produced
|
|
29
|
+
* the metric. Errors are swallowed at the source — losing a metric
|
|
30
|
+
* sample is strictly preferable to surfacing a metric-pipeline error
|
|
31
|
+
* to the user.
|
|
32
|
+
*
|
|
33
|
+
* ## Why we self-exempt the dashboard polling routes
|
|
34
|
+
*
|
|
35
|
+
* The dashboard refetches `/stats` and `/stats/api-delivery` on a
|
|
36
|
+
* timer. If we counted those requests they'd dominate the volume
|
|
37
|
+
* tile and inflate the error rate (a logged-out tab would hit
|
|
38
|
+
* `/stats` and get 401 repeatedly). The route-normaliser excludes
|
|
39
|
+
* them explicitly — see `shouldRecord`.
|
|
40
|
+
*/
|
|
41
|
+
import { createLogger } from './logger.js';
|
|
42
|
+
import { getDB } from '../db.js';
|
|
43
|
+
const logger = createLogger('api-metrics');
|
|
44
|
+
// ─── Public constants ──────────────────────────────────────────────────────
|
|
45
|
+
/**
|
|
46
|
+
* Upper bound (exclusive) of each histogram bucket, in milliseconds.
|
|
47
|
+
* The last bucket is `>= 1024 ms` (no upper bound).
|
|
48
|
+
*
|
|
49
|
+
* Indexed:
|
|
50
|
+
* 0: <1ms 1: 1-2ms 2: 2-4ms 3: 4-8ms
|
|
51
|
+
* 4: 8-16ms 5: 16-32ms 6: 32-64ms 7: 64-128ms
|
|
52
|
+
* 8: 128-256 9: 256-512 10: 512-1024 11: >=1024
|
|
53
|
+
*/
|
|
54
|
+
export const LATENCY_BUCKETS = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024];
|
|
55
|
+
export const HISTOGRAM_LENGTH = LATENCY_BUCKETS.length + 1; // 12
|
|
56
|
+
// ─── Pure helpers ──────────────────────────────────────────────────────────
|
|
57
|
+
/**
|
|
58
|
+
* Bucket a status code into one of 5 families. We keep the 1xx family
|
|
59
|
+
* out of the public type because no CMS route ever responds with one;
|
|
60
|
+
* if it ever happens, the `other` bucket catches it instead of
|
|
61
|
+
* silently being lost.
|
|
62
|
+
*/
|
|
63
|
+
export function statusBucketOf(status) {
|
|
64
|
+
if (status >= 200 && status < 300)
|
|
65
|
+
return '2xx';
|
|
66
|
+
if (status >= 300 && status < 400)
|
|
67
|
+
return '3xx';
|
|
68
|
+
if (status >= 400 && status < 500)
|
|
69
|
+
return '4xx';
|
|
70
|
+
if (status >= 500 && status < 600)
|
|
71
|
+
return '5xx';
|
|
72
|
+
return 'other';
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Return the index (0..HISTOGRAM_LENGTH-1) of the histogram bucket
|
|
76
|
+
* that this latency falls into. NaN / negative values fall in bucket
|
|
77
|
+
* 0 (treated as "<1ms") so a metric write never crashes on a bad
|
|
78
|
+
* input.
|
|
79
|
+
*/
|
|
80
|
+
export function histogramBucketIndex(durationMs) {
|
|
81
|
+
if (!Number.isFinite(durationMs) || durationMs < 1)
|
|
82
|
+
return 0;
|
|
83
|
+
for (let i = 0; i < LATENCY_BUCKETS.length; i++) {
|
|
84
|
+
if (durationMs < LATENCY_BUCKETS[i])
|
|
85
|
+
return i;
|
|
86
|
+
}
|
|
87
|
+
return LATENCY_BUCKETS.length; // overflow bucket
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Build a fresh histogram array with a single sample placed in the
|
|
91
|
+
* correct bucket. Used to produce the JSON payload for an INSERT.
|
|
92
|
+
*/
|
|
93
|
+
export function makeHistogramFor(durationMs) {
|
|
94
|
+
const arr = new Array(HISTOGRAM_LENGTH).fill(0);
|
|
95
|
+
arr[histogramBucketIndex(durationMs)] = 1;
|
|
96
|
+
return arr;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Element-wise add two histograms. Tolerant of either operand being
|
|
100
|
+
* missing or shorter than `HISTOGRAM_LENGTH` — defends against schema
|
|
101
|
+
* mismatches (e.g. an old row written before a bucket boundary change).
|
|
102
|
+
*/
|
|
103
|
+
export function mergeHistograms(a, b) {
|
|
104
|
+
const out = new Array(HISTOGRAM_LENGTH).fill(0);
|
|
105
|
+
for (let i = 0; i < HISTOGRAM_LENGTH; i++) {
|
|
106
|
+
out[i] = (a?.[i] ?? 0) + (b[i] ?? 0);
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Snap a Date to the start of its UTC minute. Returns a new Date —
|
|
112
|
+
* never mutates the input.
|
|
113
|
+
*/
|
|
114
|
+
export function bucketStartFor(now = new Date()) {
|
|
115
|
+
const ms = typeof now === 'number' ? now : now.getTime();
|
|
116
|
+
return new Date(ms - (ms % 60_000));
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Estimate percentile-N from a merged histogram. Returns the upper
|
|
120
|
+
* bound of the bucket that contains the requested percentile —
|
|
121
|
+
* which is the right "user-visible" answer (we are happy to say
|
|
122
|
+
* "p95 < 256ms" rather than guess the exact value inside the
|
|
123
|
+
* 128-256 bucket).
|
|
124
|
+
*
|
|
125
|
+
* For the overflow bucket (no upper bound), we report
|
|
126
|
+
* `LATENCY_BUCKETS[last]` as a lower-bound estimate; callers
|
|
127
|
+
* displaying this should annotate with a "≥" prefix if they want
|
|
128
|
+
* to expose the open-ended nature.
|
|
129
|
+
*/
|
|
130
|
+
export function estimatePercentile(histogram, percentile) {
|
|
131
|
+
const total = histogram.reduce((s, v) => s + v, 0);
|
|
132
|
+
if (total === 0)
|
|
133
|
+
return 0;
|
|
134
|
+
const target = total * percentile;
|
|
135
|
+
let acc = 0;
|
|
136
|
+
for (let i = 0; i < HISTOGRAM_LENGTH; i++) {
|
|
137
|
+
acc += histogram[i] ?? 0;
|
|
138
|
+
if (acc >= target) {
|
|
139
|
+
if (i < LATENCY_BUCKETS.length)
|
|
140
|
+
return LATENCY_BUCKETS[i];
|
|
141
|
+
return LATENCY_BUCKETS[LATENCY_BUCKETS.length - 1] * 2; // overflow estimate
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Normalise a request path to bound metric-row cardinality.
|
|
148
|
+
*
|
|
149
|
+
* /collections/posts/abc123 → /collections/posts/:id
|
|
150
|
+
* /globals/site-settings → /globals/:slug
|
|
151
|
+
* /collections/posts/abc123/restore→ /collections/posts/:id/restore
|
|
152
|
+
* /unknown/long/path/that/leaks → /other
|
|
153
|
+
*
|
|
154
|
+
* The "/other" bucket catches anything we don't explicitly know
|
|
155
|
+
* about so a single hostile client with random URLs can't blow up
|
|
156
|
+
* the table.
|
|
157
|
+
*/
|
|
158
|
+
export function normalizeRoute(pathname) {
|
|
159
|
+
// Strip query string + trailing slash (except root).
|
|
160
|
+
let p = pathname.split('?')[0] ?? '/';
|
|
161
|
+
if (p.length > 1 && p.endsWith('/'))
|
|
162
|
+
p = p.slice(0, -1);
|
|
163
|
+
// Strip the `/api/cms` prefix if still present — depending on where
|
|
164
|
+
// we instrument, the path may or may not have been rewritten yet.
|
|
165
|
+
if (p.startsWith('/api/cms'))
|
|
166
|
+
p = p.slice('/api/cms'.length) || '/';
|
|
167
|
+
// Static or top-level routes — pass through verbatim.
|
|
168
|
+
if (KNOWN_STATIC_ROUTES.has(p))
|
|
169
|
+
return p;
|
|
170
|
+
// Pattern matchers for cardinality-bounding.
|
|
171
|
+
for (const { pattern, replacement } of ROUTE_PATTERNS) {
|
|
172
|
+
if (pattern.test(p))
|
|
173
|
+
return p.replace(pattern, replacement);
|
|
174
|
+
}
|
|
175
|
+
return '/other';
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Routes that the dashboard polls itself, plus webhooks the cron
|
|
179
|
+
* fires. Counting these would either (a) cause feedback loops
|
|
180
|
+
* inflating the volume tile or (b) report cron-only traffic the
|
|
181
|
+
* editor never produces. The instrumentation skips them.
|
|
182
|
+
*/
|
|
183
|
+
export function shouldRecord(normalizedRoute) {
|
|
184
|
+
return !SKIP_ROUTES.has(normalizedRoute);
|
|
185
|
+
}
|
|
186
|
+
// ─── Lookup tables (kept module-scope so they're built once) ───────────────
|
|
187
|
+
/**
|
|
188
|
+
* Verbatim routes — the dashboard, auth, health, etc. Keep this
|
|
189
|
+
* small; if your CMS exposes many top-level routes, prefer
|
|
190
|
+
* `ROUTE_PATTERNS` so the table stays bounded.
|
|
191
|
+
*/
|
|
192
|
+
const KNOWN_STATIC_ROUTES = new Set([
|
|
193
|
+
'/',
|
|
194
|
+
'/health',
|
|
195
|
+
'/stats',
|
|
196
|
+
'/stats/api-delivery',
|
|
197
|
+
'/stats/content-health',
|
|
198
|
+
'/auth/login',
|
|
199
|
+
'/auth/logout',
|
|
200
|
+
'/auth/me',
|
|
201
|
+
'/auth/csrf',
|
|
202
|
+
'/auth/forgot-password',
|
|
203
|
+
'/auth/reset-password',
|
|
204
|
+
'/auth/totp/login',
|
|
205
|
+
'/setup/status',
|
|
206
|
+
'/setup/create-admin',
|
|
207
|
+
'/search',
|
|
208
|
+
'/users',
|
|
209
|
+
'/api-keys',
|
|
210
|
+
'/notifications',
|
|
211
|
+
'/notifications/stream',
|
|
212
|
+
'/notifications/read-all',
|
|
213
|
+
'/media',
|
|
214
|
+
'/media/upload',
|
|
215
|
+
'/forms',
|
|
216
|
+
'/seo/config',
|
|
217
|
+
'/seo/analyze',
|
|
218
|
+
'/redirects',
|
|
219
|
+
'/script-tags',
|
|
220
|
+
'/saved-sections',
|
|
221
|
+
'/page-templates',
|
|
222
|
+
'/webhooks',
|
|
223
|
+
'/webhooks/test',
|
|
224
|
+
]);
|
|
225
|
+
/**
|
|
226
|
+
* Skip these from the metric — they're either the dashboard polling
|
|
227
|
+
* itself (feedback loop) or cron-only traffic that distorts the
|
|
228
|
+
* editor-facing tiles.
|
|
229
|
+
*/
|
|
230
|
+
const SKIP_ROUTES = new Set([
|
|
231
|
+
'/stats',
|
|
232
|
+
'/stats/api-delivery',
|
|
233
|
+
'/stats/content-health',
|
|
234
|
+
'/health',
|
|
235
|
+
'/notifications/stream', // long-lived SSE; counting once on open is misleading
|
|
236
|
+
]);
|
|
237
|
+
const ROUTE_PATTERNS = [
|
|
238
|
+
{
|
|
239
|
+
pattern: /^\/collections\/[^/]+\/[^/]+\/restore$/,
|
|
240
|
+
replacement: '/collections/:slug/:id/restore',
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
pattern: /^\/collections\/[^/]+\/[^/]+\/versions(?:\/[^/]+)?$/,
|
|
244
|
+
replacement: '/collections/:slug/:id/versions',
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
pattern: /^\/collections\/[^/]+\/[^/]+\/duplicate$/,
|
|
248
|
+
replacement: '/collections/:slug/:id/duplicate',
|
|
249
|
+
},
|
|
250
|
+
{ pattern: /^\/collections\/[^/]+\/[^/]+$/, replacement: '/collections/:slug/:id' },
|
|
251
|
+
{ pattern: /^\/collections\/[^/]+$/, replacement: '/collections/:slug' },
|
|
252
|
+
{ pattern: /^\/globals\/[^/]+$/, replacement: '/globals/:slug' },
|
|
253
|
+
{ pattern: /^\/media\/[^/]+$/, replacement: '/media/:id' },
|
|
254
|
+
{ pattern: /^\/forms\/[^/]+\/submit$/, replacement: '/forms/:id/submit' },
|
|
255
|
+
{ pattern: /^\/forms\/[^/]+\/submissions(?:\/[^/]+)?$/, replacement: '/forms/:id/submissions' },
|
|
256
|
+
{ pattern: /^\/forms\/[^/]+$/, replacement: '/forms/:id' },
|
|
257
|
+
{ pattern: /^\/users\/[^/]+$/, replacement: '/users/:id' },
|
|
258
|
+
{ pattern: /^\/api-keys\/[^/]+$/, replacement: '/api-keys/:id' },
|
|
259
|
+
{ pattern: /^\/redirects\/[^/]+$/, replacement: '/redirects/:id' },
|
|
260
|
+
{ pattern: /^\/script-tags\/[^/]+$/, replacement: '/script-tags/:id' },
|
|
261
|
+
{ pattern: /^\/saved-sections\/[^/]+$/, replacement: '/saved-sections/:id' },
|
|
262
|
+
{ pattern: /^\/page-templates\/[^/]+$/, replacement: '/page-templates/:id' },
|
|
263
|
+
{ pattern: /^\/webhooks\/[^/]+(?:\/.+)?$/, replacement: '/webhooks/:id' },
|
|
264
|
+
{ pattern: /^\/notifications\/[^/]+\/read$/, replacement: '/notifications/:id/read' },
|
|
265
|
+
{ pattern: /^\/cron\/.+/, replacement: '/cron/:job' },
|
|
266
|
+
{ pattern: /^\/dev\/.+/, replacement: '/dev/:fn' },
|
|
267
|
+
{ pattern: /^\/seo\/.+/, replacement: '/seo/:fn' },
|
|
268
|
+
{ pattern: /^\/share\/.+/, replacement: '/share/:token' },
|
|
269
|
+
{ pattern: /^\/preview\/.+/, replacement: '/preview/:fn' },
|
|
270
|
+
{ pattern: /^\/page-builder\/.+/, replacement: '/page-builder/:fn' },
|
|
271
|
+
{ pattern: /^\/ai\/.+/, replacement: '/ai/:fn' },
|
|
272
|
+
{ pattern: /^\/auth\/.+/, replacement: '/auth/:fn' },
|
|
273
|
+
{ pattern: /^\/resolve(?:\/.+)?$/, replacement: '/resolve' },
|
|
274
|
+
];
|
|
275
|
+
// ─── Writer ────────────────────────────────────────────────────────────────
|
|
276
|
+
/**
|
|
277
|
+
* Record one API request to the metrics table. **Fire-and-forget** —
|
|
278
|
+
* callers MUST NOT `await` this. Errors are swallowed; the next
|
|
279
|
+
* request retries, and 1-minute aggregates are robust to occasional
|
|
280
|
+
* write loss.
|
|
281
|
+
*
|
|
282
|
+
* Writes a single SQL UPSERT so two concurrent requests landing in
|
|
283
|
+
* the same bucket interleave cleanly via Postgres's MVCC + the
|
|
284
|
+
* unique-on-conflict path. The histogram column is merged
|
|
285
|
+
* element-wise inside SQL, so we never read-modify-write from JS.
|
|
286
|
+
*/
|
|
287
|
+
export function recordApiRequest(sample) {
|
|
288
|
+
const route = normalizeRoute(sample.pathname);
|
|
289
|
+
if (!shouldRecord(route))
|
|
290
|
+
return;
|
|
291
|
+
// Schedule the write but do not await. Returning `void` is the
|
|
292
|
+
// contract — callers shouldn't be tempted to `.then()` on it.
|
|
293
|
+
void writeMetricBucket({
|
|
294
|
+
bucketStart: bucketStartFor(),
|
|
295
|
+
route,
|
|
296
|
+
statusBucket: statusBucketOf(sample.status),
|
|
297
|
+
durationMs: sample.durationMs,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
async function writeMetricBucket(args) {
|
|
301
|
+
let db;
|
|
302
|
+
try {
|
|
303
|
+
db = getDB();
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
// DB not initialised yet (e.g. dev server hot-reload race) —
|
|
307
|
+
// skip silently. There's nothing useful to log here and we
|
|
308
|
+
// don't want a stack trace on every dropped sample.
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
// Some hosts ship a Prisma client that doesn't include the
|
|
312
|
+
// `apiRequestMetric` model (e.g. third-party deploys that copied
|
|
313
|
+
// an older schema). Guard so we degrade silently rather than 500ing
|
|
314
|
+
// every request.
|
|
315
|
+
if (!db || typeof db.$executeRawUnsafe !== 'function' || !('apiRequestMetric' in db)) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const histogram = makeHistogramFor(args.durationMs);
|
|
319
|
+
try {
|
|
320
|
+
// Atomic INSERT...ON CONFLICT DO UPDATE. The histogram merge is
|
|
321
|
+
// expressed as a `jsonb_build_array` over the existing + new
|
|
322
|
+
// bucket counts so we never read the prior row from JS.
|
|
323
|
+
//
|
|
324
|
+
// CUID-ish ID generation via gen_random_uuid() keeps the ID
|
|
325
|
+
// schema-compatible with the model's `@default(cuid())` (both
|
|
326
|
+
// are 26+ char ASCII strings; Prisma's cuid is preferred but
|
|
327
|
+
// SQL-level UUIDs are accepted by the column type and not
|
|
328
|
+
// surfaced to consumers).
|
|
329
|
+
await db.$executeRawUnsafe(`
|
|
330
|
+
INSERT INTO "actuate_api_request_metrics"
|
|
331
|
+
("id", "bucketStart", "route", "statusBucket", "count", "sumLatencyMs", "maxLatencyMs", "latencyHistogram", "updatedAt")
|
|
332
|
+
VALUES (
|
|
333
|
+
'm_' || replace(gen_random_uuid()::text, '-', ''),
|
|
334
|
+
$1::timestamp,
|
|
335
|
+
$2,
|
|
336
|
+
$3,
|
|
337
|
+
1,
|
|
338
|
+
$4,
|
|
339
|
+
$4,
|
|
340
|
+
$5::jsonb,
|
|
341
|
+
NOW()
|
|
342
|
+
)
|
|
343
|
+
ON CONFLICT ("bucketStart", "route", "statusBucket") DO UPDATE
|
|
344
|
+
SET "count" = "actuate_api_request_metrics"."count" + 1,
|
|
345
|
+
"sumLatencyMs" = "actuate_api_request_metrics"."sumLatencyMs" + EXCLUDED."sumLatencyMs",
|
|
346
|
+
"maxLatencyMs" = GREATEST("actuate_api_request_metrics"."maxLatencyMs", EXCLUDED."maxLatencyMs"),
|
|
347
|
+
"latencyHistogram" = (
|
|
348
|
+
SELECT jsonb_agg(
|
|
349
|
+
COALESCE(("actuate_api_request_metrics"."latencyHistogram"->>i)::int, 0) +
|
|
350
|
+
COALESCE((EXCLUDED."latencyHistogram"->>i)::int, 0)
|
|
351
|
+
ORDER BY i
|
|
352
|
+
)
|
|
353
|
+
FROM generate_series(0, ${HISTOGRAM_LENGTH - 1}) AS i
|
|
354
|
+
),
|
|
355
|
+
"updatedAt" = NOW()
|
|
356
|
+
`, args.bucketStart, args.route, args.statusBucket, args.durationMs, JSON.stringify(histogram));
|
|
357
|
+
}
|
|
358
|
+
catch (err) {
|
|
359
|
+
// Throttle the log so a misconfigured DB doesn't drown the
|
|
360
|
+
// logger. We rate-limit *log lines*, not metric writes — every
|
|
361
|
+
// request still attempts a write, but only one error/minute
|
|
362
|
+
// surfaces in console.
|
|
363
|
+
rateLimitedWarn('recordApiRequest write failed', err instanceof Error ? err.message : String(err));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
let lastWarnAt = 0;
|
|
367
|
+
function rateLimitedWarn(msg, detail) {
|
|
368
|
+
const now = Date.now();
|
|
369
|
+
if (now - lastWarnAt < 60_000)
|
|
370
|
+
return;
|
|
371
|
+
lastWarnAt = now;
|
|
372
|
+
logger.warn(msg, { detail });
|
|
373
|
+
}
|
|
374
|
+
//# sourceMappingURL=api-metrics.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api-metrics.js","sourceRoot":"","sources":["../../src/diagnostics/api-metrics.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
-- Dashboard metrics & content health
|
|
2
|
+
--
|
|
3
|
+
-- Adds two tables that back the redesigned admin Dashboard:
|
|
4
|
+
-- * actuate_api_request_metrics — per-minute pre-aggregated request
|
|
5
|
+
-- counters with a fixed log-scale latency histogram, sized to keep
|
|
6
|
+
-- a week of traffic in O(minutes × routes × status-buckets) rows.
|
|
7
|
+
-- * actuate_content_issues — persisted content-quality issues
|
|
8
|
+
-- produced by the nightly content-health scanner. Upserted by
|
|
9
|
+
-- (documentId, type) so the scanner is idempotent.
|
|
10
|
+
--
|
|
11
|
+
-- Both tables are safe to add to a populated database: they have no
|
|
12
|
+
-- backfill dependency and no constraints that touch existing rows.
|
|
13
|
+
|
|
14
|
+
-- ---------------------------------------------------------------------------
|
|
15
|
+
-- ContentIssueType enum
|
|
16
|
+
-- ---------------------------------------------------------------------------
|
|
17
|
+
CREATE TYPE "ContentIssueType" AS ENUM (
|
|
18
|
+
'MISSING_META_DESCRIPTION',
|
|
19
|
+
'MISSING_ALT_TEXT',
|
|
20
|
+
'BROKEN_INTERNAL_LINK',
|
|
21
|
+
'OUTDATED_CONTENT'
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
-- ---------------------------------------------------------------------------
|
|
25
|
+
-- actuate_api_request_metrics
|
|
26
|
+
-- ---------------------------------------------------------------------------
|
|
27
|
+
CREATE TABLE "actuate_api_request_metrics" (
|
|
28
|
+
"id" TEXT NOT NULL,
|
|
29
|
+
"bucketStart" TIMESTAMP(3) NOT NULL,
|
|
30
|
+
"route" TEXT NOT NULL,
|
|
31
|
+
"statusBucket" TEXT NOT NULL,
|
|
32
|
+
"count" INTEGER NOT NULL DEFAULT 0,
|
|
33
|
+
"sumLatencyMs" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
|
34
|
+
"maxLatencyMs" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
|
35
|
+
"latencyHistogram" JSONB NOT NULL,
|
|
36
|
+
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
37
|
+
|
|
38
|
+
CONSTRAINT "actuate_api_request_metrics_pkey" PRIMARY KEY ("id")
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
CREATE UNIQUE INDEX "actuate_api_request_metrics_bucketStart_route_statusBucket_key"
|
|
42
|
+
ON "actuate_api_request_metrics" ("bucketStart", "route", "statusBucket");
|
|
43
|
+
|
|
44
|
+
CREATE INDEX "actuate_api_request_metrics_bucketStart_idx"
|
|
45
|
+
ON "actuate_api_request_metrics" ("bucketStart");
|
|
46
|
+
|
|
47
|
+
-- ---------------------------------------------------------------------------
|
|
48
|
+
-- actuate_content_issues
|
|
49
|
+
-- ---------------------------------------------------------------------------
|
|
50
|
+
CREATE TABLE "actuate_content_issues" (
|
|
51
|
+
"id" TEXT NOT NULL,
|
|
52
|
+
"documentId" TEXT NOT NULL,
|
|
53
|
+
"type" "ContentIssueType" NOT NULL,
|
|
54
|
+
"count" INTEGER NOT NULL DEFAULT 1,
|
|
55
|
+
"details" JSONB,
|
|
56
|
+
"firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
57
|
+
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
58
|
+
|
|
59
|
+
CONSTRAINT "actuate_content_issues_pkey" PRIMARY KEY ("id"),
|
|
60
|
+
CONSTRAINT "actuate_content_issues_documentId_fkey"
|
|
61
|
+
FOREIGN KEY ("documentId")
|
|
62
|
+
REFERENCES "actuate_documents"("id")
|
|
63
|
+
ON DELETE CASCADE
|
|
64
|
+
ON UPDATE CASCADE
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
CREATE UNIQUE INDEX "actuate_content_issues_documentId_type_key"
|
|
68
|
+
ON "actuate_content_issues" ("documentId", "type");
|
|
69
|
+
|
|
70
|
+
CREATE INDEX "actuate_content_issues_type_idx"
|
|
71
|
+
ON "actuate_content_issues" ("type");
|
|
72
|
+
|
|
73
|
+
CREATE INDEX "actuate_content_issues_documentId_idx"
|
|
74
|
+
ON "actuate_content_issues" ("documentId");
|