@uptimizr/collector-server 2.1.0 → 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.
- package/AGENTS.md +179 -6
- package/README.md +69 -0
- package/dist/__tests__/support/registryRequests.d.ts +2 -1
- package/dist/__tests__/support/registryRequests.d.ts.map +1 -1
- package/dist/__tests__/support/registryRequests.js +17 -1
- package/dist/__tests__/support/registryRequests.js.map +1 -1
- package/dist/__tests__/support/subscriptionFixtures.d.ts +63 -0
- package/dist/__tests__/support/subscriptionFixtures.d.ts.map +1 -0
- package/dist/__tests__/support/subscriptionFixtures.js +17 -0
- package/dist/__tests__/support/subscriptionFixtures.js.map +1 -0
- package/dist/agentReport.d.ts +256 -0
- package/dist/agentReport.d.ts.map +1 -0
- package/dist/agentReport.js +861 -0
- package/dist/agentReport.js.map +1 -0
- package/dist/app.d.ts +13 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +112 -5
- package/dist/app.js.map +1 -1
- package/dist/audit.d.ts +5 -1
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js +24 -4
- package/dist/audit.js.map +1 -1
- package/dist/auth.d.ts +41 -1
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +47 -0
- package/dist/auth.js.map +1 -1
- package/dist/cli.js +99 -2
- package/dist/cli.js.map +1 -1
- package/dist/clickhouseStore.d.ts.map +1 -1
- package/dist/clickhouseStore.js +44 -2
- package/dist/clickhouseStore.js.map +1 -1
- package/dist/config.d.ts +49 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +21 -0
- package/dist/config.js.map +1 -1
- package/dist/connectionLimiter.d.ts +23 -0
- package/dist/connectionLimiter.d.ts.map +1 -0
- package/dist/connectionLimiter.js +24 -0
- package/dist/connectionLimiter.js.map +1 -0
- package/dist/duckdbStore.d.ts.map +1 -1
- package/dist/duckdbStore.js +44 -1
- package/dist/duckdbStore.js.map +1 -1
- package/dist/internalDispatch.d.ts +36 -0
- package/dist/internalDispatch.d.ts.map +1 -0
- package/dist/internalDispatch.js +50 -0
- package/dist/internalDispatch.js.map +1 -0
- package/dist/memoryStore.d.ts.map +1 -1
- package/dist/memoryStore.js +314 -1
- package/dist/memoryStore.js.map +1 -1
- package/dist/mssqlStore.d.ts.map +1 -1
- package/dist/mssqlStore.js +44 -2
- package/dist/mssqlStore.js.map +1 -1
- package/dist/postgresStore.d.ts.map +1 -1
- package/dist/postgresStore.js +44 -2
- package/dist/postgresStore.js.map +1 -1
- package/dist/projectMetadata.d.ts +82 -0
- package/dist/projectMetadata.d.ts.map +1 -0
- package/dist/projectMetadata.js +50 -0
- package/dist/projectMetadata.js.map +1 -0
- package/dist/routes/context.d.ts +135 -0
- package/dist/routes/context.d.ts.map +1 -0
- package/dist/routes/context.js +306 -0
- package/dist/routes/context.js.map +1 -0
- package/dist/routes/insights.d.ts +36 -0
- package/dist/routes/insights.d.ts.map +1 -0
- package/dist/routes/insights.js +810 -0
- package/dist/routes/insights.js.map +1 -0
- package/dist/routes/live.d.ts +8 -0
- package/dist/routes/live.d.ts.map +1 -1
- package/dist/routes/live.js +8 -7
- package/dist/routes/live.js.map +1 -1
- package/dist/routes/mcp.d.ts +36 -0
- package/dist/routes/mcp.d.ts.map +1 -0
- package/dist/routes/mcp.js +262 -0
- package/dist/routes/mcp.js.map +1 -0
- package/dist/routes/meta.d.ts +16 -2
- package/dist/routes/meta.d.ts.map +1 -1
- package/dist/routes/meta.js +699 -10
- package/dist/routes/meta.js.map +1 -1
- package/dist/routes/metadata.d.ts +31 -0
- package/dist/routes/metadata.d.ts.map +1 -0
- package/dist/routes/metadata.js +163 -0
- package/dist/routes/metadata.js.map +1 -0
- package/dist/routes/narrative.d.ts +36 -0
- package/dist/routes/narrative.d.ts.map +1 -0
- package/dist/routes/narrative.js +95 -0
- package/dist/routes/narrative.js.map +1 -0
- package/dist/routes/panels.d.ts +44 -0
- package/dist/routes/panels.d.ts.map +1 -0
- package/dist/routes/panels.js +108 -0
- package/dist/routes/panels.js.map +1 -0
- package/dist/routes/query-dsl.d.ts +50 -0
- package/dist/routes/query-dsl.d.ts.map +1 -0
- package/dist/routes/query-dsl.js +359 -0
- package/dist/routes/query-dsl.js.map +1 -0
- package/dist/routes/query.d.ts +36 -0
- package/dist/routes/query.d.ts.map +1 -1
- package/dist/routes/query.js +101 -13
- package/dist/routes/query.js.map +1 -1
- package/dist/routes/subscriptions.d.ts +52 -0
- package/dist/routes/subscriptions.d.ts.map +1 -0
- package/dist/routes/subscriptions.js +290 -0
- package/dist/routes/subscriptions.js.map +1 -0
- package/dist/store.d.ts +113 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/subscriptions/cli.d.ts +36 -0
- package/dist/subscriptions/cli.d.ts.map +1 -0
- package/dist/subscriptions/cli.js +143 -0
- package/dist/subscriptions/cli.js.map +1 -0
- package/dist/subscriptions/delivery.d.ts +101 -0
- package/dist/subscriptions/delivery.d.ts.map +1 -0
- package/dist/subscriptions/delivery.js +165 -0
- package/dist/subscriptions/delivery.js.map +1 -0
- package/dist/subscriptions/evaluate.d.ts +119 -0
- package/dist/subscriptions/evaluate.d.ts.map +1 -0
- package/dist/subscriptions/evaluate.js +326 -0
- package/dist/subscriptions/evaluate.js.map +1 -0
- package/dist/subscriptions/scheduler.d.ts +97 -0
- package/dist/subscriptions/scheduler.d.ts.map +1 -0
- package/dist/subscriptions/scheduler.js +332 -0
- package/dist/subscriptions/scheduler.js.map +1 -0
- package/dist/subscriptions/stream.d.ts +40 -0
- package/dist/subscriptions/stream.d.ts.map +1 -0
- package/dist/subscriptions/stream.js +38 -0
- package/dist/subscriptions/stream.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +25 -0
- package/dist/version.js.map +1 -0
- package/dist/webhookSignature.d.ts +64 -0
- package/dist/webhookSignature.d.ts.map +1 -0
- package/dist/webhookSignature.js +91 -0
- package/dist/webhookSignature.js.map +1 -0
- package/llms.txt +38 -1
- 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
|