@uptimizr/metrics 0.0.0 → 0.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 +129 -0
- package/README.md +43 -21
- package/dist/envelopes.d.ts +280 -0
- package/dist/envelopes.d.ts.map +1 -0
- package/dist/envelopes.js +289 -0
- package/dist/envelopes.js.map +1 -0
- package/dist/index.d.ts +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -1
- package/dist/index.js.map +1 -1
- package/dist/narrative.d.ts +164 -0
- package/dist/narrative.d.ts.map +1 -0
- package/dist/narrative.js +150 -0
- package/dist/narrative.js.map +1 -0
- package/dist/panelSpec.d.ts +145 -0
- package/dist/panelSpec.d.ts.map +1 -0
- package/dist/panelSpec.js +286 -0
- package/dist/panelSpec.js.map +1 -0
- package/dist/query.d.ts +192 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +461 -0
- package/dist/query.js.map +1 -0
- package/dist/registry.d.ts +948 -98
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +954 -13
- package/dist/registry.js.map +1 -1
- package/llms.txt +12 -3
- package/package.json +4 -4
package/dist/query.d.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **Registry validation for the query DSL** (ADR 0051 §3, design sketch §C.1).
|
|
3
|
+
*
|
|
4
|
+
* `@uptimizr/schema`'s `queryV1Schema` answers "is this a well-formed query?" —
|
|
5
|
+
* the right types, bounded strings, no unknown keys, no free-form expression.
|
|
6
|
+
* It cannot answer "is `mesh_sources` a metric, does it accept a `cameraMode`
|
|
7
|
+
* filter, and is `limit: 900` within its cap", because those are questions about
|
|
8
|
+
* the **vocabulary**, and the vocabulary is this package. (`@uptimizr/schema`
|
|
9
|
+
* also cannot ask them: it is this package's dependency, not the other way
|
|
10
|
+
* round.)
|
|
11
|
+
*
|
|
12
|
+
* So the split is: shape at the edge, vocabulary here. {@link validateQuery}
|
|
13
|
+
* takes a structurally-valid query and returns the registry's objections as
|
|
14
|
+
* data — a code, the path that offended, a human message, and, where it helps,
|
|
15
|
+
* the list of values that *would* have been accepted. The collector turns them
|
|
16
|
+
* into one `400`; a test can assert on the codes without matching prose.
|
|
17
|
+
*
|
|
18
|
+
* Nothing here executes anything, touches a store, or knows what a dialect is.
|
|
19
|
+
* The compilation itself lives in `@uptimizr/db`'s `query/dsl`.
|
|
20
|
+
*
|
|
21
|
+
* ## The two compilation tiers
|
|
22
|
+
*
|
|
23
|
+
* A query runs on one of two tiers (design sketch §C.2), and which one is a
|
|
24
|
+
* registry question as well:
|
|
25
|
+
*
|
|
26
|
+
* - **delegated** — the metric's *existing* aggregation builder runs, so the
|
|
27
|
+
* result is what the canned endpoint has always returned. This is every query
|
|
28
|
+
* at the metric's own grain, and the only tier a spatial or percentile metric
|
|
29
|
+
* has.
|
|
30
|
+
* - **generic** — a metric that declares `genericGroupBy` (a portable
|
|
31
|
+
* count/sum over promoted columns) is recomputed at an arbitrary grain by one
|
|
32
|
+
* shared builder: any subset of the dimensions it declares, plus the `event`
|
|
33
|
+
* and `device` predicates no canned builder ever took.
|
|
34
|
+
*
|
|
35
|
+
* {@link queryTier} answers which, from the query and the registry alone — no
|
|
36
|
+
* store, no dialect, no SQL.
|
|
37
|
+
*
|
|
38
|
+
* ## What is accepted
|
|
39
|
+
*
|
|
40
|
+
* - `metric` — any registry id with a `builder`. The two builder-less
|
|
41
|
+
* **resource** entries (`session_meta`, `scene_representation`) are single
|
|
42
|
+
* store reads, not aggregations, and are rejected.
|
|
43
|
+
* - `dimensions` — omitted, or the metric's {@link nativeDimensions}, on either
|
|
44
|
+
* tier; on the generic tier additionally any subset of the metric's declared
|
|
45
|
+
* dimensions that the tier can render ({@link genericDimensions}).
|
|
46
|
+
* - `filters` — the filter ids the metric declares, plus the ids it carries in
|
|
47
|
+
* its path (a session trajectory's `session`), which are required.
|
|
48
|
+
* `filters.event` (an ADR 0038 predicate) and `filters.device` are
|
|
49
|
+
* **generic-tier only**: no canned builder takes either.
|
|
50
|
+
* - `segment` / `compare.segment` — dimensions the metric can hold fixed: an
|
|
51
|
+
* accepted filter on either tier, any renderable dimension on the generic one.
|
|
52
|
+
* - `order` — a measure column, on the generic tier or on a **ranked** delegated
|
|
53
|
+
* metric. A series, a hotspot grid and a single-row record each already have
|
|
54
|
+
* the one order that means anything, and re-sorting them would be noise.
|
|
55
|
+
* - `compare` and `explain` — accepted on both tiers. Neither changes the SQL of
|
|
56
|
+
* the query itself, so neither is a tier question.
|
|
57
|
+
* - `limit` — at most the metric's `limits.maxRows`.
|
|
58
|
+
*/
|
|
59
|
+
import type { QueryV1 } from "@uptimizr/schema";
|
|
60
|
+
import { type DimensionId, type FilterId, type MetricDefinition } from "./registry.js";
|
|
61
|
+
/**
|
|
62
|
+
* The dimensions a metric's rows are **actually keyed by** — its native grain —
|
|
63
|
+
* as opposed to the dimensions it can merely be *filtered* or *regrouped* by,
|
|
64
|
+
* which is what `MetricDefinition.dimensions` lists.
|
|
65
|
+
*
|
|
66
|
+
* A thin accessor over registry data since #304: the grain is declared on each
|
|
67
|
+
* entry as `grainDimensions` rather than derived from `row.shape` at call time.
|
|
68
|
+
* `top_meshes` declares `["mesh", "session"]` but is keyed by `["mesh"]`, so a
|
|
69
|
+
* `dimensions: ["session"]` query is a *regrouping* (the generic tier) rather
|
|
70
|
+
* than the metric's own result. `src/__tests__/registry.test.ts` keeps the old
|
|
71
|
+
* derivation, as the gate on the declaration.
|
|
72
|
+
*/
|
|
73
|
+
export declare function nativeDimensions(metric: MetricDefinition): readonly DimensionId[];
|
|
74
|
+
/**
|
|
75
|
+
* The dimensions this metric can be **grouped by** on the generic tier: the ones
|
|
76
|
+
* it declares, intersected with the ones that tier can render at all. Empty for
|
|
77
|
+
* a metric with no `genericGroupBy` — a spatial or percentile metric is stuck
|
|
78
|
+
* with its own grain, and says so.
|
|
79
|
+
*/
|
|
80
|
+
export declare function genericDimensions(metric: MetricDefinition): readonly DimensionId[];
|
|
81
|
+
/**
|
|
82
|
+
* The row column a dimension is projected as for this metric.
|
|
83
|
+
*
|
|
84
|
+
* A metric already keyed by the dimension keeps its own spelling —
|
|
85
|
+
* `top_input_actions` calls the `name` column `action`, `mesh_interaction_kinds`
|
|
86
|
+
* calls it `kind` — so regrouping a metric never renames a column it already
|
|
87
|
+
* returns. Anything else takes the canonical name from
|
|
88
|
+
* {@link DIMENSION_ROW_COLUMNS}; `cameraMode`, which no aggregation projects,
|
|
89
|
+
* becomes `camera_mode`.
|
|
90
|
+
*/
|
|
91
|
+
export declare function dimensionColumn(metric: MetricDefinition, dimension: DimensionId): string;
|
|
92
|
+
/**
|
|
93
|
+
* Filters a metric cannot be queried without, beyond the ones it carries in its
|
|
94
|
+
* path. The collector declares these required in its querystring schema
|
|
95
|
+
* (`funnelQueryParams.steps`, `meshUvHeatmapQueryParams.mesh`) and the builders
|
|
96
|
+
* take them as non-optional options; the registry records requiredness in prose
|
|
97
|
+
* rather than as data, so the two exceptions are listed here — once, in the
|
|
98
|
+
* vocabulary package, for the DSL and the generated tool catalog to share.
|
|
99
|
+
*/
|
|
100
|
+
export declare const REQUIRED_FILTERS: Readonly<Record<string, readonly FilterId[]>>;
|
|
101
|
+
/** Which compilation tier a validated query runs on. */
|
|
102
|
+
export type QueryTier = "delegated" | "generic";
|
|
103
|
+
/**
|
|
104
|
+
* Which tier a query runs on.
|
|
105
|
+
*
|
|
106
|
+
* The delegated tier is preferred wherever it can answer, so an unchanged query
|
|
107
|
+
* keeps returning the canned endpoint's exact bytes and the generic builder is
|
|
108
|
+
* reached only by asking for something the metric's own builder cannot do: a
|
|
109
|
+
* different grain, an event or device predicate, or a segment on a dimension
|
|
110
|
+
* that is not one of its filters.
|
|
111
|
+
*/
|
|
112
|
+
export declare function queryTier(metric: MetricDefinition, query: QueryV1): QueryTier;
|
|
113
|
+
/**
|
|
114
|
+
* The columns a query may `order` by — empty when the metric's result has no
|
|
115
|
+
* caller-choosable order at all (see {@link RANKED_GRAINS}).
|
|
116
|
+
*
|
|
117
|
+
* On the generic tier that is exactly the measures the generic builder projects;
|
|
118
|
+
* on the delegated tier it is the metric's own numeric measure columns, because
|
|
119
|
+
* those are the only ones present in every row it returns.
|
|
120
|
+
*/
|
|
121
|
+
export declare function orderableColumns(metric: MetricDefinition, tier?: QueryTier): readonly string[];
|
|
122
|
+
/** Why a query was rejected. Stable, matchable in a test without parsing prose. */
|
|
123
|
+
export type QueryIssueCode =
|
|
124
|
+
/** `metric` names nothing in the registry. */
|
|
125
|
+
"unknown_metric"
|
|
126
|
+
/** `metric` names a resource read (`session_meta`, `scene_representation`), not an aggregation. */
|
|
127
|
+
| "metric_not_queryable"
|
|
128
|
+
/** A dimension the metric does not declare at all. */
|
|
129
|
+
| "unknown_dimension"
|
|
130
|
+
/** A dimension the metric declares but is not keyed by, and cannot be regrouped by. */
|
|
131
|
+
| "dimension_not_native"
|
|
132
|
+
/** A declared dimension the generic tier cannot render (see `GENERIC_DIMENSIONS`). */
|
|
133
|
+
| "dimension_not_groupable"
|
|
134
|
+
/** A filter the metric does not accept. */
|
|
135
|
+
| "unsupported_filter"
|
|
136
|
+
/** A filter the metric cannot be queried without. */
|
|
137
|
+
| "missing_filter"
|
|
138
|
+
/** `limit` exceeds the metric's registry cap. */
|
|
139
|
+
| "limit_too_large"
|
|
140
|
+
/** `order.by` is not a column this metric's result can be ordered by. */
|
|
141
|
+
| "unsupported_order"
|
|
142
|
+
/** A `segment` / `compare.segment` dimension this metric cannot hold fixed. */
|
|
143
|
+
| "unsupported_segment"
|
|
144
|
+
/** A grammar feature this metric cannot support (`filters.event` on a delegated metric). */
|
|
145
|
+
| "unsupported_feature";
|
|
146
|
+
/** One registry objection to a query. */
|
|
147
|
+
export interface QueryIssue {
|
|
148
|
+
code: QueryIssueCode;
|
|
149
|
+
/** Dotted path into the query, e.g. `filters.cameraMode` or `dimensions[1]`. */
|
|
150
|
+
path: string;
|
|
151
|
+
/** Human-readable, and specific enough to fix the query from. */
|
|
152
|
+
message: string;
|
|
153
|
+
/** What would have been accepted here, when that is a closed list. */
|
|
154
|
+
accepted?: readonly string[];
|
|
155
|
+
}
|
|
156
|
+
/** The outcome of validating a query against the registry. */
|
|
157
|
+
export interface QueryValidation {
|
|
158
|
+
/** Empty when the query is executable. */
|
|
159
|
+
issues: readonly QueryIssue[];
|
|
160
|
+
/** The resolved metric, when `metric` named one with a builder. */
|
|
161
|
+
metric?: MetricDefinition;
|
|
162
|
+
/** The tier the query runs on, when `metric` resolved. */
|
|
163
|
+
tier?: QueryTier;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The filter ids a metric accepts in a DSL query: the ones it declares, plus the
|
|
167
|
+
* ones it carries in its path (a session trajectory's `session` is a filter here
|
|
168
|
+
* even though the canned route spells it as a path segment), minus the three
|
|
169
|
+
* that are not filters in the DSL.
|
|
170
|
+
*/
|
|
171
|
+
export declare function queryableFilters(metric: MetricDefinition): readonly FilterId[];
|
|
172
|
+
/**
|
|
173
|
+
* Filters a DSL query for this metric must carry: everything the canned endpoint
|
|
174
|
+
* puts in its path (always required — a trajectory without a session is not a
|
|
175
|
+
* query) plus the metric's {@link REQUIRED_FILTERS}.
|
|
176
|
+
*/
|
|
177
|
+
export declare function requiredFilters(metric: MetricDefinition): readonly FilterId[];
|
|
178
|
+
/**
|
|
179
|
+
* The dimensions a metric can hold fixed as a `segment`: an accepted filter on
|
|
180
|
+
* either tier, or any dimension the generic tier can render.
|
|
181
|
+
*/
|
|
182
|
+
export declare function segmentableDimensions(metric: MetricDefinition): readonly string[];
|
|
183
|
+
/**
|
|
184
|
+
* Check a structurally-valid query against the registry.
|
|
185
|
+
*
|
|
186
|
+
* Every objection is collected rather than thrown on the first one: an agent
|
|
187
|
+
* that mis-specified two things should learn both in one round trip. An empty
|
|
188
|
+
* `issues` array means the compiler can run the query as written, on the
|
|
189
|
+
* returned `tier`.
|
|
190
|
+
*/
|
|
191
|
+
export declare function validateQuery(query: QueryV1): QueryValidation;
|
|
192
|
+
//# sourceMappingURL=query.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyDG;AAEH,OAAO,KAAK,EAAgB,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAC9D,OAAO,EAML,KAAK,WAAW,EAChB,KAAK,QAAQ,EACb,KAAK,gBAAgB,EACtB,MAAM,eAAe,CAAC;AAEvB;;;;;;;;;;;GAWG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,SAAS,WAAW,EAAE,CAEjF;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,gBAAgB,GAAG,SAAS,WAAW,EAAE,CAGlF;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,gBAAgB,EAAE,SAAS,EAAE,WAAW,GAAG,MAAM,CAIxF;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,gBAAgB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,QAAQ,EAAE,CAAC,CAM1E,CAAC;AAyCF,wDAAwD;AACxD,MAAM,MAAM,SAAS,GAAG,WAAW,GAAG,SAAS,CAAC;AAyBhD;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,gBAAgB,EAAE,KAAK,EAAE,OAAO,GAAG,SAAS,CAO7E;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,gBAAgB,EACxB,IAAI,GAAE,SAAuB,GAC5B,SAAS,MAAM,EAAE,CAUnB;AAED,mFAAmF;AACnF,MAAM,MAAM,cAAc;AACxB,8CAA8C;AAC5C,gBAAgB;AAClB,mGAAmG;GACjG,sBAAsB;AACxB,sDAAsD;GACpD,mBAAmB;AACrB,uFAAuF;GACrF,sBAAsB;AACxB,sFAAsF;GACpF,yBAAyB;AAC3B,2CAA2C;GACzC,oBAAoB;AACtB,qDAAqD;GACnD,gBAAgB;AAClB,iDAAiD;GAC/C,iBAAiB;AACnB,yEAAyE;GACvE,mBAAmB;AACrB,+EAA+E;GAC7E,qBAAqB;AACvB,4FAA4F;GAC1F,qBAAqB,CAAC;AAE1B,yCAAyC;AACzC,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,cAAc,CAAC;IACrB,gFAAgF;IAChF,IAAI,EAAE,MAAM,CAAC;IACb,iEAAiE;IACjE,OAAO,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC9B;AAED,8DAA8D;AAC9D,MAAM,WAAW,eAAe;IAC9B,0CAA0C;IAC1C,MAAM,EAAE,SAAS,UAAU,EAAE,CAAC;IAC9B,mEAAmE;IACnE,MAAM,CAAC,EAAE,gBAAgB,CAAC;IAC1B,0DAA0D;IAC1D,IAAI,CAAC,EAAE,SAAS,CAAC;CAClB;AAOD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,SAAS,QAAQ,EAAE,CAK9E;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,gBAAgB,GAAG,SAAS,QAAQ,EAAE,CAI7E;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,gBAAgB,GAAG,SAAS,MAAM,EAAE,CAIjF;AAmCD;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,eAAe,CA+L7D"}
|
package/dist/query.js
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **Registry validation for the query DSL** (ADR 0051 §3, design sketch §C.1).
|
|
3
|
+
*
|
|
4
|
+
* `@uptimizr/schema`'s `queryV1Schema` answers "is this a well-formed query?" —
|
|
5
|
+
* the right types, bounded strings, no unknown keys, no free-form expression.
|
|
6
|
+
* It cannot answer "is `mesh_sources` a metric, does it accept a `cameraMode`
|
|
7
|
+
* filter, and is `limit: 900` within its cap", because those are questions about
|
|
8
|
+
* the **vocabulary**, and the vocabulary is this package. (`@uptimizr/schema`
|
|
9
|
+
* also cannot ask them: it is this package's dependency, not the other way
|
|
10
|
+
* round.)
|
|
11
|
+
*
|
|
12
|
+
* So the split is: shape at the edge, vocabulary here. {@link validateQuery}
|
|
13
|
+
* takes a structurally-valid query and returns the registry's objections as
|
|
14
|
+
* data — a code, the path that offended, a human message, and, where it helps,
|
|
15
|
+
* the list of values that *would* have been accepted. The collector turns them
|
|
16
|
+
* into one `400`; a test can assert on the codes without matching prose.
|
|
17
|
+
*
|
|
18
|
+
* Nothing here executes anything, touches a store, or knows what a dialect is.
|
|
19
|
+
* The compilation itself lives in `@uptimizr/db`'s `query/dsl`.
|
|
20
|
+
*
|
|
21
|
+
* ## The two compilation tiers
|
|
22
|
+
*
|
|
23
|
+
* A query runs on one of two tiers (design sketch §C.2), and which one is a
|
|
24
|
+
* registry question as well:
|
|
25
|
+
*
|
|
26
|
+
* - **delegated** — the metric's *existing* aggregation builder runs, so the
|
|
27
|
+
* result is what the canned endpoint has always returned. This is every query
|
|
28
|
+
* at the metric's own grain, and the only tier a spatial or percentile metric
|
|
29
|
+
* has.
|
|
30
|
+
* - **generic** — a metric that declares `genericGroupBy` (a portable
|
|
31
|
+
* count/sum over promoted columns) is recomputed at an arbitrary grain by one
|
|
32
|
+
* shared builder: any subset of the dimensions it declares, plus the `event`
|
|
33
|
+
* and `device` predicates no canned builder ever took.
|
|
34
|
+
*
|
|
35
|
+
* {@link queryTier} answers which, from the query and the registry alone — no
|
|
36
|
+
* store, no dialect, no SQL.
|
|
37
|
+
*
|
|
38
|
+
* ## What is accepted
|
|
39
|
+
*
|
|
40
|
+
* - `metric` — any registry id with a `builder`. The two builder-less
|
|
41
|
+
* **resource** entries (`session_meta`, `scene_representation`) are single
|
|
42
|
+
* store reads, not aggregations, and are rejected.
|
|
43
|
+
* - `dimensions` — omitted, or the metric's {@link nativeDimensions}, on either
|
|
44
|
+
* tier; on the generic tier additionally any subset of the metric's declared
|
|
45
|
+
* dimensions that the tier can render ({@link genericDimensions}).
|
|
46
|
+
* - `filters` — the filter ids the metric declares, plus the ids it carries in
|
|
47
|
+
* its path (a session trajectory's `session`), which are required.
|
|
48
|
+
* `filters.event` (an ADR 0038 predicate) and `filters.device` are
|
|
49
|
+
* **generic-tier only**: no canned builder takes either.
|
|
50
|
+
* - `segment` / `compare.segment` — dimensions the metric can hold fixed: an
|
|
51
|
+
* accepted filter on either tier, any renderable dimension on the generic one.
|
|
52
|
+
* - `order` — a measure column, on the generic tier or on a **ranked** delegated
|
|
53
|
+
* metric. A series, a hotspot grid and a single-row record each already have
|
|
54
|
+
* the one order that means anything, and re-sorting them would be noise.
|
|
55
|
+
* - `compare` and `explain` — accepted on both tiers. Neither changes the SQL of
|
|
56
|
+
* the query itself, so neither is a tier question.
|
|
57
|
+
* - `limit` — at most the metric's `limits.maxRows`.
|
|
58
|
+
*/
|
|
59
|
+
import { DIMENSION_ROW_COLUMNS, FILTER_TARGETS, GENERIC_DIMENSIONS, getMetric, isResourceMetric, } from "./registry.js";
|
|
60
|
+
/**
|
|
61
|
+
* The dimensions a metric's rows are **actually keyed by** — its native grain —
|
|
62
|
+
* as opposed to the dimensions it can merely be *filtered* or *regrouped* by,
|
|
63
|
+
* which is what `MetricDefinition.dimensions` lists.
|
|
64
|
+
*
|
|
65
|
+
* A thin accessor over registry data since #304: the grain is declared on each
|
|
66
|
+
* entry as `grainDimensions` rather than derived from `row.shape` at call time.
|
|
67
|
+
* `top_meshes` declares `["mesh", "session"]` but is keyed by `["mesh"]`, so a
|
|
68
|
+
* `dimensions: ["session"]` query is a *regrouping* (the generic tier) rather
|
|
69
|
+
* than the metric's own result. `src/__tests__/registry.test.ts` keeps the old
|
|
70
|
+
* derivation, as the gate on the declaration.
|
|
71
|
+
*/
|
|
72
|
+
export function nativeDimensions(metric) {
|
|
73
|
+
return metric.grainDimensions;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The dimensions this metric can be **grouped by** on the generic tier: the ones
|
|
77
|
+
* it declares, intersected with the ones that tier can render at all. Empty for
|
|
78
|
+
* a metric with no `genericGroupBy` — a spatial or percentile metric is stuck
|
|
79
|
+
* with its own grain, and says so.
|
|
80
|
+
*/
|
|
81
|
+
export function genericDimensions(metric) {
|
|
82
|
+
if (metric.genericGroupBy == null)
|
|
83
|
+
return [];
|
|
84
|
+
return metric.dimensions.filter((dimension) => GENERIC_DIMENSIONS.includes(dimension));
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The row column a dimension is projected as for this metric.
|
|
88
|
+
*
|
|
89
|
+
* A metric already keyed by the dimension keeps its own spelling —
|
|
90
|
+
* `top_input_actions` calls the `name` column `action`, `mesh_interaction_kinds`
|
|
91
|
+
* calls it `kind` — so regrouping a metric never renames a column it already
|
|
92
|
+
* returns. Anything else takes the canonical name from
|
|
93
|
+
* {@link DIMENSION_ROW_COLUMNS}; `cameraMode`, which no aggregation projects,
|
|
94
|
+
* becomes `camera_mode`.
|
|
95
|
+
*/
|
|
96
|
+
export function dimensionColumn(metric, dimension) {
|
|
97
|
+
const candidates = DIMENSION_ROW_COLUMNS[dimension];
|
|
98
|
+
const own = candidates.find((column) => column in metric.row.shape);
|
|
99
|
+
return own ?? candidates[0] ?? "camera_mode";
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Filters a metric cannot be queried without, beyond the ones it carries in its
|
|
103
|
+
* path. The collector declares these required in its querystring schema
|
|
104
|
+
* (`funnelQueryParams.steps`, `meshUvHeatmapQueryParams.mesh`) and the builders
|
|
105
|
+
* take them as non-optional options; the registry records requiredness in prose
|
|
106
|
+
* rather than as data, so the two exceptions are listed here — once, in the
|
|
107
|
+
* vocabulary package, for the DSL and the generated tool catalog to share.
|
|
108
|
+
*/
|
|
109
|
+
export const REQUIRED_FILTERS = {
|
|
110
|
+
funnel: ["steps"],
|
|
111
|
+
mesh_uv_heatmap: ["mesh"],
|
|
112
|
+
// An insight is a metric computed *over* another metric, so `baseline` has no
|
|
113
|
+
// meaning until its subject is named (ADR 0051 §4).
|
|
114
|
+
insight_baseline: ["metric"],
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* Filter ids that never travel in a DSL `filters` object: the time window is the
|
|
118
|
+
* required `range`, and `format` selects the response envelope rather than
|
|
119
|
+
* narrowing anything, so it is a top-level field.
|
|
120
|
+
*/
|
|
121
|
+
const NON_FILTER_IDS = ["since", "until", "format"];
|
|
122
|
+
/**
|
|
123
|
+
* The two filters only the generic tier can apply, and what each one does.
|
|
124
|
+
* Declared as data so the "generic-tier only" line in the docs and the message a
|
|
125
|
+
* client actually receives cannot drift.
|
|
126
|
+
*/
|
|
127
|
+
const GENERIC_ONLY_FILTERS = {
|
|
128
|
+
event: "scoping a metric to an event predicate (ADR 0038)",
|
|
129
|
+
device: "filtering by the device attributes of a session",
|
|
130
|
+
};
|
|
131
|
+
/** Units whose column can be ordered by: a measure, not a label or a key. */
|
|
132
|
+
const ORDERABLE_UNITS = new Set([
|
|
133
|
+
"count",
|
|
134
|
+
"sessions",
|
|
135
|
+
"ms",
|
|
136
|
+
"s",
|
|
137
|
+
"fps",
|
|
138
|
+
"ratio",
|
|
139
|
+
"percent",
|
|
140
|
+
"world-units",
|
|
141
|
+
"radians",
|
|
142
|
+
"bytes",
|
|
143
|
+
]);
|
|
144
|
+
/**
|
|
145
|
+
* Grains whose result is a **ranked list** — the only delegated shape where the
|
|
146
|
+
* caller choosing the order means anything. A `bucket` metric walks an axis, a
|
|
147
|
+
* `bin`/`voxel` metric is a grid, and a `project` metric is one row; re-sorting
|
|
148
|
+
* any of those says nothing and would silently reinterpret the result.
|
|
149
|
+
*/
|
|
150
|
+
const RANKED_GRAINS = new Set(["mesh", "scene", "session", "row"]);
|
|
151
|
+
/** Whether the asked-for dimensions are exactly the metric's own grain. */
|
|
152
|
+
function isNativeGrain(metric, dimensions) {
|
|
153
|
+
if (dimensions == null)
|
|
154
|
+
return true;
|
|
155
|
+
const asked = new Set(dimensions);
|
|
156
|
+
const grain = nativeDimensions(metric);
|
|
157
|
+
return asked.size === grain.length && grain.every((dimension) => asked.has(dimension));
|
|
158
|
+
}
|
|
159
|
+
/** The dimensions a metric can hold fixed through one of its ordinary filters. */
|
|
160
|
+
function segmentableAsFilter(metric) {
|
|
161
|
+
return new Set(queryableFilters(metric));
|
|
162
|
+
}
|
|
163
|
+
/** Every `segment` key a query carries, its own and its comparison's. */
|
|
164
|
+
function segmentKeys(query) {
|
|
165
|
+
const compared = query.compare != null && "segment" in query.compare ? Object.keys(query.compare.segment) : [];
|
|
166
|
+
return [...Object.keys(query.segment ?? {}), ...compared];
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Which tier a query runs on.
|
|
170
|
+
*
|
|
171
|
+
* The delegated tier is preferred wherever it can answer, so an unchanged query
|
|
172
|
+
* keeps returning the canned endpoint's exact bytes and the generic builder is
|
|
173
|
+
* reached only by asking for something the metric's own builder cannot do: a
|
|
174
|
+
* different grain, an event or device predicate, or a segment on a dimension
|
|
175
|
+
* that is not one of its filters.
|
|
176
|
+
*/
|
|
177
|
+
export function queryTier(metric, query) {
|
|
178
|
+
if (metric.genericGroupBy == null)
|
|
179
|
+
return "delegated";
|
|
180
|
+
if (!isNativeGrain(metric, query.dimensions))
|
|
181
|
+
return "generic";
|
|
182
|
+
if (query.filters?.event != null || query.filters?.device != null)
|
|
183
|
+
return "generic";
|
|
184
|
+
const filterable = segmentableAsFilter(metric);
|
|
185
|
+
if (segmentKeys(query).some((key) => !filterable.has(key)))
|
|
186
|
+
return "generic";
|
|
187
|
+
return "delegated";
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The columns a query may `order` by — empty when the metric's result has no
|
|
191
|
+
* caller-choosable order at all (see {@link RANKED_GRAINS}).
|
|
192
|
+
*
|
|
193
|
+
* On the generic tier that is exactly the measures the generic builder projects;
|
|
194
|
+
* on the delegated tier it is the metric's own numeric measure columns, because
|
|
195
|
+
* those are the only ones present in every row it returns.
|
|
196
|
+
*/
|
|
197
|
+
export function orderableColumns(metric, tier = "delegated") {
|
|
198
|
+
if (tier === "generic") {
|
|
199
|
+
return (metric.genericGroupBy?.measures ?? []).map((measure) => measure.column);
|
|
200
|
+
}
|
|
201
|
+
if (!RANKED_GRAINS.has(metric.grain))
|
|
202
|
+
return [];
|
|
203
|
+
return Object.entries(metric.columns)
|
|
204
|
+
.filter(([, semantics]) => semantics.measure === true || ORDERABLE_UNITS.has(semantics.unit ?? ""))
|
|
205
|
+
.map(([name]) => name);
|
|
206
|
+
}
|
|
207
|
+
/** `` `a`, `b` `` — for a message listing what is accepted. */
|
|
208
|
+
function list(values) {
|
|
209
|
+
return values.length === 0 ? "none" : values.map((value) => `\`${value}\``).join(", ");
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The filter ids a metric accepts in a DSL query: the ones it declares, plus the
|
|
213
|
+
* ones it carries in its path (a session trajectory's `session` is a filter here
|
|
214
|
+
* even though the canned route spells it as a path segment), minus the three
|
|
215
|
+
* that are not filters in the DSL.
|
|
216
|
+
*/
|
|
217
|
+
export function queryableFilters(metric) {
|
|
218
|
+
const ids = new Set(metric.filters);
|
|
219
|
+
for (const id of metric.endpoint?.pathParams ?? [])
|
|
220
|
+
ids.add(id);
|
|
221
|
+
for (const id of NON_FILTER_IDS)
|
|
222
|
+
ids.delete(id);
|
|
223
|
+
return [...ids];
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Filters a DSL query for this metric must carry: everything the canned endpoint
|
|
227
|
+
* puts in its path (always required — a trajectory without a session is not a
|
|
228
|
+
* query) plus the metric's {@link REQUIRED_FILTERS}.
|
|
229
|
+
*/
|
|
230
|
+
export function requiredFilters(metric) {
|
|
231
|
+
const ids = new Set(metric.endpoint?.pathParams ?? []);
|
|
232
|
+
for (const id of REQUIRED_FILTERS[metric.id] ?? [])
|
|
233
|
+
ids.add(id);
|
|
234
|
+
return [...ids];
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* The dimensions a metric can hold fixed as a `segment`: an accepted filter on
|
|
238
|
+
* either tier, or any dimension the generic tier can render.
|
|
239
|
+
*/
|
|
240
|
+
export function segmentableDimensions(metric) {
|
|
241
|
+
const declared = new Set(metric.dimensions);
|
|
242
|
+
const holdable = new Set([...segmentableAsFilter(metric), ...genericDimensions(metric)]);
|
|
243
|
+
return [...holdable].filter((id) => declared.has(id));
|
|
244
|
+
}
|
|
245
|
+
/** Validate one `segment` object — the query's own, or its `compare.segment`. */
|
|
246
|
+
function checkSegment(metric, segment, path, issues) {
|
|
247
|
+
if (segment == null)
|
|
248
|
+
return;
|
|
249
|
+
const declared = new Set(metric.dimensions);
|
|
250
|
+
const holdable = segmentableDimensions(metric);
|
|
251
|
+
const holdableSet = new Set(holdable);
|
|
252
|
+
for (const key of Object.keys(segment)) {
|
|
253
|
+
if (!declared.has(key)) {
|
|
254
|
+
issues.push({
|
|
255
|
+
code: "unknown_dimension",
|
|
256
|
+
path: `${path}.${key}`,
|
|
257
|
+
message: `"${metric.id}" has no dimension "${key}". It declares ${list([...declared])}.`,
|
|
258
|
+
accepted: [...declared],
|
|
259
|
+
});
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (holdableSet.has(key))
|
|
263
|
+
continue;
|
|
264
|
+
issues.push({
|
|
265
|
+
code: "unsupported_segment",
|
|
266
|
+
path: `${path}.${key}`,
|
|
267
|
+
message: `"${metric.id}" cannot be held fixed at a "${key}": its builder takes no such filter and ` +
|
|
268
|
+
`it has no generic group-by tier. It can be segmented by ${list(holdable)}.`,
|
|
269
|
+
accepted: holdable,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Check a structurally-valid query against the registry.
|
|
275
|
+
*
|
|
276
|
+
* Every objection is collected rather than thrown on the first one: an agent
|
|
277
|
+
* that mis-specified two things should learn both in one round trip. An empty
|
|
278
|
+
* `issues` array means the compiler can run the query as written, on the
|
|
279
|
+
* returned `tier`.
|
|
280
|
+
*/
|
|
281
|
+
export function validateQuery(query) {
|
|
282
|
+
const issues = [];
|
|
283
|
+
const metric = getMetric(query.metric);
|
|
284
|
+
if (!metric) {
|
|
285
|
+
issues.push({
|
|
286
|
+
code: "unknown_metric",
|
|
287
|
+
path: "metric",
|
|
288
|
+
message: `unknown metric "${query.metric}". The metric vocabulary is the registry — read it from ` +
|
|
289
|
+
"`GET /api/v1/openapi.json` or the MCP resource `uptimizr://capabilities`.",
|
|
290
|
+
});
|
|
291
|
+
return { issues };
|
|
292
|
+
}
|
|
293
|
+
if (metric.builder === undefined) {
|
|
294
|
+
// Two kinds of entry have no `build*` for the DSL to delegate to: a stored
|
|
295
|
+
// record (`session_meta`, `scene_representation`) and a **derived** insight
|
|
296
|
+
// primitive, which is computed in TypeScript *over* another metric's bucket
|
|
297
|
+
// series (ADR 0051 §4). Neither can be grouped, filtered or summarised by a
|
|
298
|
+
// query, and both are served on an endpoint of their own — so the honest
|
|
299
|
+
// answer names it rather than failing later in the compiler.
|
|
300
|
+
issues.push({
|
|
301
|
+
code: "metric_not_queryable",
|
|
302
|
+
path: "metric",
|
|
303
|
+
message: isResourceMetric(metric)
|
|
304
|
+
? `"${metric.id}" is a stored record rather than an aggregation, so it has nothing to ` +
|
|
305
|
+
`group, filter or summarise. Read it from its own endpoint (${metric.endpoint?.path ?? "—"}).`
|
|
306
|
+
: `"${metric.id}" is a derived insight computed over another metric rather than an ` +
|
|
307
|
+
`aggregation of its own, so the query DSL cannot compile it. Read it from its own ` +
|
|
308
|
+
`endpoint (${metric.endpoint?.path ?? "—"}), naming the metric to analyse there.`,
|
|
309
|
+
});
|
|
310
|
+
return { issues };
|
|
311
|
+
}
|
|
312
|
+
const tier = queryTier(metric, query);
|
|
313
|
+
// --- dimensions ---------------------------------------------------------
|
|
314
|
+
const native = nativeDimensions(metric);
|
|
315
|
+
// A repeated dimension groups by the same column twice: harmless in SQL, but
|
|
316
|
+
// it means the caller asked for something other than what they would get, and
|
|
317
|
+
// it makes `dimensions` compare equal to a grain it is not.
|
|
318
|
+
const seen = new Set();
|
|
319
|
+
(query.dimensions ?? []).forEach((dimension, index) => {
|
|
320
|
+
if (seen.has(dimension)) {
|
|
321
|
+
issues.push({
|
|
322
|
+
code: "unknown_dimension",
|
|
323
|
+
path: `dimensions[${index}]`,
|
|
324
|
+
message: `\`dimensions\` names "${dimension}" twice; each dimension groups once.`,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
seen.add(dimension);
|
|
328
|
+
});
|
|
329
|
+
if (query.dimensions != null && !isNativeGrain(metric, query.dimensions)) {
|
|
330
|
+
const declared = new Set(metric.dimensions);
|
|
331
|
+
const groupable = new Set(genericDimensions(metric));
|
|
332
|
+
query.dimensions.forEach((dimension, index) => {
|
|
333
|
+
if (!declared.has(dimension)) {
|
|
334
|
+
issues.push({
|
|
335
|
+
code: "unknown_dimension",
|
|
336
|
+
path: `dimensions[${index}]`,
|
|
337
|
+
message: `"${metric.id}" has no dimension "${dimension}". It declares ${list([...declared])}.`,
|
|
338
|
+
accepted: [...declared],
|
|
339
|
+
});
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (metric.genericGroupBy == null) {
|
|
343
|
+
issues.push({
|
|
344
|
+
code: "dimension_not_native",
|
|
345
|
+
path: `dimensions[${index}]`,
|
|
346
|
+
message: `"${metric.id}" can be filtered by "${dimension}" but is not grouped by it: its rows ` +
|
|
347
|
+
`are keyed by ${list([...native])}, and its measure cannot be recomputed at another ` +
|
|
348
|
+
"grain (it is a spatial binning or a percentile). Pass it as a filter instead.",
|
|
349
|
+
accepted: [...native],
|
|
350
|
+
});
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (!groupable.has(dimension)) {
|
|
354
|
+
issues.push({
|
|
355
|
+
code: "dimension_not_groupable",
|
|
356
|
+
path: `dimensions[${index}]`,
|
|
357
|
+
message: `"${metric.id}" declares "${dimension}" but the generic group-by tier cannot render ` +
|
|
358
|
+
`it. Group by ${list([...groupable])}, or pass it as a filter.`,
|
|
359
|
+
accepted: [...groupable],
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
// --- filters ------------------------------------------------------------
|
|
365
|
+
const accepted = queryableFilters(metric);
|
|
366
|
+
const acceptedSet = new Set(accepted);
|
|
367
|
+
for (const key of Object.keys(query.filters ?? {})) {
|
|
368
|
+
if (key === "event" || key === "device") {
|
|
369
|
+
if (metric.genericGroupBy != null) {
|
|
370
|
+
// `gpuTier` is in the published grammar but nothing captures it: the
|
|
371
|
+
// `session_start` device payload carries `engine`, `renderer`,
|
|
372
|
+
// `isMobile` and the two UA-derived families, and no connector reports a
|
|
373
|
+
// tier. Accepting it would match nothing and read as "no such device",
|
|
374
|
+
// which is the one answer worse than a refusal.
|
|
375
|
+
if (key === "device" && query.filters?.device?.gpuTier != null) {
|
|
376
|
+
issues.push({
|
|
377
|
+
code: "unsupported_filter",
|
|
378
|
+
path: "filters.device.gpuTier",
|
|
379
|
+
message: "no connector reports a GPU tier, so `filters.device.gpuTier` would match nothing " +
|
|
380
|
+
"rather than narrow anything. The device attributes that exist are `os` and " +
|
|
381
|
+
"`browser` (both derived from the User-Agent at ingestion); group by " +
|
|
382
|
+
"`device.renderer` for the GPU itself.",
|
|
383
|
+
accepted: ["os", "browser"],
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
issues.push({
|
|
389
|
+
code: "unsupported_feature",
|
|
390
|
+
path: `filters.${key}`,
|
|
391
|
+
message: `${GENERIC_ONLY_FILTERS[key]} needs the generic group-by tier, and "${metric.id}" has ` +
|
|
392
|
+
"none: its measure is a spatial binning or a percentile, which cannot be recomputed " +
|
|
393
|
+
"over an arbitrary subset of events. Scope it with the filters it declares instead: " +
|
|
394
|
+
`${list([...accepted])}.`,
|
|
395
|
+
accepted: [...accepted],
|
|
396
|
+
});
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (acceptedSet.has(key))
|
|
400
|
+
continue;
|
|
401
|
+
issues.push({
|
|
402
|
+
code: "unsupported_filter",
|
|
403
|
+
path: `filters.${key}`,
|
|
404
|
+
message: `"${metric.id}" does not accept the filter "${key}". It accepts ${list([...accepted])}.`,
|
|
405
|
+
accepted: [...accepted],
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
for (const id of requiredFilters(metric)) {
|
|
409
|
+
if (query.filters?.[id] != null)
|
|
410
|
+
continue;
|
|
411
|
+
issues.push({
|
|
412
|
+
code: "missing_filter",
|
|
413
|
+
path: `filters.${id}`,
|
|
414
|
+
message: `"${metric.id}" cannot be queried without the filter "${id}": ${FILTER_TARGETS[id].description}`,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
// --- segment / compare --------------------------------------------------
|
|
418
|
+
checkSegment(metric, query.segment, "segment", issues);
|
|
419
|
+
if (query.compare != null && "segment" in query.compare) {
|
|
420
|
+
checkSegment(metric, query.compare.segment, "compare.segment", issues);
|
|
421
|
+
}
|
|
422
|
+
// --- order --------------------------------------------------------------
|
|
423
|
+
if (query.order != null) {
|
|
424
|
+
const orderable = orderableColumns(metric, tier);
|
|
425
|
+
if (!orderable.includes(query.order.by)) {
|
|
426
|
+
issues.push({
|
|
427
|
+
code: "unsupported_order",
|
|
428
|
+
path: "order.by",
|
|
429
|
+
message: orderable.length === 0
|
|
430
|
+
? `"${metric.id}" returns its rows in the one order that means anything (a time axis, ` +
|
|
431
|
+
"a grid of cells, or a single record), so it cannot be reordered. Omit `order`."
|
|
432
|
+
: `"${metric.id}" cannot be ordered by "${query.order.by}". Order by one of its ` +
|
|
433
|
+
`measure columns: ${list(orderable)}.`,
|
|
434
|
+
accepted: orderable,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// --- limit --------------------------------------------------------------
|
|
439
|
+
// `limit` is a filter like any other, spelled at the top level because every
|
|
440
|
+
// bounded result has one. A metric whose builder takes no row cap (a one-row
|
|
441
|
+
// summary, a fixed-width histogram) must say so rather than accept the
|
|
442
|
+
// parameter and ignore it. The generic tier always bounds its own output, so
|
|
443
|
+
// it takes a `limit` whatever the canned endpoint does.
|
|
444
|
+
if (query.limit != null && tier === "delegated" && !acceptedSet.has("limit")) {
|
|
445
|
+
issues.push({
|
|
446
|
+
code: "unsupported_filter",
|
|
447
|
+
path: "limit",
|
|
448
|
+
message: `"${metric.id}" returns a fixed result (one row per ${metric.grain}) and takes no row cap. ` +
|
|
449
|
+
"Omit `limit`.",
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
else if (query.limit != null && query.limit > metric.limits.maxRows) {
|
|
453
|
+
issues.push({
|
|
454
|
+
code: "limit_too_large",
|
|
455
|
+
path: "limit",
|
|
456
|
+
message: `"${metric.id}" returns at most ${metric.limits.maxRows} rows; ${query.limit} was asked for.`,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
return { issues, metric, tier };
|
|
460
|
+
}
|
|
461
|
+
//# sourceMappingURL=query.js.map
|