@uptimizr/db 2.0.1 → 2.1.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 (171) hide show
  1. package/AGENTS.md +228 -3
  2. package/README.md +13 -0
  3. package/dist/duckdb/migrations.d.ts.map +1 -1
  4. package/dist/duckdb/migrations.js +169 -0
  5. package/dist/duckdb/migrations.js.map +1 -1
  6. package/dist/duckdb/panelSpecs.d.ts +29 -0
  7. package/dist/duckdb/panelSpecs.d.ts.map +1 -0
  8. package/dist/duckdb/panelSpecs.js +104 -0
  9. package/dist/duckdb/panelSpecs.js.map +1 -0
  10. package/dist/duckdb/projectMetadata.d.ts +43 -0
  11. package/dist/duckdb/projectMetadata.d.ts.map +1 -0
  12. package/dist/duckdb/projectMetadata.js +222 -0
  13. package/dist/duckdb/projectMetadata.js.map +1 -0
  14. package/dist/duckdb/subscriptions.d.ts +45 -0
  15. package/dist/duckdb/subscriptions.d.ts.map +1 -0
  16. package/dist/duckdb/subscriptions.js +202 -0
  17. package/dist/duckdb/subscriptions.js.map +1 -0
  18. package/dist/index.d.ts +18 -4
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +48 -3
  21. package/dist/index.js.map +1 -1
  22. package/dist/insights/anomalies.d.ts +213 -0
  23. package/dist/insights/anomalies.d.ts.map +1 -0
  24. package/dist/insights/anomalies.js +340 -0
  25. package/dist/insights/anomalies.js.map +1 -0
  26. package/dist/insights/baseline.d.ts +51 -0
  27. package/dist/insights/baseline.d.ts.map +1 -0
  28. package/dist/insights/baseline.js +48 -0
  29. package/dist/insights/baseline.js.map +1 -0
  30. package/dist/insights/buckets.d.ts +114 -0
  31. package/dist/insights/buckets.d.ts.map +1 -0
  32. package/dist/insights/buckets.js +220 -0
  33. package/dist/insights/buckets.js.map +1 -0
  34. package/dist/insights/changepoint.d.ts +130 -0
  35. package/dist/insights/changepoint.d.ts.map +1 -0
  36. package/dist/insights/changepoint.js +209 -0
  37. package/dist/insights/changepoint.js.map +1 -0
  38. package/dist/insights/evaluate.d.ts +63 -0
  39. package/dist/insights/evaluate.d.ts.map +1 -0
  40. package/dist/insights/evaluate.js +158 -0
  41. package/dist/insights/evaluate.js.map +1 -0
  42. package/dist/insights/health.d.ts +219 -0
  43. package/dist/insights/health.d.ts.map +1 -0
  44. package/dist/insights/health.js +349 -0
  45. package/dist/insights/health.js.map +1 -0
  46. package/dist/insights/index.d.ts +45 -0
  47. package/dist/insights/index.d.ts.map +1 -0
  48. package/dist/insights/index.js +40 -0
  49. package/dist/insights/index.js.map +1 -0
  50. package/dist/insights/measures.d.ts +222 -0
  51. package/dist/insights/measures.d.ts.map +1 -0
  52. package/dist/insights/measures.js +431 -0
  53. package/dist/insights/measures.js.map +1 -0
  54. package/dist/insights/movers.d.ts +123 -0
  55. package/dist/insights/movers.d.ts.map +1 -0
  56. package/dist/insights/movers.js +166 -0
  57. package/dist/insights/movers.js.map +1 -0
  58. package/dist/insights/significance.d.ts +221 -0
  59. package/dist/insights/significance.d.ts.map +1 -0
  60. package/dist/insights/significance.js +531 -0
  61. package/dist/insights/significance.js.map +1 -0
  62. package/dist/insights/stats.d.ts +104 -0
  63. package/dist/insights/stats.d.ts.map +1 -0
  64. package/dist/insights/stats.js +181 -0
  65. package/dist/insights/stats.js.map +1 -0
  66. package/dist/insights/windows.d.ts +95 -0
  67. package/dist/insights/windows.d.ts.map +1 -0
  68. package/dist/insights/windows.js +97 -0
  69. package/dist/insights/windows.js.map +1 -0
  70. package/dist/metadata.d.ts +178 -1
  71. package/dist/metadata.d.ts.map +1 -1
  72. package/dist/metadata.js +80 -0
  73. package/dist/metadata.js.map +1 -1
  74. package/dist/narrative/build.d.ts +78 -0
  75. package/dist/narrative/build.d.ts.map +1 -0
  76. package/dist/narrative/build.js +437 -0
  77. package/dist/narrative/build.js.map +1 -0
  78. package/dist/narrative/index.d.ts +14 -0
  79. package/dist/narrative/index.d.ts.map +1 -0
  80. package/dist/narrative/index.js +13 -0
  81. package/dist/narrative/index.js.map +1 -0
  82. package/dist/narrative/text.d.ts +25 -0
  83. package/dist/narrative/text.d.ts.map +1 -0
  84. package/dist/narrative/text.js +41 -0
  85. package/dist/narrative/text.js.map +1 -0
  86. package/dist/parity/cases.d.ts +3 -0
  87. package/dist/parity/cases.d.ts.map +1 -1
  88. package/dist/parity/cases.js +387 -7
  89. package/dist/parity/cases.js.map +1 -1
  90. package/dist/parity/compare.d.ts +19 -0
  91. package/dist/parity/compare.d.ts.map +1 -1
  92. package/dist/parity/compare.js +19 -0
  93. package/dist/parity/compare.js.map +1 -1
  94. package/dist/parity/fixtures.d.ts.map +1 -1
  95. package/dist/parity/fixtures.js +25 -0
  96. package/dist/parity/fixtures.js.map +1 -1
  97. package/dist/query/aggregations.d.ts +46 -1
  98. package/dist/query/aggregations.d.ts.map +1 -1
  99. package/dist/query/aggregations.js +86 -0
  100. package/dist/query/aggregations.js.map +1 -1
  101. package/dist/query/clickhouseDialect.d.ts.map +1 -1
  102. package/dist/query/clickhouseDialect.js +5 -1
  103. package/dist/query/clickhouseDialect.js.map +1 -1
  104. package/dist/query/customEventVocabulary.d.ts +46 -0
  105. package/dist/query/customEventVocabulary.d.ts.map +1 -0
  106. package/dist/query/customEventVocabulary.js +127 -0
  107. package/dist/query/customEventVocabulary.js.map +1 -0
  108. package/dist/query/dsl/builders.d.ts +38 -0
  109. package/dist/query/dsl/builders.d.ts.map +1 -0
  110. package/dist/query/dsl/builders.js +35 -0
  111. package/dist/query/dsl/builders.js.map +1 -0
  112. package/dist/query/dsl/compare.d.ts +167 -0
  113. package/dist/query/dsl/compare.d.ts.map +1 -0
  114. package/dist/query/dsl/compare.js +358 -0
  115. package/dist/query/dsl/compare.js.map +1 -0
  116. package/dist/query/dsl/compile.d.ts +89 -0
  117. package/dist/query/dsl/compile.d.ts.map +1 -0
  118. package/dist/query/dsl/compile.js +174 -0
  119. package/dist/query/dsl/compile.js.map +1 -0
  120. package/dist/query/dsl/explain.d.ts +115 -0
  121. package/dist/query/dsl/explain.d.ts.map +1 -0
  122. package/dist/query/dsl/explain.js +143 -0
  123. package/dist/query/dsl/explain.js.map +1 -0
  124. package/dist/query/dsl/generic.d.ts +127 -0
  125. package/dist/query/dsl/generic.d.ts.map +1 -0
  126. package/dist/query/dsl/generic.js +323 -0
  127. package/dist/query/dsl/generic.js.map +1 -0
  128. package/dist/query/dsl/index.d.ts +30 -0
  129. package/dist/query/dsl/index.d.ts.map +1 -0
  130. package/dist/query/dsl/index.js +23 -0
  131. package/dist/query/dsl/index.js.map +1 -0
  132. package/dist/query/dsl/order.d.ts +41 -0
  133. package/dist/query/dsl/order.d.ts.map +1 -0
  134. package/dist/query/dsl/order.js +55 -0
  135. package/dist/query/dsl/order.js.map +1 -0
  136. package/dist/query/dsl/significance.d.ts +130 -0
  137. package/dist/query/dsl/significance.d.ts.map +1 -0
  138. package/dist/query/dsl/significance.js +224 -0
  139. package/dist/query/dsl/significance.js.map +1 -0
  140. package/dist/query/index.d.ts +2 -0
  141. package/dist/query/index.d.ts.map +1 -1
  142. package/dist/query/index.js +8 -0
  143. package/dist/query/index.js.map +1 -1
  144. package/dist/query/summary/index.d.ts +4 -2
  145. package/dist/query/summary/index.d.ts.map +1 -1
  146. package/dist/query/summary/index.js +2 -1
  147. package/dist/query/summary/index.js.map +1 -1
  148. package/dist/query/summary/labels.d.ts +101 -0
  149. package/dist/query/summary/labels.d.ts.map +1 -0
  150. package/dist/query/summary/labels.js +261 -0
  151. package/dist/query/summary/labels.js.map +1 -0
  152. package/dist/query/summary/reading.d.ts.map +1 -1
  153. package/dist/query/summary/reading.js +21 -2
  154. package/dist/query/summary/reading.js.map +1 -1
  155. package/dist/query/summary/schema.d.ts +250 -20
  156. package/dist/query/summary/schema.d.ts.map +1 -1
  157. package/dist/query/summary/schema.js +135 -123
  158. package/dist/query/summary/schema.js.map +1 -1
  159. package/dist/query/summary/summarize.d.ts.map +1 -1
  160. package/dist/query/summary/summarize.js +91 -5
  161. package/dist/query/summary/summarize.js.map +1 -1
  162. package/dist/query/summary/types.d.ts +77 -2
  163. package/dist/query/summary/types.d.ts.map +1 -1
  164. package/dist/query/types.d.ts +47 -1
  165. package/dist/query/types.d.ts.map +1 -1
  166. package/dist/subscriptions.d.ts +208 -0
  167. package/dist/subscriptions.d.ts.map +1 -0
  168. package/dist/subscriptions.js +153 -0
  169. package/dist/subscriptions.js.map +1 -0
  170. package/llms.txt +9 -0
  171. package/package.json +8 -8
@@ -0,0 +1,340 @@
1
+ /**
2
+ * **`anomalies`** — "when did this go wrong, and what inside it went wrong?"
3
+ * (ADR 0051 §4, design sketch §D row `anomalies`).
4
+ *
5
+ * `baseline` says what normal looks like and `movers` says that something
6
+ * changed between two windows a caller had to choose. Neither names **a
7
+ * bucket**. This primitive walks one metric's bucket series and returns the
8
+ * buckets that do not belong in it, each labelled with what kind of departure it
9
+ * is and — where the metric declares a dimension it can be split by — which
10
+ * value inside the metric accounts for the excess.
11
+ *
12
+ * Pure, like the rest of `src/insights/`: it takes the rows `buildMetricBuckets`
13
+ * produced and returns rows. No store, no request, no dialect, so DuckDB,
14
+ * ClickHouse, Postgres and SQL Server cannot disagree about whether a Tuesday
15
+ * was abnormal.
16
+ *
17
+ * ## Three kinds, two detectors
18
+ *
19
+ * | `kind` | Detector | The shape it catches |
20
+ * | ------- | -------- | -------------------- |
21
+ * | `spike` | robust z vs the trailing window | one bucket far above what came before |
22
+ * | `drop` | the same, below | one bucket far below it |
23
+ * | `shift` | CUSUM over the whole series | a level that moved and *stayed* moved |
24
+ *
25
+ * The third is not a refinement of the first two, it is the case they
26
+ * structurally cannot see: a release that costs 6 FPS every day from Tuesday
27
+ * onward is never more than a MAD or two off on any single day, so no point
28
+ * detector will ever flag it, and yet it is the finding that matters most. Both
29
+ * detectors are driven by the *same* `sensitivity` dial and measured in the same
30
+ * unit (MADs of the series' own spread), so the two halves of a result are
31
+ * comparable rather than two unrelated alarms sharing an endpoint.
32
+ *
33
+ * ## The denominator: a scaled MAD, with a floor
34
+ *
35
+ * `sensitivity` is a **threshold**, not a ranking key, so the unit it counts has
36
+ * to be calibrated or the default is meaningless. The MAD of normally
37
+ * distributed data is about 0.674σ, so a raw `|z| > 3` on MADs is really "beyond
38
+ * 2σ" — a two-sided tail of roughly 4% *per bucket*, which over a 28-day window
39
+ * reports one or two perfectly ordinary days every time it is called. That is
40
+ * exactly the experience that makes people switch anomaly detection off.
41
+ *
42
+ * The spread is therefore `max(MAD × {@link MAD_TO_SIGMA}, 1% × |median|)`: the
43
+ * MAD rescaled into standard deviations, so `sensitivity: 3` means what a reader
44
+ * assumes it means, and then floored the way `movers` floors it. The floor is
45
+ * what stops a metric that sat at exactly 60 FPS for a fortnight — trailing MAD
46
+ * of 0 — from reporting a 59.9 FPS day as an infinite departure.
47
+ *
48
+ * `movers` reports the **unscaled** ratio, because there it ranks rather than
49
+ * thresholds and a constant factor changes no ordering. The two `z` columns
50
+ * therefore differ by that constant, which the registry entries both say.
51
+ *
52
+ * ## Cost is bounded, and declared
53
+ *
54
+ * The detection itself is one bucket read — the same grouped scan every insight
55
+ * makes. Attribution costs **at most {@link ANOMALY_MAX_CONTRIBUTOR_SCANS} extra
56
+ * grouped scans per request**, no matter how many buckets were anomalous:
57
+ * adjacent findings are merged into windows, the windows are ranked by how
58
+ * extreme they are, and only the top few are re-read. A metric with no declared
59
+ * split dimension pays for none.
60
+ */
61
+ import { MIN_SPREAD_FRACTION } from "./movers.js";
62
+ import { MAD_TO_SIGMA, median, medianAbsoluteDeviation, round } from "./stats.js";
63
+ import { cusumChangePoints, rollingRobustStats } from "./changepoint.js";
64
+ import { BUCKET_SECONDS, bucketMeasureFor, } from "./measures.js";
65
+ /** Default `sensitivity`: a bucket must be more than three MADs out. */
66
+ export const DEFAULT_ANOMALY_SENSITIVITY = 3;
67
+ /** Smallest `sensitivity` a caller may ask for. */
68
+ export const MIN_ANOMALY_SENSITIVITY = 1;
69
+ /**
70
+ * Largest `sensitivity` a caller may ask for.
71
+ *
72
+ * Bounded at both ends on purpose. Below 1 every bucket is an anomaly and the
73
+ * answer is noise; above 10 nothing short of a total outage is, and a caller who
74
+ * wants that has asked the wrong question — they want a threshold alert, which
75
+ * is stage 3's `subscriptions`, not a detector.
76
+ */
77
+ export const MAX_ANOMALY_SENSITIVITY = 10;
78
+ /**
79
+ * How many buckets of history each bucket is judged against, per grain.
80
+ *
81
+ * A fortnight at day grain: long enough for the median to be stable, short
82
+ * enough that a month-old regime does not keep flagging today. A week at hour
83
+ * grain, which is 168 buckets — the shortest window that contains every hour of
84
+ * every weekday, so a quiet Sunday 03:00 is compared against a distribution that
85
+ * has seen quiet nights rather than against Tuesday lunchtime.
86
+ */
87
+ export const ANOMALY_TRAILING_BUCKETS = {
88
+ day: 14,
89
+ hour: 168,
90
+ };
91
+ /**
92
+ * Trailing values a bucket needs before it can be judged at all.
93
+ *
94
+ * Under five, the median and the MAD are describing the accident of which four
95
+ * buckets happened to exist. Buckets with less history than this are reported as
96
+ * nothing rather than as findings — which is why a brand-new project reads
97
+ * "no anomalies" instead of "every day is an anomaly".
98
+ */
99
+ export const ANOMALY_MIN_TRAILING = 5;
100
+ /**
101
+ * The hard cap on extra grouped scans spent on attribution, per request.
102
+ *
103
+ * Contributor attribution re-reads the series grouped by one dimension over an
104
+ * anomalous window. Adjacent anomalous buckets are one window, and only the
105
+ * three most extreme windows are re-read, so the endpoint's cost is
106
+ * `1 + min(windows, 3)` grouped scans whatever the data looks like. A caller who
107
+ * wants attribution on a quieter finding narrows `since`/`until` around it; that
108
+ * is cheaper for the store than lifting the cap would be.
109
+ */
110
+ export const ANOMALY_MAX_CONTRIBUTOR_SCANS = 3;
111
+ /** Decimals an anomaly's numbers are rounded to before they leave the API. */
112
+ export const ANOMALY_PRECISION = 6;
113
+ /** Clamp a caller's `sensitivity` into the supported range. */
114
+ export function clampSensitivity(value) {
115
+ if (value == null || !Number.isFinite(value))
116
+ return DEFAULT_ANOMALY_SENSITIVITY;
117
+ return Math.min(MAX_ANOMALY_SENSITIVITY, Math.max(MIN_ANOMALY_SENSITIVITY, value));
118
+ }
119
+ /**
120
+ * The dimension `anomalies` would attribute this metric's excess to, or `null`.
121
+ *
122
+ * `null` for a metric that declares none, and also for a `scene`-split metric on
123
+ * a request that is already scoped to one scene: the split would return a single
124
+ * value with a share of 1, which is not an explanation, and the scan that
125
+ * produced it would be wasted.
126
+ */
127
+ export function contributorDimensionFor(metric, opts = {}) {
128
+ const splitBy = bucketMeasureFor(metric)?.splitBy;
129
+ if (splitBy == null)
130
+ return null;
131
+ if (splitBy === "scene" && opts.scene != null && opts.scene.length > 0)
132
+ return null;
133
+ return splitBy;
134
+ }
135
+ /**
136
+ * The spread a departure is measured in: the MAD rescaled to a standard
137
+ * deviation ({@link MAD_TO_SIGMA}), floored at {@link MIN_SPREAD_FRACTION} of the
138
+ * level. `null` when there is no spread to speak of.
139
+ *
140
+ * The rescaling is what makes `sensitivity` a calibrated dial (see the module
141
+ * doc); the floor is `movers`' own, so a flat window behaves identically in both
142
+ * primitives.
143
+ */
144
+ function flooredSpread(centre, mad) {
145
+ if (centre == null || mad == null || !Number.isFinite(mad))
146
+ return null;
147
+ return Math.max(mad * MAD_TO_SIGMA, Math.abs(centre) * MIN_SPREAD_FRACTION);
148
+ }
149
+ /** A finite, bucket-ordered series from the store's rows. */
150
+ function seriesOf(rows) {
151
+ return [...rows]
152
+ .filter((row) => row.value != null && Number.isFinite(row.value))
153
+ .sort((a, b) => a.bucket - b.bucket)
154
+ .map((row) => ({
155
+ bucket: row.bucket,
156
+ value: row.value,
157
+ sample: Number.isFinite(row.sample_size) ? row.sample_size : 0,
158
+ }));
159
+ }
160
+ /**
161
+ * Score one metric's bucket series and return its anomalous buckets, ordered by
162
+ * bucket, `contributor` still `null`.
163
+ *
164
+ * Point findings come first in time order, then the change-points, and a bucket
165
+ * that is both a spike and the start of a shift is reported twice — deliberately:
166
+ * "Tuesday was the worst day" and "Tuesday is when it stopped recovering" are two
167
+ * different facts, and collapsing them loses the second one.
168
+ */
169
+ export function detectAnomalies(metric, scene, rows, opts) {
170
+ const series = seriesOf(rows);
171
+ const sensitivity = clampSensitivity(opts.sensitivity);
172
+ const out = [];
173
+ if (series.length === 0)
174
+ return out;
175
+ const values = series.map((point) => point.value);
176
+ // --- point detection: each bucket against the ones just before it ---------
177
+ const trailing = rollingRobustStats(values, ANOMALY_TRAILING_BUCKETS[opts.bucket], ANOMALY_MIN_TRAILING);
178
+ for (const [index, point] of series.entries()) {
179
+ const stats = trailing[index];
180
+ if (stats == null || stats.median == null)
181
+ continue;
182
+ // The same floored spread `movers` divides by, so a `z` of 4 means the same
183
+ // thing in both primitives — and taken from the trailing statistics already
184
+ // computed, so scoring a bucket stays O(1) on top of the rolling pass.
185
+ const spread = flooredSpread(stats.median, stats.mad);
186
+ if (spread == null || !(spread > 0))
187
+ continue;
188
+ const z = (point.value - stats.median) / spread;
189
+ if (Math.abs(z) <= sensitivity)
190
+ continue;
191
+ out.push({
192
+ metric,
193
+ scene: scene ?? "",
194
+ bucketStart: point.bucket,
195
+ value: round(point.value, ANOMALY_PRECISION),
196
+ expected: round(stats.median, ANOMALY_PRECISION),
197
+ z: round(z, ANOMALY_PRECISION),
198
+ kind: z > 0 ? "spike" : "drop",
199
+ contributor: null,
200
+ sampleSize: point.sample,
201
+ });
202
+ }
203
+ // --- change-points: a level that moved and stayed moved ------------------
204
+ // The spread is the whole series', so a shift's z is in the same MAD unit as a
205
+ // spike's; the *reference level* each segment is judged against is the level
206
+ // it started at, which the segmenting in `cusumChangePoints` re-estimates.
207
+ const spread = flooredSpread(median(values), medianAbsoluteDeviation(values));
208
+ for (const change of cusumChangePoints(values, spread, sensitivity, ANOMALY_TRAILING_BUCKETS[opts.bucket])) {
209
+ const point = series[change.index];
210
+ if (point == null)
211
+ continue;
212
+ out.push(shiftRow(metric, scene, point, change, spread));
213
+ }
214
+ return out.sort((a, b) => a.bucketStart - b.bucketStart || kindOrder(a.kind) - kindOrder(b.kind));
215
+ }
216
+ /** Point findings before the change-point that starts at the same bucket. */
217
+ function kindOrder(kind) {
218
+ return kind === "shift" ? 1 : 0;
219
+ }
220
+ /** One `shift` row: the change-point bucket, carrying the pre and post levels. */
221
+ function shiftRow(metric, scene, point, change, spread) {
222
+ const delta = change.after != null && change.before != null ? change.after - change.before : null;
223
+ return {
224
+ metric,
225
+ scene: scene ?? "",
226
+ bucketStart: point.bucket,
227
+ value: round(change.after, ANOMALY_PRECISION),
228
+ expected: round(change.before, ANOMALY_PRECISION),
229
+ // The size of the *level change*, in the same MAD unit as a spike's z — not
230
+ // the CUSUM statistic, which is an accumulation and would not be comparable.
231
+ z: delta == null || spread == null || !(spread > 0)
232
+ ? null
233
+ : round(delta / spread, ANOMALY_PRECISION),
234
+ kind: "shift",
235
+ contributor: null,
236
+ sampleSize: point.sample,
237
+ };
238
+ }
239
+ /**
240
+ * Merge anomalous buckets into the windows a contributor scan has to cover, most
241
+ * extreme first, capped at {@link ANOMALY_MAX_CONTRIBUTOR_SCANS}.
242
+ *
243
+ * Adjacent findings — a three-day outage is three rows — share one window and
244
+ * therefore one scan. Each window is widened backwards by the trailing length so
245
+ * the same scan also carries the history each split value's own "expected" is
246
+ * computed from; without that the attribution would compare a value against
247
+ * nothing and name whichever dimension happens to be biggest.
248
+ */
249
+ export function contributorWindows(rows, opts) {
250
+ const width = BUCKET_SECONDS[opts.bucket] * 1000;
251
+ const trailing = ANOMALY_TRAILING_BUCKETS[opts.bucket] * width;
252
+ const ordered = [...rows].sort((a, b) => a.bucketStart - b.bucketStart);
253
+ const groups = [];
254
+ for (const row of ordered) {
255
+ const score = row.z == null ? 0 : Math.abs(row.z);
256
+ const last = groups[groups.length - 1];
257
+ // A `shift` is a statement about everything from its bucket onward, so its
258
+ // window runs to the end of the series; a point finding is one bucket.
259
+ const until = row.kind === "shift" ? opts.seriesUntil : row.bucketStart + width;
260
+ if (last != null && row.bucketStart <= last.until) {
261
+ last.until = Math.max(last.until, until);
262
+ last.score = Math.max(last.score, score);
263
+ }
264
+ else {
265
+ groups.push({ since: row.bucketStart, until, score });
266
+ }
267
+ }
268
+ return groups
269
+ .sort((a, b) => b.score - a.score || a.since - b.since)
270
+ .slice(0, ANOMALY_MAX_CONTRIBUTOR_SCANS)
271
+ .map((group) => ({ since: group.since - trailing, until: group.until }))
272
+ .sort((a, b) => a.since - b.since);
273
+ }
274
+ /** Whether an anomalous bucket falls inside a contributor window. */
275
+ export function inContributorWindow(row, window) {
276
+ return row.bucketStart >= window.since && row.bucketStart < window.until;
277
+ }
278
+ /**
279
+ * Name the dimension value that accounts for one row's excess.
280
+ *
281
+ * `splitRows` are the same measure re-read grouped by `dimension` over the row's
282
+ * contributor window. For each value the excess is `observed − prior`, where
283
+ * `prior` is that value's median over the buckets *before* the finding and
284
+ * `observed` is its value in the anomalous bucket (or, for a `shift`, its median
285
+ * from the change-point onward — the same before/after comparison the row
286
+ * itself reports, done one dimension value at a time).
287
+ *
288
+ * The winner is the largest excess **with the same sign as the row's own**: a
289
+ * spike is explained by what went up, and a value that happened to fall during
290
+ * an error spike is not a contributor to it. `share` is that value's fraction of
291
+ * the summed same-signed excess, so the shares of all contributing values add to
292
+ * 1 and a reported share of 0.9 means what a reader thinks it means.
293
+ *
294
+ * Returns `null` when nothing can be named: no split rows, or an excess of zero,
295
+ * or every split value moving against the finding.
296
+ */
297
+ export function attributeContributor(row, dimension, splitRows) {
298
+ if (row.value == null || row.expected == null)
299
+ return null;
300
+ const direction = Math.sign(row.value - row.expected);
301
+ if (direction === 0)
302
+ return null;
303
+ const byValue = new Map();
304
+ for (const split of splitRows) {
305
+ if (split.value == null || !Number.isFinite(split.value))
306
+ continue;
307
+ const key = split.dimension_value ?? "";
308
+ const list = byValue.get(key);
309
+ if (list == null)
310
+ byValue.set(key, [{ bucket: split.bucket, value: split.value }]);
311
+ else
312
+ list.push({ bucket: split.bucket, value: split.value });
313
+ }
314
+ if (byValue.size === 0)
315
+ return null;
316
+ const excesses = [];
317
+ for (const [key, points] of byValue) {
318
+ const prior = median(points.filter((p) => p.bucket < row.bucketStart).map((p) => p.value)) ?? 0;
319
+ const observed = row.kind === "shift"
320
+ ? median(points.filter((p) => p.bucket >= row.bucketStart).map((p) => p.value))
321
+ : (points.find((p) => p.bucket === row.bucketStart)?.value ?? null);
322
+ // A split value that vanished in the anomalous bucket contributes its own
323
+ // absence: for a drop that is the whole explanation.
324
+ const excess = (observed ?? 0) - prior;
325
+ if (Math.sign(excess) === direction)
326
+ excesses.push({ value: key, excess });
327
+ }
328
+ if (excesses.length === 0)
329
+ return null;
330
+ let total = 0;
331
+ for (const entry of excesses)
332
+ total += entry.excess;
333
+ const winner = excesses.reduce((best, entry) => Math.abs(entry.excess) > Math.abs(best.excess) ? entry : best);
334
+ return {
335
+ dimension,
336
+ value: winner.value,
337
+ share: total === 0 ? null : round(winner.excess / total, ANOMALY_PRECISION),
338
+ };
339
+ }
340
+ //# sourceMappingURL=anomalies.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"anomalies.js","sourceRoot":"","sources":["../../src/insights/anomalies.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,51 @@
1
+ /**
2
+ * **`baseline`** — "what is normal for this scene?" (ADR 0051 §4, sketch §D).
3
+ *
4
+ * Reduces a metric's per-bucket series to the handful of numbers that let a
5
+ * reader (or an agent) judge a single later observation without re-deriving the
6
+ * distribution: where the series sits (`mean`, `median`), how much it normally
7
+ * moves (`mad`), what its ordinary range looks like (`p10`, `p90`), and whether
8
+ * it is drifting (`slope`).
9
+ *
10
+ * Pure: it takes the rows `buildMetricBuckets` produced and returns one row. It
11
+ * never sees a store, a request or a dialect, so the same series produces the
12
+ * same baseline on every engine — which is the point of computing statistics in
13
+ * TypeScript rather than in five dialects' SQL.
14
+ */
15
+ import type { MetricBucketRow } from "./buckets.js";
16
+ /**
17
+ * What `baseline` reports. One row per request.
18
+ *
19
+ * Every statistic is nullable and `null` means *"not defined for this series"* —
20
+ * an empty window, or fewer than two buckets for a slope. A brand-new project
21
+ * reads `null`, never `0`.
22
+ */
23
+ export interface BaselineRow {
24
+ /** The registry metric the series is of. */
25
+ metric: string;
26
+ /** The scene it was scoped to; `''` when the baseline spans every scene. */
27
+ scene: string;
28
+ /** How many buckets carried a value. */
29
+ buckets: number;
30
+ mean: number | null;
31
+ median: number | null;
32
+ /** Median absolute deviation — the typical bucket-to-bucket swing. */
33
+ mad: number | null;
34
+ p10: number | null;
35
+ p90: number | null;
36
+ /** Least-squares slope against the bucket index: change per bucket. */
37
+ slope: number | null;
38
+ /** Events (or sessions, for a session-valued metric) behind the whole series. */
39
+ sampleSize: number;
40
+ }
41
+ /** How many decimals a baseline statistic is rounded to before it leaves the API. */
42
+ export declare const BASELINE_PRECISION = 6;
43
+ /**
44
+ * Reduce a bucket series to its baseline.
45
+ *
46
+ * Buckets whose `value` is `null` (an aggregate over no rows) are excluded from
47
+ * every statistic but still counted in `sampleSize`, because their events did
48
+ * happen — they simply did not produce a value for this measure.
49
+ */
50
+ export declare function computeBaseline(metric: string, scene: string | undefined, rows: readonly MetricBucketRow[]): BaselineRow;
51
+ //# sourceMappingURL=baseline.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"baseline.d.ts","sourceRoot":"","sources":["../../src/insights/baseline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAUH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAEpD;;;;;;GAMG;AACH,MAAM,WAAW,WAAW;IAC1B,4CAA4C;IAC5C,MAAM,EAAE,MAAM,CAAC;IACf,4EAA4E;IAC5E,KAAK,EAAE,MAAM,CAAC;IACd,wCAAwC;IACxC,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,sEAAsE;IACtE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,uEAAuE;IACvE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,iFAAiF;IACjF,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,qFAAqF;AACrF,eAAO,MAAM,kBAAkB,IAAI,CAAC;AAEpC;;;;;;GAMG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,GAAG,SAAS,EACzB,IAAI,EAAE,SAAS,eAAe,EAAE,GAC/B,WAAW,CAsBb"}
@@ -0,0 +1,48 @@
1
+ /**
2
+ * **`baseline`** — "what is normal for this scene?" (ADR 0051 §4, sketch §D).
3
+ *
4
+ * Reduces a metric's per-bucket series to the handful of numbers that let a
5
+ * reader (or an agent) judge a single later observation without re-deriving the
6
+ * distribution: where the series sits (`mean`, `median`), how much it normally
7
+ * moves (`mad`), what its ordinary range looks like (`p10`, `p90`), and whether
8
+ * it is drifting (`slope`).
9
+ *
10
+ * Pure: it takes the rows `buildMetricBuckets` produced and returns one row. It
11
+ * never sees a store, a request or a dialect, so the same series produces the
12
+ * same baseline on every engine — which is the point of computing statistics in
13
+ * TypeScript rather than in five dialects' SQL.
14
+ */
15
+ import { leastSquaresSlope, mean, median, medianAbsoluteDeviation, quantile, round, } from "./stats.js";
16
+ /** How many decimals a baseline statistic is rounded to before it leaves the API. */
17
+ export const BASELINE_PRECISION = 6;
18
+ /**
19
+ * Reduce a bucket series to its baseline.
20
+ *
21
+ * Buckets whose `value` is `null` (an aggregate over no rows) are excluded from
22
+ * every statistic but still counted in `sampleSize`, because their events did
23
+ * happen — they simply did not produce a value for this measure.
24
+ */
25
+ export function computeBaseline(metric, scene, rows) {
26
+ const values = [];
27
+ let sampleSize = 0;
28
+ // Ordered by bucket, so the slope is a slope against *time* rather than
29
+ // against whatever order the driver happened to return rows in.
30
+ for (const row of [...rows].sort((a, b) => a.bucket - b.bucket)) {
31
+ sampleSize += Number.isFinite(row.sample_size) ? row.sample_size : 0;
32
+ if (row.value != null && Number.isFinite(row.value))
33
+ values.push(row.value);
34
+ }
35
+ return {
36
+ metric,
37
+ scene: scene ?? "",
38
+ buckets: values.length,
39
+ mean: round(mean(values), BASELINE_PRECISION),
40
+ median: round(median(values), BASELINE_PRECISION),
41
+ mad: round(medianAbsoluteDeviation(values), BASELINE_PRECISION),
42
+ p10: round(quantile(values, 0.1), BASELINE_PRECISION),
43
+ p90: round(quantile(values, 0.9), BASELINE_PRECISION),
44
+ slope: round(leastSquaresSlope(values), BASELINE_PRECISION),
45
+ sampleSize,
46
+ };
47
+ }
48
+ //# sourceMappingURL=baseline.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"baseline.js","sourceRoot":"","sources":["../../src/insights/baseline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EACL,iBAAiB,EACjB,IAAI,EACJ,MAAM,EACN,uBAAuB,EACvB,QAAQ,EACR,KAAK,GACN,MAAM,YAAY,CAAC;AA6BpB,qFAAqF;AACrF,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,CAAC;AAEpC;;;;;;GAMG;AACH,MAAM,UAAU,eAAe,CAC7B,MAAc,EACd,KAAyB,EACzB,IAAgC;IAEhC,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,IAAI,UAAU,GAAG,CAAC,CAAC;IACnB,wEAAwE;IACxE,gEAAgE;IAChE,KAAK,MAAM,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC;QAChE,UAAU,IAAI,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC;QACrE,IAAI,GAAG,CAAC,KAAK,IAAI,IAAI,IAAI,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC;YAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;IAC9E,CAAC;IAED,OAAO;QACL,MAAM;QACN,KAAK,EAAE,KAAK,IAAI,EAAE;QAClB,OAAO,EAAE,MAAM,CAAC,MAAM;QACtB,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,kBAAkB,CAAC;QAC7C,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,kBAAkB,CAAC;QACjD,GAAG,EAAE,KAAK,CAAC,uBAAuB,CAAC,MAAM,CAAC,EAAE,kBAAkB,CAAC;QAC/D,GAAG,EAAE,KAAK,CAAC,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,kBAAkB,CAAC;QACrD,GAAG,EAAE,KAAK,CAAC,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,kBAAkB,CAAC;QACrD,KAAK,EAAE,KAAK,CAAC,iBAAiB,CAAC,MAAM,CAAC,EAAE,kBAAkB,CAAC;QAC3D,UAAU;KACX,CAAC;AACJ,CAAC"}
@@ -0,0 +1,114 @@
1
+ /**
2
+ * **The one generic bucket query** behind both insight primitives
3
+ * (ADR 0051 §4, design sketch §D).
4
+ *
5
+ * `buildMetricBuckets` renders a {@link BucketMeasure} — the declarative
6
+ * description of how a comparable metric's primary column is reproduced per
7
+ * time bucket, from `measures.ts` — into a single grouped scan of `events`:
8
+ *
9
+ * ```sql
10
+ * SELECT <time bucket> AS bucket, <aggregate> AS value, <denominator> AS sample_size
11
+ * FROM events
12
+ * WHERE project_id = ? AND event_type IN (…) AND …
13
+ * GROUP BY bucket ORDER BY bucket ASC
14
+ * ```
15
+ *
16
+ * It is authored against the {@link Dialect} interface like every aggregation in
17
+ * `query/aggregations.ts`, and parity-tested on all four engines through
18
+ * `src/parity/cases.ts`. It deliberately does **not** live in
19
+ * `query/aggregations.ts`: the `build*` exports of that module are the registry's
20
+ * closed list of *metrics*, each of which must have its own registry entry and
21
+ * endpoint. This is not a metric — it is the shared input of two, and has no
22
+ * endpoint, no row schema and no tool of its own. Keeping it here keeps the
23
+ * registry's coverage invariant meaningful.
24
+ *
25
+ * No statistic is computed in SQL. The query returns raw per-bucket values and
26
+ * everything else — mean, median, MAD, quantiles, slope, robust z — is pure
27
+ * TypeScript in `stats.ts`, so no two engines can disagree about an insight.
28
+ */
29
+ import type { Dialect } from "../query/dialect.js";
30
+ import type { QuerySpec, RangeOptions, SceneOptions } from "../query/types.js";
31
+ import { type BucketGrain, type BucketSplitDimension, type BucketVariant } from "./measures.js";
32
+ /** What to bucket, over what window, for whom. */
33
+ export interface MetricBucketOptions extends RangeOptions, SceneOptions {
34
+ /** The registry metric whose primary column the series reproduces. */
35
+ metric: string;
36
+ /** Time grain; defaults to `day`. */
37
+ bucket?: BucketGrain;
38
+ /**
39
+ * Split the series by one promoted dimension — the measure's own
40
+ * {@link BucketMeasure.splitBy}, never a caller-supplied column.
41
+ *
42
+ * With it, each row additionally carries `dimension_value` and the grouped
43
+ * scan answers "which mesh / channel / source accounts for this bucket?" in
44
+ * one extra read rather than one read per candidate value. Without it the
45
+ * query, its rows and its cost are exactly what they were before (#305).
46
+ */
47
+ groupBy?: BucketSplitDimension;
48
+ /**
49
+ * Read one of the metric’s **named auxiliary series** instead of its headline
50
+ * one (the rate denominator, an FPS tail, … — see `measures.ts`, #307).
51
+ *
52
+ * Never caller input: `significance` and `scene_health` set it from the
53
+ * registry and from their own fixed factor catalog respectively, so the value
54
+ * is always one of a compile-time union.
55
+ *
56
+ * Named `series` rather than `variant` because `variant` is already an
57
+ * aggregation option elsewhere (the variant-leaderboard's custom-event
58
+ * predicate), and the store-parity harness builds every aggregation from one
59
+ * shared option bag: two unrelated meanings under one key would silently
60
+ * cross over.
61
+ */
62
+ series?: BucketVariant;
63
+ }
64
+ /**
65
+ * One bucket of a metric's series, as the store returns it.
66
+ *
67
+ * `value` is nullable because an aggregate over no matching rows is SQL-`NULL`
68
+ * (a bucket in which a `quantile` had nothing to rank), and `null` must reach
69
+ * the statistics as "no observation" rather than as `0`. Buckets with no
70
+ * matching events at all are simply absent — the series is sparse, and
71
+ * `baseline`/`movers` treat absence as absence.
72
+ */
73
+ export interface MetricBucketRow {
74
+ /** Bucket start, epoch milliseconds (UTC, aligned to the grain). */
75
+ bucket: number;
76
+ /** The metric's primary column for this bucket, or `null` when undefined. */
77
+ value: number | null;
78
+ /** The denominator behind `value` — what `minSample` is compared against. */
79
+ sample_size: number;
80
+ /**
81
+ * The split dimension's value for this row, present only on a grouped read
82
+ * (`groupBy`). `''` is the store's "unknown"/unattributed, exactly as it is
83
+ * in every other aggregation.
84
+ */
85
+ dimension_value?: string;
86
+ }
87
+ /**
88
+ * Build the per-bucket series of a metric's comparable primary column.
89
+ *
90
+ * Throws when the metric has no portable bucket form — the route validates
91
+ * against `BUCKETABLE_METRIC_IDS` at the edge and answers `400`, so reaching
92
+ * this is a programming error rather than bad input.
93
+ */
94
+ export declare function buildMetricBuckets(projectId: string, opts: MetricBucketOptions, d: Dialect): QuerySpec;
95
+ /**
96
+ * Total order over bucket rows: by bucket, then by the split value on a grouped
97
+ * read (#306).
98
+ *
99
+ * Compared by code point rather than `localeCompare`, so the order is a property
100
+ * of the data instead of the ICU build the collector happens to run on — the same
101
+ * reason every statistic in this directory is computed in TypeScript.
102
+ */
103
+ export declare function byBucketThenDimension(a: MetricBucketRow, b: MetricBucketRow): number;
104
+ /**
105
+ * Normalise driver output into a {@link MetricBucketRow}.
106
+ *
107
+ * `buildMetricBuckets` produces no registry metric, so it carries no
108
+ * `QuerySpec.metric` tag and the store-edge coercion (ADR 0051 §2) has no row
109
+ * schema to coerce it against. ClickHouse string-encodes 64-bit counts over
110
+ * HTTP, so the numbers are parsed here instead — the one place every store's
111
+ * bucket read passes through.
112
+ */
113
+ export declare function toMetricBucketRows(rows: readonly Record<string, unknown>[]): MetricBucketRow[];
114
+ //# sourceMappingURL=buckets.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"buckets.d.ts","sourceRoot":"","sources":["../../src/insights/buckets.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAEnD,OAAO,KAAK,EAAE,SAAS,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAC/E,OAAO,EAKL,KAAK,WAAW,EAGhB,KAAK,oBAAoB,EAEzB,KAAK,aAAa,EACnB,MAAM,eAAe,CAAC;AAEvB,kDAAkD;AAClD,MAAM,WAAW,mBAAoB,SAAQ,YAAY,EAAE,YAAY;IACrE,sEAAsE;IACtE,MAAM,EAAE,MAAM,CAAC;IACf,qCAAqC;IACrC,MAAM,CAAC,EAAE,WAAW,CAAC;IAErB;;;;;;;;OAQG;IACH,OAAO,CAAC,EAAE,oBAAoB,CAAC;IAE/B;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,aAAa,CAAC;CACxB;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,eAAe;IAC9B,oEAAoE;IACpE,MAAM,EAAE,MAAM,CAAC;IACf,6EAA6E;IAC7E,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,6EAA6E;IAC7E,WAAW,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAkGD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,mBAAmB,EACzB,CAAC,EAAE,OAAO,GACT,SAAS,CA0CX;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,EAAE,eAAe,EAAE,CAAC,EAAE,eAAe,GAAG,MAAM,CAKpF;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,eAAe,EAAE,CAmB9F"}