@actuate-media/cms-core 0.25.1 → 0.27.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/collections-discovery.test.js +47 -0
- package/dist/__tests__/api/collections-discovery.test.js.map +1 -1
- package/dist/__tests__/api/page-sections-routes.test.d.ts +2 -0
- package/dist/__tests__/api/page-sections-routes.test.d.ts.map +1 -0
- package/dist/__tests__/api/page-sections-routes.test.js +271 -0
- package/dist/__tests__/api/page-sections-routes.test.js.map +1 -0
- package/dist/__tests__/api/stats-collection-filter.test.d.ts +2 -0
- package/dist/__tests__/api/stats-collection-filter.test.d.ts.map +1 -0
- package/dist/__tests__/api/stats-collection-filter.test.js +190 -0
- package/dist/__tests__/api/stats-collection-filter.test.js.map +1 -0
- 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__/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 +678 -5
- package/dist/api/handlers.js.map +1 -1
- package/dist/config/types.d.ts +8 -0
- package/dist/config/types.d.ts.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/dist/sections/__tests__/sections.test.d.ts +2 -0
- package/dist/sections/__tests__/sections.test.d.ts.map +1 -0
- package/dist/sections/__tests__/sections.test.js +215 -0
- package/dist/sections/__tests__/sections.test.js.map +1 -0
- package/dist/sections/helpers.d.ts +62 -0
- package/dist/sections/helpers.d.ts.map +1 -0
- package/dist/sections/helpers.js +202 -0
- package/dist/sections/helpers.js.map +1 -0
- package/dist/sections/index.d.ts +13 -0
- package/dist/sections/index.d.ts.map +1 -0
- package/dist/sections/index.js +12 -0
- package/dist/sections/index.js.map +1 -0
- package/dist/sections/registry.d.ts +27 -0
- package/dist/sections/registry.d.ts.map +1 -0
- package/dist/sections/registry.js +349 -0
- package/dist/sections/registry.js.map +1 -0
- package/dist/sections/types.d.ts +127 -0
- package/dist/sections/types.d.ts.map +1 -0
- package/dist/sections/types.js +21 -0
- package/dist/sections/types.js.map +1 -0
- package/dist/sections/validate.d.ts +10 -0
- package/dist/sections/validate.d.ts.map +1 -0
- package/dist/sections/validate.js +92 -0
- package/dist/sections/validate.js.map +1 -0
- package/package.json +6 -1
- package/prisma/migrations/0007_dashboard_metrics/migration.sql +74 -0
- package/prisma/schema.prisma +180 -73
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content health scanner — produces the issue rows that power the
|
|
3
|
+
* dashboard "Content Health" card.
|
|
4
|
+
*
|
|
5
|
+
* ## Why a separate module from the cron / endpoint
|
|
6
|
+
*
|
|
7
|
+
* The scanner is pure logic — give it a document, get back an array
|
|
8
|
+
* of issues. The cron drives it across the corpus and persists the
|
|
9
|
+
* results; the endpoint reads the persisted results. Keeping the
|
|
10
|
+
* scanner free of I/O dependencies makes it trivially testable
|
|
11
|
+
* (~10 µs per call, no DB) and lets future callers (CI checks, a
|
|
12
|
+
* pre-publish hook, etc.) reuse it without dragging in cron
|
|
13
|
+
* infrastructure.
|
|
14
|
+
*
|
|
15
|
+
* ## Why HEAD probes for the link checker
|
|
16
|
+
*
|
|
17
|
+
* Internal links 404 in two flavors: a hard 404 from the framework
|
|
18
|
+
* (page genuinely doesn't exist) and a soft 404 where the framework
|
|
19
|
+
* returns 200 with a "not found" template. We only catch the first.
|
|
20
|
+
* That's fine — the dashboard's job is to surface the high-confidence
|
|
21
|
+
* issues, not chase ambiguous soft-404 heuristics. The scanner can
|
|
22
|
+
* be extended with response-body matching later if the editor
|
|
23
|
+
* feedback warrants it.
|
|
24
|
+
*
|
|
25
|
+
* ## Why we count alt-less images per document, not per image
|
|
26
|
+
*
|
|
27
|
+
* The dashboard surfaces total issue counts. Storing one row per
|
|
28
|
+
* image would blow up the issue table for pages with 20+
|
|
29
|
+
* unannotated photos and provide no extra value (the editor still
|
|
30
|
+
* gets a single "12 images need alt text" row in the UI). We
|
|
31
|
+
* collapse to one row per (document, type) with `count` carrying
|
|
32
|
+
* the per-document magnitude.
|
|
33
|
+
*/
|
|
34
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
35
|
+
/**
|
|
36
|
+
* Return the longest plausible HTML payload from a document. CMS
|
|
37
|
+
* collections use different field names (`body`, `content`,
|
|
38
|
+
* `richText`, `html`) so we try each and pick the longest — that
|
|
39
|
+
* resolves the common case where a document has both a plain
|
|
40
|
+
* `excerpt` and a rich `body`, and we want to scan the body.
|
|
41
|
+
*/
|
|
42
|
+
function extractHtml(data) {
|
|
43
|
+
const candidates = ['body', 'content', 'richText', 'richtext', 'html', 'description'];
|
|
44
|
+
let best = '';
|
|
45
|
+
for (const key of candidates) {
|
|
46
|
+
const v = data[key];
|
|
47
|
+
if (typeof v === 'string' && v.length > best.length)
|
|
48
|
+
best = v;
|
|
49
|
+
}
|
|
50
|
+
return best;
|
|
51
|
+
}
|
|
52
|
+
const META_DESCRIPTION_KEYS = ['metaDescription', 'seoDescription', 'meta_description'];
|
|
53
|
+
function getMetaDescription(data) {
|
|
54
|
+
for (const key of META_DESCRIPTION_KEYS) {
|
|
55
|
+
const v = data[key];
|
|
56
|
+
if (typeof v === 'string' && v.trim().length > 0)
|
|
57
|
+
return v.trim();
|
|
58
|
+
}
|
|
59
|
+
// Nested SEO objects (e.g. data.seo.description).
|
|
60
|
+
const seo = data['seo'];
|
|
61
|
+
if (seo && typeof seo === 'object') {
|
|
62
|
+
const obj = seo;
|
|
63
|
+
const v = obj['description'] ?? obj['metaDescription'];
|
|
64
|
+
if (typeof v === 'string' && v.trim().length > 0)
|
|
65
|
+
return v.trim();
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Count <img> tags in the HTML that don't carry an `alt` attribute.
|
|
71
|
+
* Matches the existing a11y rule in `a11y/index.ts` so the scanner
|
|
72
|
+
* agrees with the per-document audit panel.
|
|
73
|
+
*/
|
|
74
|
+
export function countMissingAltImages(html) {
|
|
75
|
+
if (!html)
|
|
76
|
+
return 0;
|
|
77
|
+
const re = /<img(?![^>]*\salt\s*=)[^>]*>/gi;
|
|
78
|
+
let count = 0;
|
|
79
|
+
// We don't iterate matches into an array — just count, so memory
|
|
80
|
+
// stays bounded even on huge pages.
|
|
81
|
+
while (re.exec(html) !== null)
|
|
82
|
+
count++;
|
|
83
|
+
return count;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Extract candidate URLs from <a href="..."> attributes. Returns
|
|
87
|
+
* the raw href values; classification (internal vs external,
|
|
88
|
+
* absolute vs relative) happens in the caller.
|
|
89
|
+
*/
|
|
90
|
+
export function extractLinks(html) {
|
|
91
|
+
if (!html)
|
|
92
|
+
return [];
|
|
93
|
+
const out = [];
|
|
94
|
+
const re = /<a\b[^>]*\shref\s*=\s*("([^"]*)"|'([^']*)')/gi;
|
|
95
|
+
let match;
|
|
96
|
+
while ((match = re.exec(html)) !== null) {
|
|
97
|
+
const url = (match[2] ?? match[3] ?? '').trim();
|
|
98
|
+
if (url.length > 0)
|
|
99
|
+
out.push(url);
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Decide whether a URL is "internal" relative to `siteOrigin`.
|
|
105
|
+
* Internal links include:
|
|
106
|
+
* - relative paths: `/about`, `./page`, `../foo`
|
|
107
|
+
* - same-origin absolute URLs: `https://example.com/page`
|
|
108
|
+
*
|
|
109
|
+
* Excluded:
|
|
110
|
+
* - external URLs (different origin)
|
|
111
|
+
* - hash-only anchors (`#section`)
|
|
112
|
+
* - non-http schemes (`mailto:`, `tel:`, `javascript:`, etc.)
|
|
113
|
+
* - protocol-relative URLs to a different host (`//other.com/x`)
|
|
114
|
+
*/
|
|
115
|
+
export function classifyLink(url, siteOrigin) {
|
|
116
|
+
const trimmed = url.trim();
|
|
117
|
+
if (trimmed.length === 0)
|
|
118
|
+
return { isInternal: false, resolved: null };
|
|
119
|
+
if (trimmed.startsWith('#'))
|
|
120
|
+
return { isInternal: false, resolved: null };
|
|
121
|
+
// Reject non-http schemes outright.
|
|
122
|
+
const schemeMatch = /^([a-z][a-z0-9+.-]*):/i.exec(trimmed);
|
|
123
|
+
if (schemeMatch) {
|
|
124
|
+
const scheme = schemeMatch[1].toLowerCase();
|
|
125
|
+
if (scheme !== 'http' && scheme !== 'https')
|
|
126
|
+
return { isInternal: false, resolved: null };
|
|
127
|
+
}
|
|
128
|
+
let parsedOrigin;
|
|
129
|
+
try {
|
|
130
|
+
parsedOrigin = new URL(siteOrigin);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return { isInternal: false, resolved: null };
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const resolved = new URL(trimmed, parsedOrigin);
|
|
137
|
+
if (resolved.protocol !== 'http:' && resolved.protocol !== 'https:') {
|
|
138
|
+
return { isInternal: false, resolved: null };
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
isInternal: resolved.origin === parsedOrigin.origin,
|
|
142
|
+
resolved: resolved.toString(),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return { isInternal: false, resolved: null };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Single-link reachability probe. Default implementation used by
|
|
151
|
+
* the cron — issues a HEAD with a 5s timeout, falls back to GET if
|
|
152
|
+
* HEAD returns 405/501 (some frameworks reject HEAD).
|
|
153
|
+
*/
|
|
154
|
+
export async function checkInternalLinkReachable(url, timeoutMs = 5000) {
|
|
155
|
+
const controller = new AbortController();
|
|
156
|
+
const t = setTimeout(() => controller.abort(), timeoutMs);
|
|
157
|
+
try {
|
|
158
|
+
const head = await fetch(url, {
|
|
159
|
+
method: 'HEAD',
|
|
160
|
+
redirect: 'follow',
|
|
161
|
+
signal: controller.signal,
|
|
162
|
+
});
|
|
163
|
+
if (head.status === 405 || head.status === 501) {
|
|
164
|
+
// Method not allowed / not implemented → retry with GET.
|
|
165
|
+
const get = await fetch(url, {
|
|
166
|
+
method: 'GET',
|
|
167
|
+
redirect: 'follow',
|
|
168
|
+
signal: controller.signal,
|
|
169
|
+
});
|
|
170
|
+
return get.status < 400;
|
|
171
|
+
}
|
|
172
|
+
return head.status < 400;
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
clearTimeout(t);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// ─── Scanner ───────────────────────────────────────────────────────────────
|
|
182
|
+
/**
|
|
183
|
+
* Run all checks on a single document. The link checker is
|
|
184
|
+
* concurrency-bounded to `LINK_CHECK_CONCURRENCY` per document so a
|
|
185
|
+
* page with 200 links doesn't open 200 sockets at once.
|
|
186
|
+
*
|
|
187
|
+
* Each issue type appears at most once in the returned array. The
|
|
188
|
+
* shape `{ type, count, details }` lets the caller upsert by
|
|
189
|
+
* `(documentId, type)` and rely on `count` for the magnitude
|
|
190
|
+
* surfaced in the dashboard.
|
|
191
|
+
*/
|
|
192
|
+
export async function scanDocument(doc, opts = {}) {
|
|
193
|
+
const outdatedDays = opts.outdatedDays ?? 90;
|
|
194
|
+
const issues = [];
|
|
195
|
+
// 1. Missing meta description --------------------------------------------
|
|
196
|
+
if (!getMetaDescription(doc.data)) {
|
|
197
|
+
issues.push({ type: 'MISSING_META_DESCRIPTION', count: 1 });
|
|
198
|
+
}
|
|
199
|
+
// 2. Missing alt text ----------------------------------------------------
|
|
200
|
+
const html = extractHtml(doc.data);
|
|
201
|
+
const altLessImages = countMissingAltImages(html);
|
|
202
|
+
if (altLessImages > 0) {
|
|
203
|
+
issues.push({ type: 'MISSING_ALT_TEXT', count: altLessImages });
|
|
204
|
+
}
|
|
205
|
+
// 3. Outdated content ----------------------------------------------------
|
|
206
|
+
const referenceTime = doc.publishedAt ?? doc.updatedAt;
|
|
207
|
+
const ageMs = Date.now() - referenceTime.getTime();
|
|
208
|
+
if (ageMs > outdatedDays * 24 * 60 * 60 * 1000) {
|
|
209
|
+
issues.push({
|
|
210
|
+
type: 'OUTDATED_CONTENT',
|
|
211
|
+
count: 1,
|
|
212
|
+
details: {
|
|
213
|
+
ageDays: Math.floor(ageMs / (24 * 60 * 60 * 1000)),
|
|
214
|
+
threshold: outdatedDays,
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
// 4. Broken internal links ----------------------------------------------
|
|
219
|
+
if (opts.checkLink && opts.siteOrigin) {
|
|
220
|
+
const links = extractLinks(html);
|
|
221
|
+
const internalUrls = new Set();
|
|
222
|
+
for (const raw of links) {
|
|
223
|
+
const { isInternal, resolved } = classifyLink(raw, opts.siteOrigin);
|
|
224
|
+
if (isInternal && resolved)
|
|
225
|
+
internalUrls.add(resolved);
|
|
226
|
+
}
|
|
227
|
+
const broken = [];
|
|
228
|
+
if (internalUrls.size > 0) {
|
|
229
|
+
const checker = opts.checkLink;
|
|
230
|
+
await Promise.all(chunk([...internalUrls], LINK_CHECK_CONCURRENCY).map(async (group) => {
|
|
231
|
+
const results = await Promise.all(group.map((u) => checker(u).catch(() => false)));
|
|
232
|
+
group.forEach((u, i) => {
|
|
233
|
+
if (!results[i])
|
|
234
|
+
broken.push(u);
|
|
235
|
+
});
|
|
236
|
+
}));
|
|
237
|
+
}
|
|
238
|
+
if (broken.length > 0) {
|
|
239
|
+
issues.push({
|
|
240
|
+
type: 'BROKEN_INTERNAL_LINK',
|
|
241
|
+
count: broken.length,
|
|
242
|
+
details: { urls: broken.slice(0, 10) }, // cap stored details
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return issues;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Concurrency cap for the per-document link checker. Vercel
|
|
250
|
+
* functions are wall-time bounded; opening 5 concurrent sockets
|
|
251
|
+
* keeps us under the typical 60s soft cap even for pages with
|
|
252
|
+
* 100+ internal links.
|
|
253
|
+
*/
|
|
254
|
+
const LINK_CHECK_CONCURRENCY = 5;
|
|
255
|
+
function chunk(arr, size) {
|
|
256
|
+
if (size <= 0)
|
|
257
|
+
return [arr];
|
|
258
|
+
const out = [];
|
|
259
|
+
for (let i = 0; i < arr.length; i += size)
|
|
260
|
+
out.push(arr.slice(i, i + size));
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
//# sourceMappingURL=scanner.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scanner.js","sourceRoot":"","sources":["../../src/content-health/scanner.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
/**
|
|
42
|
+
* Upper bound (exclusive) of each histogram bucket, in milliseconds.
|
|
43
|
+
* The last bucket is `>= 1024 ms` (no upper bound).
|
|
44
|
+
*
|
|
45
|
+
* Indexed:
|
|
46
|
+
* 0: <1ms 1: 1-2ms 2: 2-4ms 3: 4-8ms
|
|
47
|
+
* 4: 8-16ms 5: 16-32ms 6: 32-64ms 7: 64-128ms
|
|
48
|
+
* 8: 128-256 9: 256-512 10: 512-1024 11: >=1024
|
|
49
|
+
*/
|
|
50
|
+
export declare const LATENCY_BUCKETS: readonly [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024];
|
|
51
|
+
export declare const HISTOGRAM_LENGTH: number;
|
|
52
|
+
export type StatusBucket = '2xx' | '3xx' | '4xx' | '5xx' | 'other';
|
|
53
|
+
export interface ApiMetricSample {
|
|
54
|
+
/** Request path, CMS-relative — e.g. `/collections/posts/abc123`. */
|
|
55
|
+
pathname: string;
|
|
56
|
+
status: number;
|
|
57
|
+
/** Response time in milliseconds. May be a float. */
|
|
58
|
+
durationMs: number;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Bucket a status code into one of 5 families. We keep the 1xx family
|
|
62
|
+
* out of the public type because no CMS route ever responds with one;
|
|
63
|
+
* if it ever happens, the `other` bucket catches it instead of
|
|
64
|
+
* silently being lost.
|
|
65
|
+
*/
|
|
66
|
+
export declare function statusBucketOf(status: number): StatusBucket;
|
|
67
|
+
/**
|
|
68
|
+
* Return the index (0..HISTOGRAM_LENGTH-1) of the histogram bucket
|
|
69
|
+
* that this latency falls into. NaN / negative values fall in bucket
|
|
70
|
+
* 0 (treated as "<1ms") so a metric write never crashes on a bad
|
|
71
|
+
* input.
|
|
72
|
+
*/
|
|
73
|
+
export declare function histogramBucketIndex(durationMs: number): number;
|
|
74
|
+
/**
|
|
75
|
+
* Build a fresh histogram array with a single sample placed in the
|
|
76
|
+
* correct bucket. Used to produce the JSON payload for an INSERT.
|
|
77
|
+
*/
|
|
78
|
+
export declare function makeHistogramFor(durationMs: number): number[];
|
|
79
|
+
/**
|
|
80
|
+
* Element-wise add two histograms. Tolerant of either operand being
|
|
81
|
+
* missing or shorter than `HISTOGRAM_LENGTH` — defends against schema
|
|
82
|
+
* mismatches (e.g. an old row written before a bucket boundary change).
|
|
83
|
+
*/
|
|
84
|
+
export declare function mergeHistograms(a: number[] | null | undefined, b: number[]): number[];
|
|
85
|
+
/**
|
|
86
|
+
* Snap a Date to the start of its UTC minute. Returns a new Date —
|
|
87
|
+
* never mutates the input.
|
|
88
|
+
*/
|
|
89
|
+
export declare function bucketStartFor(now?: Date | number): Date;
|
|
90
|
+
/**
|
|
91
|
+
* Estimate percentile-N from a merged histogram. Returns the upper
|
|
92
|
+
* bound of the bucket that contains the requested percentile —
|
|
93
|
+
* which is the right "user-visible" answer (we are happy to say
|
|
94
|
+
* "p95 < 256ms" rather than guess the exact value inside the
|
|
95
|
+
* 128-256 bucket).
|
|
96
|
+
*
|
|
97
|
+
* For the overflow bucket (no upper bound), we report
|
|
98
|
+
* `LATENCY_BUCKETS[last]` as a lower-bound estimate; callers
|
|
99
|
+
* displaying this should annotate with a "≥" prefix if they want
|
|
100
|
+
* to expose the open-ended nature.
|
|
101
|
+
*/
|
|
102
|
+
export declare function estimatePercentile(histogram: number[], percentile: number): number;
|
|
103
|
+
/**
|
|
104
|
+
* Normalise a request path to bound metric-row cardinality.
|
|
105
|
+
*
|
|
106
|
+
* /collections/posts/abc123 → /collections/posts/:id
|
|
107
|
+
* /globals/site-settings → /globals/:slug
|
|
108
|
+
* /collections/posts/abc123/restore→ /collections/posts/:id/restore
|
|
109
|
+
* /unknown/long/path/that/leaks → /other
|
|
110
|
+
*
|
|
111
|
+
* The "/other" bucket catches anything we don't explicitly know
|
|
112
|
+
* about so a single hostile client with random URLs can't blow up
|
|
113
|
+
* the table.
|
|
114
|
+
*/
|
|
115
|
+
export declare function normalizeRoute(pathname: string): string;
|
|
116
|
+
/**
|
|
117
|
+
* Routes that the dashboard polls itself, plus webhooks the cron
|
|
118
|
+
* fires. Counting these would either (a) cause feedback loops
|
|
119
|
+
* inflating the volume tile or (b) report cron-only traffic the
|
|
120
|
+
* editor never produces. The instrumentation skips them.
|
|
121
|
+
*/
|
|
122
|
+
export declare function shouldRecord(normalizedRoute: string): boolean;
|
|
123
|
+
/**
|
|
124
|
+
* Record one API request to the metrics table. **Fire-and-forget** —
|
|
125
|
+
* callers MUST NOT `await` this. Errors are swallowed; the next
|
|
126
|
+
* request retries, and 1-minute aggregates are robust to occasional
|
|
127
|
+
* write loss.
|
|
128
|
+
*
|
|
129
|
+
* Writes a single SQL UPSERT so two concurrent requests landing in
|
|
130
|
+
* the same bucket interleave cleanly via Postgres's MVCC + the
|
|
131
|
+
* unique-on-conflict path. The histogram column is merged
|
|
132
|
+
* element-wise inside SQL, so we never read-modify-write from JS.
|
|
133
|
+
*/
|
|
134
|
+
export declare function recordApiRequest(sample: ApiMetricSample): void;
|
|
135
|
+
//# sourceMappingURL=api-metrics.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api-metrics.d.ts","sourceRoot":"","sources":["../../src/diagnostics/api-metrics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AASH;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe,wDAAyD,CAAA;AAErF,eAAO,MAAM,gBAAgB,QAA6B,CAAA;AAE1D,MAAM,MAAM,YAAY,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,GAAG,OAAO,CAAA;AAElE,MAAM,WAAW,eAAe;IAC9B,qEAAqE;IACrE,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;IACd,qDAAqD;IACrD,UAAU,EAAE,MAAM,CAAA;CACnB;AAID;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,YAAY,CAM3D;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAM/D;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE,CAI7D;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,GAAG,SAAS,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAMrF;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,GAAG,GAAE,IAAI,GAAG,MAAmB,GAAG,IAAI,CAGpE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAalF;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAiBvD;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,eAAe,EAAE,MAAM,GAAG,OAAO,CAE7D;AAiGD;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,eAAe,GAAG,IAAI,CAY9D"}
|