@uptimizr/collector-server 2.0.1 → 2.2.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.
Files changed (139) hide show
  1. package/AGENTS.md +187 -11
  2. package/README.md +91 -7
  3. package/dist/__tests__/support/registryRequests.d.ts +2 -1
  4. package/dist/__tests__/support/registryRequests.d.ts.map +1 -1
  5. package/dist/__tests__/support/registryRequests.js +17 -1
  6. package/dist/__tests__/support/registryRequests.js.map +1 -1
  7. package/dist/__tests__/support/subscriptionFixtures.d.ts +63 -0
  8. package/dist/__tests__/support/subscriptionFixtures.d.ts.map +1 -0
  9. package/dist/__tests__/support/subscriptionFixtures.js +17 -0
  10. package/dist/__tests__/support/subscriptionFixtures.js.map +1 -0
  11. package/dist/agentReport.d.ts +256 -0
  12. package/dist/agentReport.d.ts.map +1 -0
  13. package/dist/agentReport.js +861 -0
  14. package/dist/agentReport.js.map +1 -0
  15. package/dist/app.d.ts +13 -0
  16. package/dist/app.d.ts.map +1 -1
  17. package/dist/app.js +112 -5
  18. package/dist/app.js.map +1 -1
  19. package/dist/audit.d.ts +5 -1
  20. package/dist/audit.d.ts.map +1 -1
  21. package/dist/audit.js +24 -4
  22. package/dist/audit.js.map +1 -1
  23. package/dist/auth.d.ts +41 -1
  24. package/dist/auth.d.ts.map +1 -1
  25. package/dist/auth.js +47 -0
  26. package/dist/auth.js.map +1 -1
  27. package/dist/cli.js +133 -13
  28. package/dist/cli.js.map +1 -1
  29. package/dist/cliStore.d.ts +31 -2
  30. package/dist/cliStore.d.ts.map +1 -1
  31. package/dist/cliStore.js +34 -0
  32. package/dist/cliStore.js.map +1 -1
  33. package/dist/clickhouseStore.d.ts.map +1 -1
  34. package/dist/clickhouseStore.js +44 -2
  35. package/dist/clickhouseStore.js.map +1 -1
  36. package/dist/config.d.ts +49 -0
  37. package/dist/config.d.ts.map +1 -1
  38. package/dist/config.js +21 -0
  39. package/dist/config.js.map +1 -1
  40. package/dist/connectionLimiter.d.ts +23 -0
  41. package/dist/connectionLimiter.d.ts.map +1 -0
  42. package/dist/connectionLimiter.js +24 -0
  43. package/dist/connectionLimiter.js.map +1 -0
  44. package/dist/duckdbStore.d.ts.map +1 -1
  45. package/dist/duckdbStore.js +44 -1
  46. package/dist/duckdbStore.js.map +1 -1
  47. package/dist/internalDispatch.d.ts +36 -0
  48. package/dist/internalDispatch.d.ts.map +1 -0
  49. package/dist/internalDispatch.js +50 -0
  50. package/dist/internalDispatch.js.map +1 -0
  51. package/dist/memoryStore.d.ts.map +1 -1
  52. package/dist/memoryStore.js +314 -1
  53. package/dist/memoryStore.js.map +1 -1
  54. package/dist/mssqlStore.d.ts.map +1 -1
  55. package/dist/mssqlStore.js +44 -2
  56. package/dist/mssqlStore.js.map +1 -1
  57. package/dist/postgresStore.d.ts.map +1 -1
  58. package/dist/postgresStore.js +44 -2
  59. package/dist/postgresStore.js.map +1 -1
  60. package/dist/projectMetadata.d.ts +82 -0
  61. package/dist/projectMetadata.d.ts.map +1 -0
  62. package/dist/projectMetadata.js +50 -0
  63. package/dist/projectMetadata.js.map +1 -0
  64. package/dist/routes/context.d.ts +135 -0
  65. package/dist/routes/context.d.ts.map +1 -0
  66. package/dist/routes/context.js +306 -0
  67. package/dist/routes/context.js.map +1 -0
  68. package/dist/routes/insights.d.ts +36 -0
  69. package/dist/routes/insights.d.ts.map +1 -0
  70. package/dist/routes/insights.js +810 -0
  71. package/dist/routes/insights.js.map +1 -0
  72. package/dist/routes/live.d.ts +8 -0
  73. package/dist/routes/live.d.ts.map +1 -1
  74. package/dist/routes/live.js +8 -7
  75. package/dist/routes/live.js.map +1 -1
  76. package/dist/routes/mcp.d.ts +36 -0
  77. package/dist/routes/mcp.d.ts.map +1 -0
  78. package/dist/routes/mcp.js +262 -0
  79. package/dist/routes/mcp.js.map +1 -0
  80. package/dist/routes/meta.d.ts +16 -2
  81. package/dist/routes/meta.d.ts.map +1 -1
  82. package/dist/routes/meta.js +699 -10
  83. package/dist/routes/meta.js.map +1 -1
  84. package/dist/routes/metadata.d.ts +31 -0
  85. package/dist/routes/metadata.d.ts.map +1 -0
  86. package/dist/routes/metadata.js +163 -0
  87. package/dist/routes/metadata.js.map +1 -0
  88. package/dist/routes/narrative.d.ts +36 -0
  89. package/dist/routes/narrative.d.ts.map +1 -0
  90. package/dist/routes/narrative.js +95 -0
  91. package/dist/routes/narrative.js.map +1 -0
  92. package/dist/routes/panels.d.ts +44 -0
  93. package/dist/routes/panels.d.ts.map +1 -0
  94. package/dist/routes/panels.js +108 -0
  95. package/dist/routes/panels.js.map +1 -0
  96. package/dist/routes/query-dsl.d.ts +50 -0
  97. package/dist/routes/query-dsl.d.ts.map +1 -0
  98. package/dist/routes/query-dsl.js +359 -0
  99. package/dist/routes/query-dsl.js.map +1 -0
  100. package/dist/routes/query.d.ts +36 -0
  101. package/dist/routes/query.d.ts.map +1 -1
  102. package/dist/routes/query.js +101 -13
  103. package/dist/routes/query.js.map +1 -1
  104. package/dist/routes/subscriptions.d.ts +52 -0
  105. package/dist/routes/subscriptions.d.ts.map +1 -0
  106. package/dist/routes/subscriptions.js +290 -0
  107. package/dist/routes/subscriptions.js.map +1 -0
  108. package/dist/store.d.ts +113 -2
  109. package/dist/store.d.ts.map +1 -1
  110. package/dist/subscriptions/cli.d.ts +36 -0
  111. package/dist/subscriptions/cli.d.ts.map +1 -0
  112. package/dist/subscriptions/cli.js +143 -0
  113. package/dist/subscriptions/cli.js.map +1 -0
  114. package/dist/subscriptions/delivery.d.ts +101 -0
  115. package/dist/subscriptions/delivery.d.ts.map +1 -0
  116. package/dist/subscriptions/delivery.js +165 -0
  117. package/dist/subscriptions/delivery.js.map +1 -0
  118. package/dist/subscriptions/evaluate.d.ts +119 -0
  119. package/dist/subscriptions/evaluate.d.ts.map +1 -0
  120. package/dist/subscriptions/evaluate.js +326 -0
  121. package/dist/subscriptions/evaluate.js.map +1 -0
  122. package/dist/subscriptions/scheduler.d.ts +97 -0
  123. package/dist/subscriptions/scheduler.d.ts.map +1 -0
  124. package/dist/subscriptions/scheduler.js +332 -0
  125. package/dist/subscriptions/scheduler.js.map +1 -0
  126. package/dist/subscriptions/stream.d.ts +40 -0
  127. package/dist/subscriptions/stream.d.ts.map +1 -0
  128. package/dist/subscriptions/stream.js +38 -0
  129. package/dist/subscriptions/stream.js.map +1 -0
  130. package/dist/version.d.ts +3 -0
  131. package/dist/version.d.ts.map +1 -0
  132. package/dist/version.js +25 -0
  133. package/dist/version.js.map +1 -0
  134. package/dist/webhookSignature.d.ts +64 -0
  135. package/dist/webhookSignature.d.ts.map +1 -0
  136. package/dist/webhookSignature.js +91 -0
  137. package/dist/webhookSignature.js.map +1 -0
  138. package/llms.txt +39 -2
  139. package/package.json +17 -14
@@ -0,0 +1,810 @@
1
+ /**
2
+ * **Insight primitives** — `GET /api/v1/insights/*` (ADR 0051 §4, sketch §D).
3
+ *
4
+ * Two registry metrics that answer the questions an agent otherwise re-derives
5
+ * from raw rows on every turn:
6
+ *
7
+ * - `insight_baseline` — what is normal for one metric in one scene;
8
+ * - `insight_movers` — what changed, ranked by how unusual the change is;
9
+ * - `insight_anomalies` — which buckets of one metric do not belong, and what
10
+ * inside the metric accounts for them (#306).
11
+ *
12
+ * The plugin is deliberately thin (ADR 0005). Everything that decides an answer
13
+ * lives in `@uptimizr/db`'s pure `src/insights/`: which metrics have a portable
14
+ * bucket series, how the window resolves, and every statistic. This file does
15
+ * three things — validate at the boundary, fan the bucket reads out under a
16
+ * declared cap, and shape the envelope.
17
+ *
18
+ * It is a separate plugin rather than more routes in `query.ts` because it is a
19
+ * separate *kind* of read: a derived metric composed of other metrics' series,
20
+ * which needs its own error vocabulary (a metric can be unknown, not comparable,
21
+ * or comparable but not bucketable — three different 400s) and its own bounded
22
+ * fan-out. One registration line in `app.ts` is the whole coupling.
23
+ */
24
+ import { z } from "zod";
25
+ import { BUCKETABLE_METRIC_IDS, MAX_ANOMALY_SENSITIVITY, MIN_ANOMALY_SENSITIVITY,
26
+ // --- significance / scene health (#307) ---
27
+ HEALTH_DEFAULT_SCENES, HEALTH_FACTORS, HEALTH_FACTOR_IDS, HEALTH_MAX_SCENES, bucketVariantFor, computeSceneHealth, computeSignificance, rankSceneHealth, resolveHealthWindows, MOVERS_DEFAULT_METRICS, MOVERS_MAX_METRICS, attributeContributor, bucketMeasureFor, computeBaseline, contributorDimensionFor, contributorWindows, detectAnomalies, inContributorWindow, inWindow, isBucketableMetric, rankMovers, resolveBaselineWindow, resolveMoversWindows, resultEnvelopeSchema, resultFormatSchema, spanningWindow, summarizeRows, tableResult, } from "@uptimizr/db";
28
+ import { allMetrics, getMetric } from "@uptimizr/metrics";
29
+ import { requireCapability } from "../auth.js";
30
+ /**
31
+ * How many bucket reads run at once.
32
+ *
33
+ * `movers` issues one grouped scan per scanned metric. Firing all of them at
34
+ * once would open more concurrent statements than a single-file DuckDB or a
35
+ * small Postgres pool wants; running them one at a time would pay the full
36
+ * round-trip latency 24 times. A small fixed pool is the compromise, and it is
37
+ * a constant rather than a setting because the cap on metrics per request
38
+ * (`MOVERS_MAX_METRICS`) already bounds the total work.
39
+ */
40
+ const BUCKET_READ_CONCURRENCY = 4;
41
+ /** Developer-assigned scene/area filter (ADR 0010) — same shape as `query.ts`. */
42
+ const sceneFilter = z
43
+ .string()
44
+ .regex(/^[A-Za-z0-9._:-]{1,64}$/)
45
+ .optional();
46
+ /** The insight time grain. Not the FPS histogram's `bucket`, which is a number. */
47
+ const bucketFilter = z.enum(["day", "hour"]).optional();
48
+ /** Result envelope (ADR 0051 §2) — every registry-served aggregate accepts it. */
49
+ const formatFilter = resultFormatSchema.optional();
50
+ /**
51
+ * `GET /api/v1/insights/baseline` parameters.
52
+ *
53
+ * `metric` is validated as a bounded identifier here and resolved against the
54
+ * registry in the handler, so the 400 can *name the alternatives* instead of
55
+ * emitting an opaque enum-mismatch message — the difference between an agent
56
+ * that recovers on the next turn and one that retries the same call.
57
+ */
58
+ const baselineQueryParams = z.object({
59
+ metric: z.string().min(1).max(64),
60
+ scene: sceneFilter,
61
+ window: z.coerce.number().int().positive().max(365).optional(),
62
+ bucket: bucketFilter,
63
+ since: z.coerce.number().int().optional(),
64
+ until: z.coerce.number().int().optional(),
65
+ format: formatFilter,
66
+ });
67
+ /**
68
+ * `GET /api/v1/insights/movers` parameters.
69
+ *
70
+ * `metrics` is a comma-separated allowlist. It is capped at
71
+ * {@link MOVERS_MAX_METRICS} by the schema itself, so an over-long list is a
72
+ * validation error rather than a silently truncated scan — a caller must never
73
+ * be told "nothing moved" about metrics that were never looked at.
74
+ */
75
+ const moversQueryParams = z.object({
76
+ scene: sceneFilter,
77
+ metrics: z
78
+ .string()
79
+ .min(1)
80
+ .max(1024)
81
+ .optional()
82
+ .transform((value, ctx) => {
83
+ if (value == null)
84
+ return undefined;
85
+ const ids = value
86
+ .split(",")
87
+ .map((id) => id.trim())
88
+ .filter((id) => id.length > 0);
89
+ if (ids.length === 0) {
90
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "metrics must name at least one id" });
91
+ return z.NEVER;
92
+ }
93
+ if (ids.length > MOVERS_MAX_METRICS) {
94
+ ctx.addIssue({
95
+ code: z.ZodIssueCode.custom,
96
+ message: `metrics is capped at ${MOVERS_MAX_METRICS} ids per request`,
97
+ });
98
+ return z.NEVER;
99
+ }
100
+ return [...new Set(ids)];
101
+ }),
102
+ bucket: bucketFilter,
103
+ limit: z.coerce.number().int().positive().max(50).optional(),
104
+ since: z.coerce.number().int().optional(),
105
+ until: z.coerce.number().int().optional(),
106
+ refSince: z.coerce.number().int().optional(),
107
+ refUntil: z.coerce.number().int().optional(),
108
+ format: formatFilter,
109
+ });
110
+ // --- anomalies (#306) ------------------------------------------------------
111
+ /**
112
+ * `GET /api/v1/insights/anomalies` parameters.
113
+ *
114
+ * The same subject-plus-window shape as `baseline` — an anomaly is an anomaly
115
+ * *of* one metric — with one extra dial. `sensitivity` is bounded by the schema
116
+ * rather than clamped silently, so a caller who asks for `0` (which would report
117
+ * every bucket) is told the range instead of being handed noise.
118
+ */
119
+ const anomaliesQueryParams = z.object({
120
+ metric: z.string().min(1).max(64),
121
+ scene: sceneFilter,
122
+ window: z.coerce.number().int().positive().max(365).optional(),
123
+ bucket: bucketFilter,
124
+ sensitivity: z.coerce
125
+ .number()
126
+ .min(MIN_ANOMALY_SENSITIVITY)
127
+ .max(MAX_ANOMALY_SENSITIVITY)
128
+ .optional(),
129
+ since: z.coerce.number().int().optional(),
130
+ until: z.coerce.number().int().optional(),
131
+ format: formatFilter,
132
+ });
133
+ /** `{ error, … }` body the insight routes send for a rejected metric. */
134
+ const badRequestResponse = z.object({
135
+ error: z.string(),
136
+ metric: z.string().optional(),
137
+ comparable: z.array(z.string()).optional(),
138
+ available: z.array(z.string()).optional(),
139
+ });
140
+ /** The registry entry for an insight route, resolved once at startup. */
141
+ function metricFor(id) {
142
+ const metric = getMetric(id);
143
+ // Startup-time failure: this plugin exists to serve these two entries.
144
+ if (metric == null)
145
+ throw new Error(`no registry metric '${id}'`);
146
+ return metric;
147
+ }
148
+ const BASELINE = metricFor("insight_baseline");
149
+ const MOVERS = metricFor("insight_movers");
150
+ // --- anomalies (#306) ---
151
+ const ANOMALIES = metricFor("insight_anomalies");
152
+ /** 200 response schema for an insight route: rows, or either envelope. */
153
+ function rowsFor(metric) {
154
+ return resultEnvelopeSchema(z.array(metric.row), metric.row);
155
+ }
156
+ /** Every comparable registry metric id, sorted — quoted back in a 400. */
157
+ const COMPARABLE_METRIC_IDS = allMetrics()
158
+ .filter((metric) => metric.comparable != null)
159
+ .map((metric) => metric.id)
160
+ .sort();
161
+ /**
162
+ * Resolve a caller-supplied metric id to something a series can be built from.
163
+ *
164
+ * Three distinct failures, each with its own remedy, because collapsing them
165
+ * into one "bad metric" message is what makes an agent loop:
166
+ *
167
+ * - **unknown** — a typo or a hallucinated id; the fix is a different name;
168
+ * - **not comparable** — a real metric with no single headline column (a
169
+ * trajectory, a representation); the fix is a different *metric*, so the
170
+ * comparable ids are listed;
171
+ * - **not bucketable** — comparable, but its value is defined by a join, a
172
+ * window function or a caller-supplied predicate and so has no faithful
173
+ * per-bucket form (see `measures.ts`); the fix is one of the ids that do.
174
+ */
175
+ function resolveSeriesMetric(id) {
176
+ const metric = getMetric(id);
177
+ if (metric == null) {
178
+ return {
179
+ error: `unknown metric '${id}'`,
180
+ metric: id,
181
+ available: [...BUCKETABLE_METRIC_IDS],
182
+ };
183
+ }
184
+ if (metric.comparable == null) {
185
+ return {
186
+ error: `metric '${id}' declares no comparable measure, so it has no single value to baseline ` +
187
+ `or compare`,
188
+ metric: id,
189
+ comparable: [...COMPARABLE_METRIC_IDS],
190
+ };
191
+ }
192
+ if (!isBucketableMetric(id)) {
193
+ return {
194
+ error: `metric '${id}' is comparable but has no portable bucket series (its value is defined by ` +
195
+ `a join, a window function or a caller-supplied predicate), so it cannot be bucketed ` +
196
+ `without changing what it means`,
197
+ metric: id,
198
+ comparable: [...COMPARABLE_METRIC_IDS],
199
+ available: [...BUCKETABLE_METRIC_IDS],
200
+ };
201
+ }
202
+ return metric;
203
+ }
204
+ function isRejection(value) {
205
+ return "error" in value;
206
+ }
207
+ /** Run `task` over `items` with at most `limit` in flight, preserving order. */
208
+ async function mapPooled(items, limit, task) {
209
+ const out = new Array(items.length);
210
+ let next = 0;
211
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
212
+ for (let index = next++; index < items.length; index = next++) {
213
+ out[index] = await task(items[index]);
214
+ }
215
+ });
216
+ await Promise.all(workers);
217
+ return out;
218
+ }
219
+ /** Split one spanning series into the two windows a mover compares. */
220
+ function partition(rows, range, reference) {
221
+ const current = [];
222
+ const previous = [];
223
+ for (const row of rows) {
224
+ if (inWindow(row.bucket, range))
225
+ current.push(row);
226
+ else if (inWindow(row.bucket, reference))
227
+ previous.push(row);
228
+ }
229
+ return { current, reference: previous };
230
+ }
231
+ /**
232
+ * Everything the summariser needs about the request that produced the rows.
233
+ *
234
+ * The **resolved** window is echoed rather than the raw querystring, because
235
+ * the bounds were snapped to whole buckets — a caller reading `range` in the
236
+ * envelope must see the window that was actually measured.
237
+ */
238
+ function summaryContextFor(metric, request, range) {
239
+ const query = (request.query ?? {});
240
+ const filters = { ...query, since: range.since, until: range.until };
241
+ // `metrics` arrives parsed as an array; render it back to the comma list the
242
+ // caller sent, so the echoed filters are a valid querystring value.
243
+ if (Array.isArray(filters.metrics))
244
+ filters.metrics = filters.metrics.join(",");
245
+ return {
246
+ range: { since: range.since, until: range.until },
247
+ filters,
248
+ };
249
+ }
250
+ // =========================================================================
251
+ // --- significance / scene health (#307) ----------------------------------
252
+ //
253
+ // Two more derived metrics on the same plugin. They add no new *kind* of
254
+ // coupling: the same capability check, the same bounded fan-out over
255
+ // `store.metricBuckets`, the same `format` hook. What is new is only what the
256
+ // pure layer in `@uptimizr/db`'s `src/insights/` does with the series after it
257
+ // comes back.
258
+ // =========================================================================
259
+ const SIGNIFICANCE = metricFor("insight_significance");
260
+ const SCENE_HEALTH = metricFor("insight_scene_health");
261
+ /**
262
+ * `GET /api/v1/insights/significance` parameters.
263
+ *
264
+ * `segment` / `refSegment` are accepted **in order to be refused**. Sketch §D
265
+ * allows a segment-versus-segment contrast, so an agent that has read the
266
+ * design will try one; a `400` naming the window parameters is a far better
267
+ * answer than a silently dropped parameter and a comparison of the wrong two
268
+ * things.
269
+ */
270
+ const significanceQueryParams = z.object({
271
+ metric: z.string().min(1).max(64),
272
+ scene: sceneFilter,
273
+ bucket: bucketFilter,
274
+ since: z.coerce.number().int().optional(),
275
+ until: z.coerce.number().int().optional(),
276
+ refSince: z.coerce.number().int().optional(),
277
+ refUntil: z.coerce.number().int().optional(),
278
+ format: formatFilter,
279
+ });
280
+ /**
281
+ * Parameters the sketch allows but v1 cannot honour.
282
+ *
283
+ * They are deliberately **not** in the querystring schema — the registry
284
+ * `filters` list is the contract, and advertising a parameter that always fails
285
+ * would be worse than not having it. But an agent that has read sketch §D will
286
+ * try one, so the raw querystring is checked and the answer names what to use
287
+ * instead. Silently dropping the parameter would compare the wrong two things
288
+ * and report a p-value for it.
289
+ */
290
+ const UNSUPPORTED_SIGNIFICANCE_PARAMS = ["segment", "refSegment"];
291
+ /** The first unsupported parameter present in a request’s raw querystring. */
292
+ function unsupportedParam(request) {
293
+ const query = (request.raw.url ?? "").split("?")[1];
294
+ if (query == null || query.length === 0)
295
+ return undefined;
296
+ const params = new URLSearchParams(query);
297
+ return UNSUPPORTED_SIGNIFICANCE_PARAMS.find((name) => params.has(name));
298
+ }
299
+ /**
300
+ * `GET /api/v1/insights/scene-health` parameters.
301
+ *
302
+ * `weights` is a JSON object, parsed and validated here rather than in the pure
303
+ * layer: an unknown factor id deserves a `400` naming the ids that exist, and
304
+ * by the time the value reaches `computeSceneHealth` it is an ordinary record
305
+ * of non-negative numbers.
306
+ */
307
+ const sceneHealthQueryParams = z.object({
308
+ scene: sceneFilter,
309
+ window: z.coerce.number().int().positive().max(90).optional(),
310
+ bucket: bucketFilter,
311
+ since: z.coerce.number().int().optional(),
312
+ until: z.coerce.number().int().optional(),
313
+ limit: z.coerce.number().int().positive().max(HEALTH_MAX_SCENES).optional(),
314
+ weights: z
315
+ .string()
316
+ .min(1)
317
+ .max(512)
318
+ .optional()
319
+ .transform((value, ctx) => {
320
+ if (value == null)
321
+ return undefined;
322
+ let parsed;
323
+ try {
324
+ parsed = JSON.parse(value);
325
+ }
326
+ catch {
327
+ ctx.addIssue({
328
+ code: z.ZodIssueCode.custom,
329
+ message: 'weights must be a JSON object, e.g. {"error_rate":0.5}',
330
+ });
331
+ return z.NEVER;
332
+ }
333
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
334
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "weights must be a JSON object" });
335
+ return z.NEVER;
336
+ }
337
+ const out = {};
338
+ for (const [id, weight] of Object.entries(parsed)) {
339
+ if (!HEALTH_FACTOR_IDS.includes(id)) {
340
+ ctx.addIssue({
341
+ code: z.ZodIssueCode.custom,
342
+ message: `unknown health factor '${id}'; the factors are ${HEALTH_FACTOR_IDS.join(", ")}`,
343
+ });
344
+ return z.NEVER;
345
+ }
346
+ if (typeof weight !== "number" || !Number.isFinite(weight) || weight < 0) {
347
+ ctx.addIssue({
348
+ code: z.ZodIssueCode.custom,
349
+ message: `weight for '${id}' must be a number >= 0`,
350
+ });
351
+ return z.NEVER;
352
+ }
353
+ out[id] = weight;
354
+ }
355
+ return out;
356
+ }),
357
+ format: formatFilter,
358
+ });
359
+ /**
360
+ * Whether a metric's headline column is a **declared rate**: its
361
+ * `comparable.primary` names a `rateOf` denominator *and* the bucket catalog
362
+ * carries the series that reproduces that denominator per bucket.
363
+ *
364
+ * Both halves are required. The registry alone says the number is a share of
365
+ * something; the catalog alone says a second series exists. Only together do
366
+ * they license a two-proportion test.
367
+ */
368
+ function isDeclaredRate(metric) {
369
+ const primary = metric.comparable?.primary;
370
+ if (primary == null)
371
+ return false;
372
+ if (metric.columns[primary]?.rateOf == null)
373
+ return false;
374
+ return bucketVariantFor(metric.id, "denominator") != null;
375
+ }
376
+ /**
377
+ * Whether a measure **counts events** — the property that separates the two
378
+ * discrete tests from Welch's t. A `count` or distinct-`sessions` aggregate
379
+ * counts things that happened; a `sum`, `avg`, `max` or `quantile` is a level,
380
+ * and a level is compared with a t-test whatever its unit.
381
+ */
382
+ function isCountingMeasure(metric) {
383
+ const kind = bucketMeasureFor(metric)?.aggregate.kind;
384
+ return kind === "count" || kind === "sessions";
385
+ }
386
+ /** What one unit of a metric's headline column is, for `effectUnit`. */
387
+ function unitOf(metric) {
388
+ const primary = metric.comparable?.primary;
389
+ return (primary == null ? undefined : metric.columns[primary]?.unit) ?? "value";
390
+ }
391
+ /** The key a `HealthRead`'s rows are filed under. */
392
+ function readKey(read) {
393
+ return `${read.baseline ? "" : read.scene}|${read.factor}|${read.side}|${read.baseline ? "b" : "c"}`;
394
+ }
395
+ /** The sum of a series' finite values — the sessions behind a scored window. */
396
+ function seriesTotal(rows) {
397
+ let total = 0;
398
+ for (const row of rows ?? []) {
399
+ if (row.value != null && Number.isFinite(row.value))
400
+ total += row.value;
401
+ }
402
+ return total;
403
+ }
404
+ /**
405
+ * Insight API. Every route is scoped to the authenticated project and needs the
406
+ * ordinary `query` capability — an insight is a read of the project's own
407
+ * telemetry, nothing more.
408
+ */
409
+ export const insightRoutes = async (app, { store }) => {
410
+ const r = app.withTypeProvider();
411
+ /** The resolved window of the request in flight, for the envelope hook. */
412
+ const resolvedRanges = new WeakMap();
413
+ /**
414
+ * `format=table | summary` for the two derived metrics (ADR 0051 §2).
415
+ *
416
+ * The same job the hook in `query.ts` does for the aggregations, scoped to
417
+ * this plugin — Fastify hooks are encapsulated, and these two metrics are not
418
+ * in that plugin's path index because they carry no `build*` builder. The
419
+ * shaping itself is `@uptimizr/db`'s pure, registry-driven `tableResult` /
420
+ * `summarizeRows`, so neither handler knows the envelope exists.
421
+ */
422
+ app.addHook("preSerialization", async (request, reply, payload) => {
423
+ if (reply.statusCode !== 200)
424
+ return payload;
425
+ const format = request.query?.format;
426
+ if (format == null || format === "full")
427
+ return payload;
428
+ const metric = METRIC_BY_PATH.get(request.routeOptions.url ?? "");
429
+ if (metric == null)
430
+ return payload;
431
+ const rows = (Array.isArray(payload) ? payload : payload == null ? [] : [payload]);
432
+ const range = resolvedRanges.get(request) ?? { since: 0, until: 0 };
433
+ const context = summaryContextFor(metric, request, range);
434
+ const shaped = format === "table"
435
+ ? tableResult(metric, rows, context)
436
+ : summarizeRows(metric, rows, context);
437
+ return shaped ?? payload;
438
+ });
439
+ /**
440
+ * What is normal here. One row: the centre, spread, range and drift of one
441
+ * metric's bucket series over the window.
442
+ */
443
+ r.get(BASELINE.endpoint.path, {
444
+ schema: {
445
+ querystring: baselineQueryParams,
446
+ response: { 200: rowsFor(BASELINE), 400: badRequestResponse },
447
+ },
448
+ }, async (req, reply) => {
449
+ const resolved = await requireCapability(req, reply, store, "query");
450
+ if (!resolved)
451
+ return reply;
452
+ const metric = resolveSeriesMetric(req.query.metric);
453
+ if (isRejection(metric))
454
+ return reply.code(400).send(metric);
455
+ const bucket = req.query.bucket ?? "day";
456
+ const range = resolveBaselineWindow({
457
+ since: req.query.since,
458
+ until: req.query.until,
459
+ windowDays: req.query.window,
460
+ bucket,
461
+ now: Date.now(),
462
+ });
463
+ resolvedRanges.set(req, range);
464
+ const rows = await store.metricBuckets(resolved.projectId, {
465
+ metric: metric.id,
466
+ bucket,
467
+ since: range.since,
468
+ until: range.until,
469
+ scene: req.query.scene,
470
+ });
471
+ const baseline = computeBaseline(metric.id, req.query.scene, rows);
472
+ return [baseline];
473
+ });
474
+ /**
475
+ * What changed. One row per scanned metric, risers first, then fallers, then
476
+ * the ones that did not move.
477
+ */
478
+ r.get(MOVERS.endpoint.path, {
479
+ schema: {
480
+ querystring: moversQueryParams,
481
+ response: { 200: rowsFor(MOVERS), 400: badRequestResponse },
482
+ },
483
+ }, async (req, reply) => {
484
+ const resolved = await requireCapability(req, reply, store, "query");
485
+ if (!resolved)
486
+ return reply;
487
+ // An explicit allowlist is validated id by id, so a single bad name is a
488
+ // 400 naming it rather than a quietly shorter scan.
489
+ const requested = req.query.metrics;
490
+ const scanned = [];
491
+ for (const id of requested ?? MOVERS_DEFAULT_METRICS) {
492
+ const metric = resolveSeriesMetric(id);
493
+ if (isRejection(metric))
494
+ return reply.code(400).send(metric);
495
+ scanned.push(metric);
496
+ }
497
+ const bucket = req.query.bucket ?? "day";
498
+ const { range, reference } = resolveMoversWindows({
499
+ since: req.query.since,
500
+ until: req.query.until,
501
+ refSince: req.query.refSince,
502
+ refUntil: req.query.refUntil,
503
+ bucket,
504
+ now: Date.now(),
505
+ });
506
+ resolvedRanges.set(req, range);
507
+ // One read per metric covering *both* windows: half the queries, and both
508
+ // windows are guaranteed to have seen the same snapshot of the data.
509
+ const span = spanningWindow(range, reference);
510
+ const inputs = await mapPooled(scanned, BUCKET_READ_CONCURRENCY, async (metric) => {
511
+ const rows = await store.metricBuckets(resolved.projectId, {
512
+ metric: metric.id,
513
+ bucket,
514
+ since: span.since,
515
+ until: span.until,
516
+ scene: req.query.scene,
517
+ });
518
+ const split = partition(rows, range, reference);
519
+ return {
520
+ metric: metric.id,
521
+ direction: metric.comparable?.direction ?? "neutral",
522
+ minSample: metric.comparable?.minSample ?? 1,
523
+ rollup: bucketMeasureFor(metric.id)?.rollup ?? "sum",
524
+ current: split.current,
525
+ reference: split.reference,
526
+ };
527
+ });
528
+ const rows = rankMovers(inputs, req.query.limit ?? 10);
529
+ return rows;
530
+ });
531
+ // --- anomalies (#306) ----------------------------------------------------
532
+ /**
533
+ * When one metric stopped behaving. One row per anomalous bucket, oldest
534
+ * first, each carrying what was expected, how far out it was, and — where the
535
+ * metric declares a split dimension — which value inside it accounts for the
536
+ * excess.
537
+ *
538
+ * Cost is `1 + min(anomalous windows, ANOMALY_MAX_CONTRIBUTOR_SCANS)` grouped
539
+ * scans: one to build the series, and at most three to attribute it. The cap
540
+ * is the reason a pathological series (every bucket anomalous) cannot turn one
541
+ * request into a hundred scans.
542
+ */
543
+ r.get(ANOMALIES.endpoint.path, {
544
+ schema: {
545
+ querystring: anomaliesQueryParams,
546
+ response: { 200: rowsFor(ANOMALIES), 400: badRequestResponse },
547
+ },
548
+ }, async (req, reply) => {
549
+ const resolved = await requireCapability(req, reply, store, "query");
550
+ if (!resolved)
551
+ return reply;
552
+ const metric = resolveSeriesMetric(req.query.metric);
553
+ if (isRejection(metric))
554
+ return reply.code(400).send(metric);
555
+ const bucket = req.query.bucket ?? "day";
556
+ // Same window resolution as `baseline`: the two answer questions about the
557
+ // same series, and a caller who reads one and then the other must not have
558
+ // to reason about two different notions of "the last 28 days".
559
+ const range = resolveBaselineWindow({
560
+ since: req.query.since,
561
+ until: req.query.until,
562
+ windowDays: req.query.window,
563
+ bucket,
564
+ now: Date.now(),
565
+ });
566
+ resolvedRanges.set(req, range);
567
+ const series = await store.metricBuckets(resolved.projectId, {
568
+ metric: metric.id,
569
+ bucket,
570
+ since: range.since,
571
+ until: range.until,
572
+ scene: req.query.scene,
573
+ });
574
+ const rows = detectAnomalies(metric.id, req.query.scene, series, {
575
+ bucket,
576
+ sensitivity: req.query.sensitivity,
577
+ });
578
+ const dimension = contributorDimensionFor(metric.id, { scene: req.query.scene });
579
+ if (dimension == null || rows.length === 0)
580
+ return rows;
581
+ // One extra grouped scan per anomalous window, capped. Each scan is
582
+ // clamped to the analysed range, so attribution never reads outside the
583
+ // window the caller asked about and each split value's "before" is the
584
+ // same history the detection itself used.
585
+ const windows = contributorWindows(rows, { bucket, seriesUntil: range.until });
586
+ const splits = await mapPooled(windows, BUCKET_READ_CONCURRENCY, (window) => store.metricBuckets(resolved.projectId, {
587
+ metric: metric.id,
588
+ bucket,
589
+ since: Math.max(window.since, range.since),
590
+ until: Math.min(window.until, range.until),
591
+ scene: req.query.scene,
592
+ groupBy: dimension,
593
+ }));
594
+ for (const [index, window] of windows.entries()) {
595
+ const splitRows = splits[index] ?? [];
596
+ for (const row of rows) {
597
+ if (row.contributor != null || !inContributorWindow(row, window))
598
+ continue;
599
+ row.contributor = attributeContributor(row, dimension, splitRows);
600
+ }
601
+ }
602
+ return rows;
603
+ });
604
+ // =======================================================================
605
+ // --- significance / scene health (#307) --------------------------------
606
+ // =======================================================================
607
+ /**
608
+ * Is that difference real. One row: the effect, its interval, a p-value and
609
+ * the test that produced them.
610
+ */
611
+ r.get(SIGNIFICANCE.endpoint.path, {
612
+ schema: {
613
+ querystring: significanceQueryParams,
614
+ response: { 200: rowsFor(SIGNIFICANCE), 400: badRequestResponse },
615
+ },
616
+ }, async (req, reply) => {
617
+ const resolved = await requireCapability(req, reply, store, "query");
618
+ if (!resolved)
619
+ return reply;
620
+ const unsupported = unsupportedParam(req);
621
+ if (unsupported != null) {
622
+ return reply.code(400).send({
623
+ error: `${unsupported}: significance compares two time windows, not two segments. Use ` +
624
+ "'since'/'until' for the window under test and 'refSince'/'refUntil' for the one it " +
625
+ "is compared with " +
626
+ "(the previous equal window by default). Splitting a metric by a dimension value is " +
627
+ "not available yet, and answering a segment question with a window comparison would " +
628
+ "compare the wrong two things.",
629
+ metric: req.query.metric,
630
+ });
631
+ }
632
+ const metric = resolveSeriesMetric(req.query.metric);
633
+ if (isRejection(metric))
634
+ return reply.code(400).send(metric);
635
+ const bucket = req.query.bucket ?? "day";
636
+ const { range, reference } = resolveMoversWindows({
637
+ since: req.query.since,
638
+ until: req.query.until,
639
+ refSince: req.query.refSince,
640
+ refUntil: req.query.refUntil,
641
+ bucket,
642
+ now: Date.now(),
643
+ });
644
+ resolvedRanges.set(req, range);
645
+ // One read spanning both windows, split in TypeScript — the same trick
646
+ // `movers` uses, and for the same two reasons: half the queries, and both
647
+ // windows are guaranteed to have seen one snapshot of the data.
648
+ const span = spanningWindow(range, reference);
649
+ const read = (series) => store.metricBuckets(resolved.projectId, {
650
+ metric: metric.id,
651
+ series,
652
+ bucket,
653
+ since: span.since,
654
+ until: span.until,
655
+ scene: req.query.scene,
656
+ });
657
+ const rate = isDeclaredRate(metric);
658
+ const [valueRows, denominatorRows] = await Promise.all([
659
+ read(),
660
+ rate ? read("denominator") : Promise.resolve(null),
661
+ ]);
662
+ const split = partition(valueRows, range, reference);
663
+ const denominator = denominatorRows == null
664
+ ? undefined
665
+ : (() => {
666
+ const parts = partition(denominatorRows, range, reference);
667
+ return { current: parts.current, reference: parts.reference };
668
+ })();
669
+ const row = computeSignificance({
670
+ metric: metric.id,
671
+ scene: req.query.scene,
672
+ counting: isCountingMeasure(metric.id),
673
+ unit: unitOf(metric),
674
+ current: split.current,
675
+ reference: split.reference,
676
+ ...(denominator ? { denominator } : {}),
677
+ });
678
+ return [row];
679
+ });
680
+ /**
681
+ * Which scene is in trouble, and why. One row per scene, least healthy first,
682
+ * every factor carrying the metric id behind it.
683
+ */
684
+ r.get(SCENE_HEALTH.endpoint.path, {
685
+ schema: {
686
+ querystring: sceneHealthQueryParams,
687
+ response: { 200: rowsFor(SCENE_HEALTH), 400: badRequestResponse },
688
+ },
689
+ }, async (req, reply) => {
690
+ const resolved = await requireCapability(req, reply, store, "query");
691
+ if (!resolved)
692
+ return reply;
693
+ const bucket = req.query.bucket ?? "day";
694
+ const { range, baseline: baselineWindow } = resolveHealthWindows({
695
+ since: req.query.since,
696
+ until: req.query.until,
697
+ windowDays: req.query.window,
698
+ bucket,
699
+ now: Date.now(),
700
+ });
701
+ resolvedRanges.set(req, range);
702
+ // Which scenes. A named scene is scored on its own; otherwise the busiest
703
+ // scenes over the window, bounded — the fan-out is linear in this.
704
+ let scenes;
705
+ if (req.query.scene != null) {
706
+ scenes = [req.query.scene];
707
+ }
708
+ else {
709
+ const rows = await store.scenes(resolved.projectId, {
710
+ since: range.since,
711
+ until: range.until,
712
+ limit: HEALTH_MAX_SCENES,
713
+ });
714
+ scenes = rows
715
+ .map((row) => row.scene_id)
716
+ .filter((id) => id.length > 0)
717
+ .slice(0, req.query.limit ?? HEALTH_DEFAULT_SCENES);
718
+ // A project whose events carry no scene id is still a project: score it
719
+ // as a whole rather than answering with an empty list.
720
+ if (scenes.length === 0)
721
+ scenes = [""];
722
+ }
723
+ // One read per (factor, side, scene) over the scored window, plus one per
724
+ // (factor, side) for the project baseline — shared by every scene, so the
725
+ // cost is `factors x sides x (scenes + 1)` and not `x scenes x 2`.
726
+ const plan = [];
727
+ for (const factor of HEALTH_FACTORS) {
728
+ const sides = [
729
+ { side: "numerator", ...(factor.numerator ? { series: factor.numerator } : {}) },
730
+ ...(factor.denominator
731
+ ? [{ side: "denominator", series: factor.denominator }]
732
+ : []),
733
+ ];
734
+ for (const { side, series } of sides) {
735
+ plan.push({
736
+ scene: "",
737
+ factor: factor.id,
738
+ side,
739
+ metric: factor.metric,
740
+ series,
741
+ window: baselineWindow,
742
+ baseline: true,
743
+ });
744
+ for (const scene of scenes) {
745
+ plan.push({
746
+ scene,
747
+ factor: factor.id,
748
+ side,
749
+ metric: factor.metric,
750
+ series,
751
+ window: range,
752
+ baseline: false,
753
+ });
754
+ }
755
+ }
756
+ }
757
+ const results = await mapPooled(plan, BUCKET_READ_CONCURRENCY, (read) => store.metricBuckets(resolved.projectId, {
758
+ metric: read.metric,
759
+ series: read.series,
760
+ bucket,
761
+ since: read.window.since,
762
+ until: read.window.until,
763
+ // The baseline deliberately spans every scene: a factor is normalised
764
+ // against the project, not against the scene's own past.
765
+ scene: read.baseline || read.scene.length === 0 ? undefined : read.scene,
766
+ }));
767
+ const series = new Map();
768
+ plan.forEach((read, index) => series.set(readKey(read), results[index] ?? []));
769
+ const rows = scenes.map((scene) => {
770
+ const inputs = {};
771
+ for (const factor of HEALTH_FACTORS) {
772
+ const at = (side, baseline) => series.get(`${baseline ? "" : scene}|${factor.id}|${side}|${baseline ? "b" : "c"}`) ??
773
+ [];
774
+ inputs[factor.id] = {
775
+ current: {
776
+ numerator: at("numerator", false),
777
+ ...(factor.denominator ? { denominator: at("denominator", false) } : {}),
778
+ },
779
+ baseline: {
780
+ numerator: at("numerator", true),
781
+ ...(factor.denominator ? { denominator: at("denominator", true) } : {}),
782
+ },
783
+ };
784
+ }
785
+ return computeSceneHealth({
786
+ scene,
787
+ since: range.since,
788
+ until: range.until,
789
+ // Sessions started in the scene over the window: the `error_rate`
790
+ // factor's denominator is exactly that series, so the number is read
791
+ // off a scan already paid for.
792
+ sampleSize: Math.round(seriesTotal(series.get(`${scene}|error_rate|denominator|c`))),
793
+ inputs,
794
+ weights: req.query.weights,
795
+ });
796
+ });
797
+ return rankSceneHealth(rows);
798
+ });
799
+ };
800
+ /** The derived metrics this plugin serves, indexed by the path that serves them. */
801
+ const METRIC_BY_PATH = new Map([
802
+ [BASELINE.endpoint.path, BASELINE],
803
+ [MOVERS.endpoint.path, MOVERS],
804
+ // --- anomalies (#306) ---
805
+ [ANOMALIES.endpoint.path, ANOMALIES],
806
+ // --- significance / scene health (#307) ---
807
+ [SIGNIFICANCE.endpoint.path, SIGNIFICANCE],
808
+ [SCENE_HEALTH.endpoint.path, SCENE_HEALTH],
809
+ ]);
810
+ //# sourceMappingURL=insights.js.map