@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,222 @@
1
+ /**
2
+ * **Portable bucket measures** — the input side of the insight primitives
3
+ * (ADR 0051 §4, design sketch §D).
4
+ *
5
+ * `baseline` and `movers` both answer questions about *one number moving over
6
+ * time*: "what is normal for this scene", "what changed since last week". Both
7
+ * therefore need the same thing from the store — a per-bucket series of a
8
+ * comparable metric's `comparable.primary` column — and neither may compute
9
+ * statistics in SQL, because five dialects' `quantile`, `median` and
10
+ * `stddev` do not agree and the answer must not depend on which engine a
11
+ * self-hoster chose.
12
+ *
13
+ * This module is the declaration that makes that series portable. For each
14
+ * comparable registry metric it records **how to reproduce that metric's
15
+ * primary column per time bucket** as a single grouped scan of `events`:
16
+ *
17
+ * - which capture channels the metric reads (`eventTypes`);
18
+ * - a closed vocabulary of extra predicates (`where`) — a promoted-column
19
+ * equality or set membership, or a geometry-arity guard. Never free SQL, so
20
+ * nothing here can become an injection surface;
21
+ * - the aggregate that produces the value (`count`, distinct `sessions`, `sum`,
22
+ * `avg`, `max`, `quantile`);
23
+ * - how bucket values combine into a window total (`rollup`).
24
+ *
25
+ * `buckets.ts` renders exactly one generic query from this data, per dialect.
26
+ *
27
+ * ## What is *not* here, and why
28
+ *
29
+ * 42 of the registry's 63 `comparable` metrics have a faithful portable bucket
30
+ * form. The rest do not, and are deliberately absent rather than approximated:
31
+ *
32
+ * - **funnel-shaped** metrics (`funnel`, `load_bounce_funnel`,
33
+ * `variant_leaderboard`, `scene_retention`) are defined by request-supplied
34
+ * step predicates or by a cohort that spans buckets — bucketing them changes
35
+ * what they mean;
36
+ * - **join- or window-function-shaped** metrics (`click_rays`, `flow_links`,
37
+ * `mesh_reachability`, `navigation_stats`, `backtrack_ratio`,
38
+ * `xr_rotation`, `xr_abandonment`, `perf_churn`, `rage_clicks`) derive a row
39
+ * from the *relationship between consecutive events*, which a per-bucket
40
+ * `GROUP BY` cannot express without changing the answer at every bucket
41
+ * boundary;
42
+ * - **per-session-then-aggregate** ratios (`jank_rate`) and payload-derived
43
+ * percentiles that are not promoted columns (`frame_time_percentiles`,
44
+ * `render_scale_truth`, `mesh_uv_heatmap`, `mesh_blind_spots`,
45
+ * `xr_boundary_contacts`) would need a second aggregation level or a JSON
46
+ * extraction whose null-handling differs from the metric's own.
47
+ *
48
+ * An approximate series is worse than no series: `baseline` would report a
49
+ * "normal" that the metric's own endpoint never produces. Asking for one of
50
+ * those metrics is a `400` naming the ids that *do* have a series
51
+ * ({@link BUCKETABLE_METRIC_IDS}), which is a better answer than a plausible
52
+ * wrong number. Widening the catalog is additive — add an entry here, a parity
53
+ * case in `src/parity/cases.ts`, and the metric becomes available to both
54
+ * primitives with no route or registry change.
55
+ */
56
+ import type { MetricId } from "@uptimizr/metrics";
57
+ /** The two time grains an insight series can be bucketed at. */
58
+ export type BucketGrain = "day" | "hour";
59
+ /** Bucket width in seconds, the unit `Dialect.timeBucketMs` takes. */
60
+ export declare const BUCKET_SECONDS: Readonly<Record<BucketGrain, number>>;
61
+ /** Whether an arbitrary string names a supported bucket grain. */
62
+ export declare function isBucketGrain(value: string): value is BucketGrain;
63
+ /**
64
+ * Promoted numeric columns a measure may aggregate.
65
+ *
66
+ * Restricted to columns the events table actually promotes, so a measure never
67
+ * needs a JSON extraction whose null semantics differ per engine. The one
68
+ * exception is `ar_placement_scale`, which `buildArPlacementSurfaces` itself
69
+ * reads with `jsonFloat` — it is rendered the same way here so the two agree.
70
+ */
71
+ export type BucketValueColumn = "fps" | "visible_ms" | "js_heap_bytes" | "long_frames" | "ar_placement_scale";
72
+ /**
73
+ * A predicate a measure may add beyond its event-type filter.
74
+ *
75
+ * A closed vocabulary of three shapes over promoted columns only. Values are
76
+ * compile-time constants declared in {@link BUCKET_MEASURES}, never request
77
+ * input, and are bound as parameters regardless.
78
+ */
79
+ export type BucketPredicate =
80
+ /** `mesh`/`name`/`source` equals (or does not equal) a constant. `''` is the store's "unknown". */
81
+ {
82
+ readonly kind: "eq" | "ne";
83
+ readonly column: "mesh" | "name" | "source";
84
+ readonly value: string;
85
+ }
86
+ /** `name`/`source` is one of a constant set. */
87
+ | {
88
+ readonly kind: "in";
89
+ readonly column: "name" | "source";
90
+ readonly values: readonly string[];
91
+ }
92
+ /**
93
+ * A vector column carries a full coordinate (`arrayLength(col) = arity`) — the
94
+ * same guard every spatial aggregation applies before it bins a point, so the
95
+ * bucket count matches the heatmap's own row total.
96
+ */
97
+ | {
98
+ readonly kind: "geometry";
99
+ readonly column: string;
100
+ readonly arity: 2 | 3;
101
+ };
102
+ /** The aggregate that turns a bucket's matching events into one value. */
103
+ export type BucketAggregate = {
104
+ readonly kind: "count";
105
+ } | {
106
+ readonly kind: "sessions";
107
+ } | {
108
+ readonly kind: "sum";
109
+ readonly column: BucketValueColumn;
110
+ } | {
111
+ readonly kind: "avg";
112
+ readonly column: BucketValueColumn;
113
+ } | {
114
+ readonly kind: "max";
115
+ readonly column: BucketValueColumn;
116
+ } | {
117
+ readonly kind: "quantile";
118
+ readonly column: BucketValueColumn;
119
+ readonly q: number;
120
+ };
121
+ /** How a metric's per-bucket values combine into one number for a whole window. */
122
+ export type BucketRollup =
123
+ /** Additive quantities (counts, durations, sessions): the window value is the sum. */
124
+ "sum"
125
+ /** Levels (an FPS median, a heap percentile, a scale): the window value is the mean of the buckets. */
126
+ | "mean";
127
+ /** The promoted dimensions a bucket series may be split by. A `DimensionId` subset. */
128
+ export type BucketSplitDimension = "scene" | "mesh" | "name" | "source" | "event_type";
129
+ /** The `events` column behind each split dimension. */
130
+ export declare const BUCKET_SPLIT_COLUMNS: Readonly<Record<BucketSplitDimension, string>>;
131
+ /** Whether an arbitrary string names a supported split dimension. */
132
+ export declare function isBucketSplitDimension(value: string): value is BucketSplitDimension;
133
+ /** How to reproduce one metric's `comparable.primary` as a per-bucket series. */
134
+ export interface BucketMeasure {
135
+ /**
136
+ * The registry column this series stands for — always the metric's
137
+ * `comparable.primary`, asserted in `src/__tests__/insights.test.ts`.
138
+ */
139
+ readonly column: string;
140
+ /** Capture channels the series counts over. Empty means *every* event type. */
141
+ readonly eventTypes: readonly string[];
142
+ /** Extra predicates, ANDed with the event-type filter. */
143
+ readonly where?: readonly BucketPredicate[];
144
+ readonly aggregate: BucketAggregate;
145
+ readonly rollup: BucketRollup;
146
+ /**
147
+ * The one dimension this series can be split by, for `anomalies`' contributor
148
+ * attribution (#306). Absent means the metric's excess has no single promoted
149
+ * column that explains it, and the contributor is reported as `null`.
150
+ */
151
+ readonly splitBy?: BucketSplitDimension;
152
+ /** Where the bucket form differs in emphasis from the metric's own endpoint. */
153
+ readonly note?: string;
154
+ }
155
+ /**
156
+ * The portable bucket form of every comparable metric that has one, keyed by
157
+ * metric id. See the module doc for what is deliberately absent.
158
+ */
159
+ export declare const BUCKET_MEASURES: Readonly<Partial<Record<MetricId, BucketMeasure>>>;
160
+ /**
161
+ * Every metric that has a portable bucket series, sorted — the list a `400`
162
+ * quotes back when a caller asks for one that does not.
163
+ */
164
+ export declare const BUCKETABLE_METRIC_IDS: readonly MetricId[];
165
+ /** The bucket measure for a metric, or `undefined` when it has no portable series. */
166
+ export declare function bucketMeasureFor(metric: string): BucketMeasure | undefined;
167
+ /** Whether a metric id can be asked for a bucket series. */
168
+ export declare function isBucketableMetric(metric: string): boolean;
169
+ /**
170
+ * The metrics `movers` scans when the caller names none, in the order it scans
171
+ * them, capped by {@link MOVERS_MAX_METRICS}.
172
+ *
173
+ * Curated rather than "every bucketable metric": each request costs one grouped
174
+ * scan per metric, so an uncapped default would make a single `movers` call the
175
+ * most expensive endpoint the collector serves. The list leads with the signals
176
+ * a person actually asks "what changed?" about — traffic, performance, errors,
177
+ * interaction — and covers each of them exactly once, so two entries never
178
+ * report the same move twice under different names. Any other bucketable metric
179
+ * is still reachable through the `metrics=` allowlist.
180
+ */
181
+ export declare const MOVERS_DEFAULT_METRICS: readonly MetricId[];
182
+ /**
183
+ * The hard cap on how many metrics one `movers` request may scan.
184
+ *
185
+ * Each scanned metric is one grouped scan of `events` over the combined
186
+ * reference+current window, so the cost of the endpoint is linear in this
187
+ * number and bounded by it whatever the caller asks for. Enforced in two
188
+ * places, both of which are visible to the caller: the route's `metrics=`
189
+ * schema rejects a longer allowlist with a `400`, and
190
+ * {@link MOVERS_DEFAULT_METRICS} is asserted to be no longer than this.
191
+ */
192
+ export declare const MOVERS_MAX_METRICS = 24;
193
+ /**
194
+ * The named auxiliary series a metric may declare.
195
+ *
196
+ * - `denominator` — the `rateOf` column beside a rate numerator;
197
+ * - `numerator` — the counted part of a ratio whose metric has no main measure;
198
+ * - `p05` — the 5th percentile of a distribution whose main measure is its median.
199
+ */
200
+ export type BucketVariant = "numerator" | "denominator" | "p05";
201
+ /**
202
+ * Named auxiliary series, by metric. See the section header for why these are
203
+ * separate from {@link BUCKET_MEASURES} rather than entries in it.
204
+ */
205
+ export declare const BUCKET_MEASURE_VARIANTS: Readonly<Partial<Record<MetricId, Readonly<Partial<Record<BucketVariant, BucketMeasure>>>>>>;
206
+ /**
207
+ * The auxiliary series a metric declares under `variant`, or `undefined`.
208
+ *
209
+ * Kept separate from {@link bucketMeasureFor} so that "has a portable bucket
210
+ * series" — the question `baseline` and `movers` validate a caller's `metric`
211
+ * against — keeps meaning exactly what it meant before: a faithful per-bucket
212
+ * form of the metric's *own* headline column.
213
+ */
214
+ export declare function bucketVariantFor(metric: string, variant: BucketVariant): BucketMeasure | undefined;
215
+ /**
216
+ * The measure a bucket read resolves to: a metric's main series, or the named
217
+ * auxiliary one. The single lookup `buildMetricBuckets` and
218
+ * `evaluateBucketMeasure` share, so the SQL and the in-memory path cannot
219
+ * resolve the same request differently.
220
+ */
221
+ export declare function resolveBucketMeasure(metric: string, variant?: BucketVariant): BucketMeasure | undefined;
222
+ //# sourceMappingURL=measures.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"measures.d.ts","sourceRoot":"","sources":["../../src/insights/measures.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,431 @@
1
+ /**
2
+ * **Portable bucket measures** — the input side of the insight primitives
3
+ * (ADR 0051 §4, design sketch §D).
4
+ *
5
+ * `baseline` and `movers` both answer questions about *one number moving over
6
+ * time*: "what is normal for this scene", "what changed since last week". Both
7
+ * therefore need the same thing from the store — a per-bucket series of a
8
+ * comparable metric's `comparable.primary` column — and neither may compute
9
+ * statistics in SQL, because five dialects' `quantile`, `median` and
10
+ * `stddev` do not agree and the answer must not depend on which engine a
11
+ * self-hoster chose.
12
+ *
13
+ * This module is the declaration that makes that series portable. For each
14
+ * comparable registry metric it records **how to reproduce that metric's
15
+ * primary column per time bucket** as a single grouped scan of `events`:
16
+ *
17
+ * - which capture channels the metric reads (`eventTypes`);
18
+ * - a closed vocabulary of extra predicates (`where`) — a promoted-column
19
+ * equality or set membership, or a geometry-arity guard. Never free SQL, so
20
+ * nothing here can become an injection surface;
21
+ * - the aggregate that produces the value (`count`, distinct `sessions`, `sum`,
22
+ * `avg`, `max`, `quantile`);
23
+ * - how bucket values combine into a window total (`rollup`).
24
+ *
25
+ * `buckets.ts` renders exactly one generic query from this data, per dialect.
26
+ *
27
+ * ## What is *not* here, and why
28
+ *
29
+ * 42 of the registry's 63 `comparable` metrics have a faithful portable bucket
30
+ * form. The rest do not, and are deliberately absent rather than approximated:
31
+ *
32
+ * - **funnel-shaped** metrics (`funnel`, `load_bounce_funnel`,
33
+ * `variant_leaderboard`, `scene_retention`) are defined by request-supplied
34
+ * step predicates or by a cohort that spans buckets — bucketing them changes
35
+ * what they mean;
36
+ * - **join- or window-function-shaped** metrics (`click_rays`, `flow_links`,
37
+ * `mesh_reachability`, `navigation_stats`, `backtrack_ratio`,
38
+ * `xr_rotation`, `xr_abandonment`, `perf_churn`, `rage_clicks`) derive a row
39
+ * from the *relationship between consecutive events*, which a per-bucket
40
+ * `GROUP BY` cannot express without changing the answer at every bucket
41
+ * boundary;
42
+ * - **per-session-then-aggregate** ratios (`jank_rate`) and payload-derived
43
+ * percentiles that are not promoted columns (`frame_time_percentiles`,
44
+ * `render_scale_truth`, `mesh_uv_heatmap`, `mesh_blind_spots`,
45
+ * `xr_boundary_contacts`) would need a second aggregation level or a JSON
46
+ * extraction whose null-handling differs from the metric's own.
47
+ *
48
+ * An approximate series is worse than no series: `baseline` would report a
49
+ * "normal" that the metric's own endpoint never produces. Asking for one of
50
+ * those metrics is a `400` naming the ids that *do* have a series
51
+ * ({@link BUCKETABLE_METRIC_IDS}), which is a better answer than a plausible
52
+ * wrong number. Widening the catalog is additive — add an entry here, a parity
53
+ * case in `src/parity/cases.ts`, and the metric becomes available to both
54
+ * primitives with no route or registry change.
55
+ */
56
+ /** Bucket width in seconds, the unit `Dialect.timeBucketMs` takes. */
57
+ export const BUCKET_SECONDS = {
58
+ day: 86_400,
59
+ hour: 3_600,
60
+ };
61
+ /** Whether an arbitrary string names a supported bucket grain. */
62
+ export function isBucketGrain(value) {
63
+ return value === "day" || value === "hour";
64
+ }
65
+ /** The `events` column behind each split dimension. */
66
+ export const BUCKET_SPLIT_COLUMNS = {
67
+ scene: "scene_id",
68
+ mesh: "mesh",
69
+ name: "name",
70
+ source: "source",
71
+ event_type: "event_type",
72
+ };
73
+ /** Whether an arbitrary string names a supported split dimension. */
74
+ export function isBucketSplitDimension(value) {
75
+ return Object.prototype.hasOwnProperty.call(BUCKET_SPLIT_COLUMNS, value);
76
+ }
77
+ /** Shorthand for the commonest measure: "count these channels' events per bucket". */
78
+ function counted(column, eventTypes, where, note) {
79
+ return {
80
+ column,
81
+ eventTypes,
82
+ ...(where ? { where } : {}),
83
+ aggregate: { kind: "count" },
84
+ rollup: "sum",
85
+ ...(note ? { note } : {}),
86
+ };
87
+ }
88
+ /** A vector-column arity guard, matching the spatial aggregations' own. */
89
+ function geometry(column, arity) {
90
+ return { kind: "geometry", column, arity };
91
+ }
92
+ /**
93
+ * Declare the dimension a measure's excess can be attributed to (#306).
94
+ *
95
+ * A wrapper rather than an extra parameter, so the catalog below stays one
96
+ * expression per metric and giving a metric attribution is a purely additive
97
+ * edit to its own line.
98
+ */
99
+ function split(measure, splitBy) {
100
+ return { ...measure, splitBy };
101
+ }
102
+ /** The median-FPS series shared by every metric whose primary is `p50_fps`. */
103
+ const P50_FPS = {
104
+ column: "p50_fps",
105
+ eventTypes: ["frame_perf"],
106
+ aggregate: { kind: "quantile", column: "fps", q: 0.5 },
107
+ rollup: "mean",
108
+ // The only promoted dimension a frame rate can be attributed to: an FPS drop
109
+ // confined to one scene is a scene problem, one spread across them is a build
110
+ // problem. Device is the split a reader would ask for next, but it lives in
111
+ // the `session_start` payload rather than on the frame, so it is not reachable
112
+ // from a single grouped scan of `events` (#306).
113
+ splitBy: "scene",
114
+ note: "The bucket series is the median over the bucket's raw `frame_perf` samples. The metric's own " +
115
+ "endpoint computes per session and then aggregates (ADR 0028 §1), so a bucket median and the " +
116
+ "endpoint's headline can differ slightly when one session dominates a bucket.",
117
+ };
118
+ /**
119
+ * The portable bucket form of every comparable metric that has one, keyed by
120
+ * metric id. See the module doc for what is deliberately absent.
121
+ */
122
+ export const BUCKET_MEASURES = {
123
+ // --- volume / orientation ------------------------------------------------
124
+ list_sessions: split(counted("events", []), "event_type"),
125
+ list_scenes: split(counted("events", []), "scene"),
126
+ timeseries: split(counted("events", []), "event_type"),
127
+ event_counts: split(counted("count", []), "event_type"),
128
+ events_daily: split(counted("events", []), "event_type"),
129
+ // --- attention / spatial -------------------------------------------------
130
+ // Each counts exactly the events its heatmap bins, geometry guard included, so
131
+ // the series total equals the sum of the heatmap's own bin counts.
132
+ pointer_heatmap: split(counted("count", ["pointer_move", "pointer_click"], [geometry("screen", 2)]), "event_type"),
133
+ world_heatmap: split(counted("count", ["pointer_move", "pointer_click"], [geometry("hit_point", 3)]), "mesh"),
134
+ gaze_heatmap: split(counted("count", ["camera_sample"], [geometry("hit_point", 3)]), "mesh"),
135
+ camera_heatmap: split(counted("count", ["camera_sample"], [geometry("direction", 3)]), "scene"),
136
+ position_heatmap: split(counted("count", ["camera_sample"], [geometry("position", 3)]), "scene"),
137
+ scene_coverage: split(counted("count", ["camera_sample"], [geometry("position", 3)]), "scene"),
138
+ view_coverage_histogram: {
139
+ column: "sessions",
140
+ eventTypes: ["camera_sample"],
141
+ aggregate: { kind: "sessions" },
142
+ rollup: "sum",
143
+ note: "Distinct sessions are counted *within* each bucket, so a session that spans two buckets " +
144
+ "contributes to both and the window total is an upper bound on distinct sessions.",
145
+ },
146
+ // --- meshes / interaction -----------------------------------------------
147
+ top_meshes: split(counted("count", ["mesh_interaction", "pointer_click", "camera_sample"], [{ kind: "ne", column: "mesh", value: "" }]), "mesh"),
148
+ mesh_sources: split(counted("count", ["mesh_interaction", "pointer_click"], [{ kind: "ne", column: "mesh", value: "" }]), "source"),
149
+ mesh_trend: split(counted("count", ["mesh_interaction", "pointer_click"], [{ kind: "ne", column: "mesh", value: "" }]), "mesh"),
150
+ mesh_interaction_kinds: split(counted("count", ["mesh_interaction"], [{ kind: "ne", column: "mesh", value: "" }]), "name"),
151
+ mesh_dwell: {
152
+ column: "visible_ms",
153
+ eventTypes: ["mesh_visibility"],
154
+ where: [{ kind: "ne", column: "mesh", value: "" }],
155
+ aggregate: { kind: "sum", column: "visible_ms" },
156
+ rollup: "sum",
157
+ splitBy: "mesh",
158
+ },
159
+ dead_clicks: split(counted("dead_clicks", ["pointer_click"], [{ kind: "eq", column: "mesh", value: "" }], "Counts the clicks that hit nothing. The metric's own `total_clicks` denominator is not " +
160
+ "part of the series — read `pointer_heatmap` alongside it for click volume."), "source"),
161
+ hover_dwell: {
162
+ column: "dwell_ms",
163
+ eventTypes: ["hover_dwell"],
164
+ aggregate: { kind: "sum", column: "visible_ms" },
165
+ rollup: "sum",
166
+ splitBy: "mesh",
167
+ },
168
+ interaction_sources: split(counted("count", ["pointer_click", "pointer_move", "mesh_interaction", "input_action"]), "source"),
169
+ top_input_actions: split(counted("count", ["input_action"]), "name"),
170
+ camera_gestures: split(counted("gestures", ["camera_gesture"]), "name"),
171
+ // --- performance ---------------------------------------------------------
172
+ perf_summary: P50_FPS,
173
+ perf_distribution: P50_FPS,
174
+ perf_by_scene: P50_FPS,
175
+ perf_by_device: P50_FPS,
176
+ perf_daily: P50_FPS,
177
+ perf_heatmap: {
178
+ column: "avg_fps",
179
+ eventTypes: ["frame_perf"],
180
+ aggregate: { kind: "avg", column: "fps" },
181
+ rollup: "mean",
182
+ splitBy: "scene",
183
+ },
184
+ fps_histogram: {
185
+ column: "sessions",
186
+ eventTypes: ["frame_perf"],
187
+ aggregate: { kind: "sessions" },
188
+ rollup: "sum",
189
+ splitBy: "scene",
190
+ note: "Distinct sessions with perf samples, counted within each bucket; a session spanning two " +
191
+ "buckets contributes to both.",
192
+ },
193
+ compile_stalls: {
194
+ column: "total_ms",
195
+ eventTypes: ["compile_stall"],
196
+ aggregate: { kind: "sum", column: "visible_ms" },
197
+ rollup: "sum",
198
+ splitBy: "name",
199
+ },
200
+ resource_summary: {
201
+ column: "max_js_heap_bytes",
202
+ eventTypes: ["resource_sample"],
203
+ aggregate: { kind: "max", column: "js_heap_bytes" },
204
+ rollup: "mean",
205
+ note: "Each bucket reports its own peak heap; the window value is the mean of those peaks.",
206
+ },
207
+ resource_percentiles: {
208
+ column: "p95_js_heap_bytes",
209
+ eventTypes: ["resource_sample"],
210
+ aggregate: { kind: "quantile", column: "js_heap_bytes", q: 0.95 },
211
+ rollup: "mean",
212
+ },
213
+ // --- errors / stability --------------------------------------------------
214
+ stability_counts: split(counted("incidents", ["context_lost", "compile_stall"]), "event_type"),
215
+ graphics_diagnostics: split(counted("incidents", ["graphics_diagnostic"]), "scene"),
216
+ error_heatmap: split(counted("count", ["runtime_error", "graphics_diagnostic"]), "event_type"),
217
+ capability_changes: split(counted("changes", ["capability_change"]), "name"),
218
+ rendering_technology: {
219
+ column: "sessions",
220
+ eventTypes: ["session_start"],
221
+ aggregate: { kind: "sessions" },
222
+ rollup: "sum",
223
+ splitBy: "scene",
224
+ },
225
+ // --- XR / AR -------------------------------------------------------------
226
+ xr_locomotion: {
227
+ column: "locomotion_ms",
228
+ eventTypes: ["camera_gesture"],
229
+ where: [{ kind: "in", column: "name", values: ["fly", "navigate"] }],
230
+ aggregate: { kind: "sum", column: "visible_ms" },
231
+ rollup: "sum",
232
+ splitBy: "name",
233
+ },
234
+ xr_tracking_quality: {
235
+ column: "degraded_ms",
236
+ eventTypes: ["capability_change"],
237
+ where: [{ kind: "eq", column: "name", value: "tracking" }],
238
+ aggregate: { kind: "sum", column: "visible_ms" },
239
+ rollup: "sum",
240
+ },
241
+ boundary_heatmap: split(counted("count", ["xr_boundary_proximity"]), "scene"),
242
+ ar_placement_time_to_place: counted("placements", ["ar_placement"]),
243
+ ar_placement_attempts: counted("placements", ["ar_placement"]),
244
+ ar_placement_surfaces: {
245
+ column: "avg_scale",
246
+ eventTypes: ["ar_placement"],
247
+ aggregate: { kind: "avg", column: "ar_placement_scale" },
248
+ rollup: "mean",
249
+ },
250
+ };
251
+ /**
252
+ * Every metric that has a portable bucket series, sorted — the list a `400`
253
+ * quotes back when a caller asks for one that does not.
254
+ */
255
+ export const BUCKETABLE_METRIC_IDS = Object.keys(BUCKET_MEASURES).sort();
256
+ /** The bucket measure for a metric, or `undefined` when it has no portable series. */
257
+ export function bucketMeasureFor(metric) {
258
+ return Object.prototype.hasOwnProperty.call(BUCKET_MEASURES, metric)
259
+ ? BUCKET_MEASURES[metric]
260
+ : undefined;
261
+ }
262
+ /** Whether a metric id can be asked for a bucket series. */
263
+ export function isBucketableMetric(metric) {
264
+ return bucketMeasureFor(metric) != null;
265
+ }
266
+ /**
267
+ * The metrics `movers` scans when the caller names none, in the order it scans
268
+ * them, capped by {@link MOVERS_MAX_METRICS}.
269
+ *
270
+ * Curated rather than "every bucketable metric": each request costs one grouped
271
+ * scan per metric, so an uncapped default would make a single `movers` call the
272
+ * most expensive endpoint the collector serves. The list leads with the signals
273
+ * a person actually asks "what changed?" about — traffic, performance, errors,
274
+ * interaction — and covers each of them exactly once, so two entries never
275
+ * report the same move twice under different names. Any other bucketable metric
276
+ * is still reachable through the `metrics=` allowlist.
277
+ */
278
+ export const MOVERS_DEFAULT_METRICS = [
279
+ // traffic and reach
280
+ "list_sessions",
281
+ "fps_histogram",
282
+ "rendering_technology",
283
+ // performance
284
+ "perf_summary",
285
+ "perf_heatmap",
286
+ "compile_stalls",
287
+ "resource_percentiles",
288
+ // errors and stability
289
+ "error_heatmap",
290
+ "stability_counts",
291
+ "graphics_diagnostics",
292
+ "capability_changes",
293
+ // interaction
294
+ "pointer_heatmap",
295
+ "dead_clicks",
296
+ "top_meshes",
297
+ "mesh_interaction_kinds",
298
+ "mesh_dwell",
299
+ "hover_dwell",
300
+ "top_input_actions",
301
+ "camera_gestures",
302
+ // attention and navigation
303
+ "camera_heatmap",
304
+ "gaze_heatmap",
305
+ "scene_coverage",
306
+ // XR / AR
307
+ "xr_locomotion",
308
+ "boundary_heatmap",
309
+ ];
310
+ /**
311
+ * The hard cap on how many metrics one `movers` request may scan.
312
+ *
313
+ * Each scanned metric is one grouped scan of `events` over the combined
314
+ * reference+current window, so the cost of the endpoint is linear in this
315
+ * number and bounded by it whatever the caller asks for. Enforced in two
316
+ * places, both of which are visible to the caller: the route's `metrics=`
317
+ * schema rejects a longer allowlist with a `400`, and
318
+ * {@link MOVERS_DEFAULT_METRICS} is asserted to be no longer than this.
319
+ */
320
+ export const MOVERS_MAX_METRICS = 24;
321
+ /** XR input sources (ADR 0011) — the same set `buildXrAbandonment` restricts to. */
322
+ const XR_SOURCES = ["xr-controller", "hand", "gaze", "transient"];
323
+ /** Event channels that carry the input-source vocabulary, as `xr_abandonment` reads them. */
324
+ const XR_INTERACTION_CHANNELS = ["pointer_click", "pointer_move", "mesh_interaction"];
325
+ /** Distinct sessions per bucket — the denominator of every "per session" factor. */
326
+ function perSessionDenominator(eventTypes, note) {
327
+ return {
328
+ column: "sessions",
329
+ eventTypes,
330
+ aggregate: { kind: "sessions" },
331
+ rollup: "sum",
332
+ note,
333
+ };
334
+ }
335
+ /**
336
+ * Named auxiliary series, by metric. See the section header for why these are
337
+ * separate from {@link BUCKET_MEASURES} rather than entries in it.
338
+ */
339
+ export const BUCKET_MEASURE_VARIANTS = {
340
+ // The `rateOf` denominator of the dead-click rate: every click, not only the
341
+ // ones that hit nothing. `dead_clicks / total_clicks` is then a genuine
342
+ // proportion, which is what makes a two-proportion test legitimate.
343
+ dead_clicks: {
344
+ denominator: counted("total_clicks", ["pointer_click"]),
345
+ },
346
+ // The tail of the FPS distribution rather than its middle. Health asks "how
347
+ // bad does it get here", and a scene whose median is 60 while its 5th
348
+ // percentile is 12 is not a smooth scene — the median alone cannot say that.
349
+ perf_summary: {
350
+ p05: {
351
+ column: "p50_fps",
352
+ eventTypes: ["frame_perf"],
353
+ aggregate: { kind: "quantile", column: "fps", q: 0.05 },
354
+ rollup: "mean",
355
+ note: "The 5th percentile of the bucket's raw `frame_perf` samples — the frame rate in the " +
356
+ "worst twentieth of sampled windows, not the median the metric's own headline reports.",
357
+ },
358
+ },
359
+ // `jank_rate`'s own endpoint computes a rate per session and then takes the
360
+ // median of those (ADR 0028 §1), which no `GROUP BY` reproduces. This pair is
361
+ // the *pooled* rate over the same raw material: long frames per sampled
362
+ // window, across every session in the bucket. A different statistic, named as
363
+ // one, and the only portable one.
364
+ jank_rate: {
365
+ numerator: {
366
+ column: "total_long_frames",
367
+ eventTypes: ["frame_perf"],
368
+ aggregate: { kind: "sum", column: "long_frames" },
369
+ rollup: "sum",
370
+ note: "Long frames summed across every session in the bucket. The metric's own `median_rate` " +
371
+ "is a per-session median, so the two agree in direction but not in value.",
372
+ },
373
+ denominator: counted("sessions", ["frame_perf"], undefined, "Sampled perf windows in scope — the denominator the SDK's own rate is per."),
374
+ },
375
+ // Errors per session. The numerator is the metric's own count; the
376
+ // denominator is sessions, because an error count with no traffic behind it
377
+ // says nothing — twice the errors on three times the visitors is an
378
+ // improvement.
379
+ error_heatmap: {
380
+ denominator: perSessionDenominator(["session_start"], "Sessions started in the bucket — the denominator that turns an error count into a rate."),
381
+ },
382
+ // Exploration per session. A true voxel-coverage *percentage* needs the
383
+ // scene's registered bounds (`scene_representation`) and has no portable
384
+ // per-bucket form; positioned camera samples per session is the portable
385
+ // proxy, and it is normalised against the project's own baseline rather than
386
+ // read as an absolute.
387
+ scene_coverage: {
388
+ denominator: perSessionDenominator(["camera_sample"], "Sessions that produced a camera sample in the bucket."),
389
+ },
390
+ // XR interactions per XR session. `xr_abandonment`'s own endpoint needs a
391
+ // session-level anti-join; this pair is the portable inverse signal — a
392
+ // headset session that interacts with nothing is the abandonment the metric
393
+ // is looking for. Both sides are restricted to XR input sources, so the
394
+ // factor is absent (rather than zero) in a project with no XR traffic.
395
+ xr_abandonment: {
396
+ numerator: counted("xr_interactions", XR_INTERACTION_CHANNELS, [
397
+ { kind: "in", column: "source", values: XR_SOURCES },
398
+ ]),
399
+ denominator: {
400
+ column: "session_id",
401
+ eventTypes: XR_INTERACTION_CHANNELS,
402
+ where: [{ kind: "in", column: "source", values: XR_SOURCES }],
403
+ aggregate: { kind: "sessions" },
404
+ rollup: "sum",
405
+ note: "Distinct sessions that produced at least one XR-sourced interaction in the bucket.",
406
+ },
407
+ },
408
+ };
409
+ /**
410
+ * The auxiliary series a metric declares under `variant`, or `undefined`.
411
+ *
412
+ * Kept separate from {@link bucketMeasureFor} so that "has a portable bucket
413
+ * series" — the question `baseline` and `movers` validate a caller's `metric`
414
+ * against — keeps meaning exactly what it meant before: a faithful per-bucket
415
+ * form of the metric's *own* headline column.
416
+ */
417
+ export function bucketVariantFor(metric, variant) {
418
+ if (!Object.prototype.hasOwnProperty.call(BUCKET_MEASURE_VARIANTS, metric))
419
+ return undefined;
420
+ return BUCKET_MEASURE_VARIANTS[metric]?.[variant];
421
+ }
422
+ /**
423
+ * The measure a bucket read resolves to: a metric's main series, or the named
424
+ * auxiliary one. The single lookup `buildMetricBuckets` and
425
+ * `evaluateBucketMeasure` share, so the SQL and the in-memory path cannot
426
+ * resolve the same request differently.
427
+ */
428
+ export function resolveBucketMeasure(metric, variant) {
429
+ return variant == null ? bucketMeasureFor(metric) : bucketVariantFor(metric, variant);
430
+ }
431
+ //# sourceMappingURL=measures.js.map