@uptimizr/metrics 0.0.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 +69 -0
- package/LICENSE +201 -0
- package/README.md +124 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/registry.d.ts +4119 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +3484 -0
- package/dist/registry.js.map +1 -0
- package/llms.txt +26 -0
- package/package.json +65 -0
package/dist/registry.js
ADDED
|
@@ -0,0 +1,3484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The semantic **metric registry** (ADR 0051 §1, design sketch §A).
|
|
3
|
+
*
|
|
4
|
+
* One {@link MetricDefinition} per `build*` aggregation in `@uptimizr/db`'s
|
|
5
|
+
* `query/aggregations.ts`, declaring what the metric measures, what one row *is*,
|
|
6
|
+
* the unit and semantics of every column, the filters it accepts, the collector
|
|
7
|
+
* endpoint it is served on, the capture channels that must be enabled for it to
|
|
8
|
+
* have data, and how to read the result. Downstream consumers (the agent tool
|
|
9
|
+
* catalog, OpenAPI, the MCP capabilities resource, the docs tables) are meant to
|
|
10
|
+
* be **derived** from this file rather than hand-maintained, so coverage cannot
|
|
11
|
+
* drift.
|
|
12
|
+
*
|
|
13
|
+
* **This module is pure data.** It imports `zod` and a *type-only* declaration
|
|
14
|
+
* from `@uptimizr/schema`; it performs no I/O, touches no `node:` built-in and
|
|
15
|
+
* holds no reference to a store, a dialect or a database driver. That is the
|
|
16
|
+
* whole reason this package exists: `@uptimizr/db` depends on `@duckdb/node-api`
|
|
17
|
+
* (a ~37 MB native binding), and the browser/CLI consumers of the registry
|
|
18
|
+
* (`@uptimizr/agent-core`, `@uptimizr/mcp`, `@uptimizr/react`) can never use a
|
|
19
|
+
* DuckDB driver — so the registry lives here, in a package whose only runtime
|
|
20
|
+
* dependencies are `zod` and `@uptimizr/schema`.
|
|
21
|
+
*
|
|
22
|
+
* **Numbers are numbers.** Every numeric column is a strict `z.number()`, so a
|
|
23
|
+
* `row` schema describes exactly what the collector emits. The engines that
|
|
24
|
+
* string-encode 64-bit integers or decimals (ClickHouse over HTTP) are
|
|
25
|
+
* normalised by `coerceRows` in every store's query runner, at the one point
|
|
26
|
+
* rows leave the driver (ADR 0051 §2, `query/coerce.ts`) — the schema is the
|
|
27
|
+
* contract, the store edge is what upholds it.
|
|
28
|
+
*
|
|
29
|
+
* The invariant this file exists to enforce: **a new aggregation is not done
|
|
30
|
+
* until it has a registry entry.** This package's `src/__tests__/registry.test.ts`
|
|
31
|
+
* fails the build when a name in {@link AGGREGATION_BUILDER_NAMES} has no entry;
|
|
32
|
+
* `@uptimizr/db`'s own `src/__tests__/registry.test.ts` fails when that list and
|
|
33
|
+
* the `build*` exports of `aggregations.ts` disagree; and the collector's
|
|
34
|
+
* `registryRoutes.test.ts` fails when an entry's endpoint or filters drift from
|
|
35
|
+
* the Zod querystring that actually serves it.
|
|
36
|
+
*/
|
|
37
|
+
import { z } from "zod";
|
|
38
|
+
/**
|
|
39
|
+
* Every exported `build*` aggregation name in `@uptimizr/db`'s
|
|
40
|
+
* `query/aggregations.ts`. The registry must cover them all.
|
|
41
|
+
*
|
|
42
|
+
* Declared here as literal data rather than derived with
|
|
43
|
+
* `keyof typeof aggregations`, because deriving it would make this package
|
|
44
|
+
* depend on `@uptimizr/db` — exactly the edge (and the ~37 MB native DuckDB
|
|
45
|
+
* binding behind it) that this package exists to break. The link is not lost,
|
|
46
|
+
* only moved from the compiler to CI: `@uptimizr/db`'s `registry.test.ts`
|
|
47
|
+
* asserts at runtime that the set of its `build*` exports is exactly this list,
|
|
48
|
+
* so adding, renaming or deleting an aggregation without updating this list
|
|
49
|
+
* fails the build and names the offender.
|
|
50
|
+
*/
|
|
51
|
+
export const AGGREGATION_BUILDER_NAMES = [
|
|
52
|
+
"buildAggregateTrajectories",
|
|
53
|
+
"buildArPlacementAttempts",
|
|
54
|
+
"buildArPlacementSurfaces",
|
|
55
|
+
"buildArPlacementTimeToPlace",
|
|
56
|
+
"buildBacktrackRatio",
|
|
57
|
+
"buildBoundaryContacts",
|
|
58
|
+
"buildBoundaryHeatmap",
|
|
59
|
+
"buildBoundaryHeatmapStats",
|
|
60
|
+
"buildCameraDirectionHeatmap",
|
|
61
|
+
"buildCameraDistance",
|
|
62
|
+
"buildCameraGestures",
|
|
63
|
+
"buildCameraPositionHeatmap",
|
|
64
|
+
"buildCapabilityChanges",
|
|
65
|
+
"buildClickGazeRay",
|
|
66
|
+
"buildCompileStalls",
|
|
67
|
+
"buildDeadClicks",
|
|
68
|
+
"buildDistinctScenes",
|
|
69
|
+
"buildErrorHeatmap",
|
|
70
|
+
"buildEventTypeCounts",
|
|
71
|
+
"buildEventsDaily",
|
|
72
|
+
"buildFlowHeatmap",
|
|
73
|
+
"buildFpsHistogram",
|
|
74
|
+
"buildFrameTimePercentiles",
|
|
75
|
+
"buildFunnel",
|
|
76
|
+
"buildGazeHeatmap",
|
|
77
|
+
"buildGazeHeatmapStats",
|
|
78
|
+
"buildGraphicsDiagnosticCounts",
|
|
79
|
+
"buildHoverDwell",
|
|
80
|
+
"buildInteractionsBySource",
|
|
81
|
+
"buildJankRate",
|
|
82
|
+
"buildListSessions",
|
|
83
|
+
"buildLoadBounceFunnel",
|
|
84
|
+
"buildMeshBlindSpots",
|
|
85
|
+
"buildMeshDwell",
|
|
86
|
+
"buildMeshInteractionKinds",
|
|
87
|
+
"buildMeshUvHeatmap",
|
|
88
|
+
"buildNavigationStats",
|
|
89
|
+
"buildPerfByDevice",
|
|
90
|
+
"buildPerfByScene",
|
|
91
|
+
"buildPerfChurn",
|
|
92
|
+
"buildPerfDaily",
|
|
93
|
+
"buildPerfDistribution",
|
|
94
|
+
"buildPerfHeatmap",
|
|
95
|
+
"buildPerfSummary",
|
|
96
|
+
"buildPointerHeatmap",
|
|
97
|
+
"buildRageClicks",
|
|
98
|
+
"buildReachability",
|
|
99
|
+
"buildRenderScaleTruth",
|
|
100
|
+
"buildRenderingTechnology",
|
|
101
|
+
"buildResourcePercentiles",
|
|
102
|
+
"buildResourceSummary",
|
|
103
|
+
"buildSceneCoverage",
|
|
104
|
+
"buildSceneRetention",
|
|
105
|
+
"buildSessionTrajectory",
|
|
106
|
+
"buildStabilityCounts",
|
|
107
|
+
"buildTimeseries",
|
|
108
|
+
"buildTopInputActions",
|
|
109
|
+
"buildTopMeshes",
|
|
110
|
+
"buildTopMeshesBySource",
|
|
111
|
+
"buildTopMeshesTrend",
|
|
112
|
+
"buildTrackingQuality",
|
|
113
|
+
"buildVariantLeaderboard",
|
|
114
|
+
"buildViewCoverageHistogram",
|
|
115
|
+
"buildWorldHeatmap",
|
|
116
|
+
"buildWorldHeatmapStats",
|
|
117
|
+
"buildXrAbandonment",
|
|
118
|
+
"buildXrLocomotionComfort",
|
|
119
|
+
"buildXrRotationRate",
|
|
120
|
+
"buildXrSourceUsage",
|
|
121
|
+
];
|
|
122
|
+
/** Where each {@link DimensionId} reads from in the event model. */
|
|
123
|
+
export const DIMENSION_COLUMNS = {
|
|
124
|
+
scene: "events.scene_id",
|
|
125
|
+
session: "events.session_id",
|
|
126
|
+
mesh: "events.mesh",
|
|
127
|
+
name: "events.name (interaction/gesture kind, input action, custom name)",
|
|
128
|
+
source: "events.source",
|
|
129
|
+
event_type: "events.event_type",
|
|
130
|
+
cameraMode: "session_start.payload.scene.cameraType",
|
|
131
|
+
"device.engine": "session_start.payload.device.engine",
|
|
132
|
+
"device.renderer": "session_start.payload.device.renderer",
|
|
133
|
+
"device.isMobile": "session_start.payload.device.isMobile",
|
|
134
|
+
"device.browser": "session_start.payload.device.browser (derived from the UA, ADR 0042)",
|
|
135
|
+
"device.os": "session_start.payload.device.os (derived from the UA, ADR 0042)",
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* The single mapping from a request parameter to the option field it drives.
|
|
139
|
+
* Consumers (docs generator, OpenAPI, the DSL) read filter semantics here rather
|
|
140
|
+
* than re-deriving them from the route handlers.
|
|
141
|
+
*/
|
|
142
|
+
export const FILTER_TARGETS = {
|
|
143
|
+
since: {
|
|
144
|
+
option: "RangeOptions",
|
|
145
|
+
field: "since",
|
|
146
|
+
description: "Inclusive lower bound of the time range, epoch milliseconds.",
|
|
147
|
+
},
|
|
148
|
+
until: {
|
|
149
|
+
option: "RangeOptions",
|
|
150
|
+
field: "until",
|
|
151
|
+
description: "Exclusive upper bound of the time range, epoch milliseconds.",
|
|
152
|
+
},
|
|
153
|
+
scene: {
|
|
154
|
+
option: "SceneOptions",
|
|
155
|
+
field: "scene",
|
|
156
|
+
description: "Restrict to one developer-assigned scene id (ADR 0010).",
|
|
157
|
+
},
|
|
158
|
+
session: {
|
|
159
|
+
option: "SessionOptions",
|
|
160
|
+
field: "session",
|
|
161
|
+
description: "Scope the aggregate to a single session id.",
|
|
162
|
+
},
|
|
163
|
+
source: {
|
|
164
|
+
option: "SourceOptions",
|
|
165
|
+
field: "source",
|
|
166
|
+
description: "Restrict to one input source: mouse / touch / xr-controller / hand / … (ADR 0011).",
|
|
167
|
+
},
|
|
168
|
+
mesh: {
|
|
169
|
+
option: "MeshOptions",
|
|
170
|
+
field: "mesh",
|
|
171
|
+
description: "Restrict to one mesh/object name.",
|
|
172
|
+
},
|
|
173
|
+
region: {
|
|
174
|
+
option: "RegionOptions",
|
|
175
|
+
field: "region",
|
|
176
|
+
description: "World-space drill-down box `minX,minY,minZ,maxX,maxY,maxZ` (ADR 0040 §4). Omit for the whole scene.",
|
|
177
|
+
},
|
|
178
|
+
cameraMode: {
|
|
179
|
+
option: "CameraModeOptions",
|
|
180
|
+
field: "cameraType",
|
|
181
|
+
description: "Camera navigation model to scope to (ADR 0026). `viewer` maps to the stored `arc-rotate`, `first-person` to `free`.",
|
|
182
|
+
},
|
|
183
|
+
bins: {
|
|
184
|
+
option: "BuilderOptions",
|
|
185
|
+
field: "bins",
|
|
186
|
+
description: "Grid resolution per axis for a binned heatmap (1–500).",
|
|
187
|
+
},
|
|
188
|
+
limit: {
|
|
189
|
+
option: "BuilderOptions",
|
|
190
|
+
field: "limit",
|
|
191
|
+
description: "Maximum rows returned; the registry `limits.maxRows` is the hard cap.",
|
|
192
|
+
},
|
|
193
|
+
cellSize: {
|
|
194
|
+
option: "BuilderOptions",
|
|
195
|
+
field: "cellSize",
|
|
196
|
+
description: "Voxel / ground-cell edge in world units. Omit to let the collector derive it from the scene or region bounds (ADR 0040 §1).",
|
|
197
|
+
},
|
|
198
|
+
interval: {
|
|
199
|
+
option: "TimeseriesOptions",
|
|
200
|
+
field: "interval",
|
|
201
|
+
description: "Time-bucket width in seconds.",
|
|
202
|
+
},
|
|
203
|
+
type: {
|
|
204
|
+
option: "TimeseriesOptions",
|
|
205
|
+
field: "type",
|
|
206
|
+
description: "Restrict the volume to a single event type.",
|
|
207
|
+
},
|
|
208
|
+
bucket: {
|
|
209
|
+
option: "BuilderOptions",
|
|
210
|
+
field: "bucket",
|
|
211
|
+
description: "Histogram bin width in FPS.",
|
|
212
|
+
},
|
|
213
|
+
bucketMs: {
|
|
214
|
+
option: "BuilderOptions",
|
|
215
|
+
field: "bucketMs",
|
|
216
|
+
description: "Histogram bin width in milliseconds.",
|
|
217
|
+
},
|
|
218
|
+
bucketSize: {
|
|
219
|
+
option: "BuilderOptions",
|
|
220
|
+
field: "bucketSize",
|
|
221
|
+
description: "Histogram bin width in world units.",
|
|
222
|
+
},
|
|
223
|
+
minRepeats: {
|
|
224
|
+
option: "BuilderOptions",
|
|
225
|
+
field: "minRepeats",
|
|
226
|
+
description: "Minimum clicks in a window before it counts as a rage cluster.",
|
|
227
|
+
},
|
|
228
|
+
windowMs: {
|
|
229
|
+
option: "PerfChurnOptions",
|
|
230
|
+
field: "windowMs",
|
|
231
|
+
description: "How long before a session's end a perf dip still counts as correlated.",
|
|
232
|
+
},
|
|
233
|
+
fpsThreshold: {
|
|
234
|
+
option: "PerfChurnOptions",
|
|
235
|
+
field: "fpsThreshold",
|
|
236
|
+
description: "A `frame_perf` sample below this FPS counts as a dip.",
|
|
237
|
+
},
|
|
238
|
+
stallMs: {
|
|
239
|
+
option: "PerfChurnOptions",
|
|
240
|
+
field: "stallMs",
|
|
241
|
+
description: "A `compile_stall` at least this long (ms) counts as a dip.",
|
|
242
|
+
},
|
|
243
|
+
moveThreshold: {
|
|
244
|
+
option: "BuilderOptions",
|
|
245
|
+
field: "moveThreshold",
|
|
246
|
+
description: "Inter-sample distance (world units) above which a segment counts as active travel.",
|
|
247
|
+
},
|
|
248
|
+
rapidTurn: {
|
|
249
|
+
option: "BuilderOptions",
|
|
250
|
+
field: "rapidTurn",
|
|
251
|
+
description: "View-turn threshold in radians (0..π) above which a step counts as a rapid turn.",
|
|
252
|
+
},
|
|
253
|
+
centerX: {
|
|
254
|
+
option: "BuilderOptions",
|
|
255
|
+
field: "center[0]",
|
|
256
|
+
description: "X of the reference point distances are measured from.",
|
|
257
|
+
},
|
|
258
|
+
centerY: {
|
|
259
|
+
option: "BuilderOptions",
|
|
260
|
+
field: "center[1]",
|
|
261
|
+
description: "Y of the reference point distances are measured from.",
|
|
262
|
+
},
|
|
263
|
+
centerZ: {
|
|
264
|
+
option: "BuilderOptions",
|
|
265
|
+
field: "center[2]",
|
|
266
|
+
description: "Z of the reference point distances are measured from.",
|
|
267
|
+
},
|
|
268
|
+
severity: {
|
|
269
|
+
option: "ErrorHeatmapOptions",
|
|
270
|
+
field: "severity",
|
|
271
|
+
description: "`graphics_diagnostic` severity (info / warning / error / fatal). Setting it excludes JS runtime errors.",
|
|
272
|
+
},
|
|
273
|
+
category: {
|
|
274
|
+
option: "ErrorHeatmapOptions",
|
|
275
|
+
field: "category",
|
|
276
|
+
description: "`graphics_diagnostic` category (context-loss / validation / shader-compile / …). Setting it excludes JS runtime errors.",
|
|
277
|
+
},
|
|
278
|
+
errorKind: {
|
|
279
|
+
option: "ErrorHeatmapOptions",
|
|
280
|
+
field: "errorKind",
|
|
281
|
+
description: "`runtime_error` kind (error / unhandledrejection). Setting it excludes engine diagnostics.",
|
|
282
|
+
},
|
|
283
|
+
groupByOrigin: {
|
|
284
|
+
option: "BuilderOptions",
|
|
285
|
+
field: "groupByOrigin",
|
|
286
|
+
description: "Add the click-time standpoint voxel as a grouping dimension.",
|
|
287
|
+
},
|
|
288
|
+
originVoxel: {
|
|
289
|
+
option: "BuilderOptions",
|
|
290
|
+
field: "originVoxel",
|
|
291
|
+
description: "Restrict to clicks whose standpoint falls in this `vx,vy,vz` voxel.",
|
|
292
|
+
},
|
|
293
|
+
steps: {
|
|
294
|
+
option: "FunnelOptions",
|
|
295
|
+
field: "steps",
|
|
296
|
+
description: "Ordered funnel step predicates, JSON-encoded (ADR 0038). At least two.",
|
|
297
|
+
},
|
|
298
|
+
bands: {
|
|
299
|
+
option: "LoadBounceFunnelOptions",
|
|
300
|
+
field: "bands",
|
|
301
|
+
description: "Ascending, comma-separated load-time band boundaries in ms. Omit for the default `1000,3000,5000`.",
|
|
302
|
+
},
|
|
303
|
+
variant: {
|
|
304
|
+
option: "VariantLeaderboardOptions",
|
|
305
|
+
field: "variant",
|
|
306
|
+
description: "JSON funnel-step predicate selecting the variant events. Omit to treat every `custom` event as a variant.",
|
|
307
|
+
},
|
|
308
|
+
conversion: {
|
|
309
|
+
option: "VariantLeaderboardOptions",
|
|
310
|
+
field: "conversion",
|
|
311
|
+
description: "JSON funnel-step predicate for the success event. Omit to report views only.",
|
|
312
|
+
},
|
|
313
|
+
format: {
|
|
314
|
+
option: "ResultEnvelope",
|
|
315
|
+
field: "(response layer)",
|
|
316
|
+
description: "Result envelope: `full` (default — the bare rows, unchanged), `table` (the same rows plus " +
|
|
317
|
+
"a `meta` envelope), or `summary` (a bounded, self-describing digest: top rows or clusters " +
|
|
318
|
+
"or a trend, with shares, a sample size, a templated reading and the caveats).",
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
// --- Row-schema building blocks ------------------------------------------
|
|
322
|
+
//
|
|
323
|
+
// Numeric columns are **strict** `z.number()`: they describe what the collector
|
|
324
|
+
// actually emits. Engines that string-encode 64-bit integers or decimals (notably
|
|
325
|
+
// ClickHouse over HTTP) are normalised by `coerceRows` in every store's query
|
|
326
|
+
// runner, at the single point rows leave the driver (ADR 0051 §2) — so by the
|
|
327
|
+
// time a row reaches a consumer, a numeric column *is* a number. Do not
|
|
328
|
+
// reintroduce `z.coerce.number()` here: that would make the schema describe a
|
|
329
|
+
// wire format rather than the API contract, and would let a store regression
|
|
330
|
+
// pass unnoticed.
|
|
331
|
+
/** A numeric column (integer or fractional). */
|
|
332
|
+
const num = z.number();
|
|
333
|
+
/** A whole-number column: a `count(*)`, a distinct count, or a `floor()` bin. */
|
|
334
|
+
const int = z.number().int();
|
|
335
|
+
/**
|
|
336
|
+
* A nullable numeric column. Two things make a column nullable:
|
|
337
|
+
*
|
|
338
|
+
* 1. the metric is one an engine or connector may not report at all
|
|
339
|
+
* (`avg_render_scale` on a renderer with no dynamic resolution); or
|
|
340
|
+
* 2. it is an aggregate over a set that can be **empty** — `sum`, `avg`, `max`
|
|
341
|
+
* and `quantile` are SQL-`NULL` over no rows, and a summary metric with no
|
|
342
|
+
* `GROUP BY` still returns its one row when the range or filter matched
|
|
343
|
+
* nothing. A brand-new project reads `null`, not `0`.
|
|
344
|
+
*
|
|
345
|
+
* `null` means "no samples" and is never `0`-filled: a zero average is a claim
|
|
346
|
+
* about the data, absence is not.
|
|
347
|
+
*/
|
|
348
|
+
const numOrNull = z.number().nullable();
|
|
349
|
+
/** A nullable whole-number column — see {@link numOrNull} for when null occurs. */
|
|
350
|
+
const intOrNull = z.number().int().nullable();
|
|
351
|
+
/** An identifier or label column; `''` means "unknown"/"unattributed". */
|
|
352
|
+
const text = z.string();
|
|
353
|
+
/**
|
|
354
|
+
* An engine-formatted wall-clock column (`YYYY-MM-DD HH:MM:SS.mmm`). Every store
|
|
355
|
+
* normalises temporal projections to naive-UTC text, so this is a string on all
|
|
356
|
+
* four engines; the parity harness excludes these columns from comparison.
|
|
357
|
+
*/
|
|
358
|
+
const ts = z.string();
|
|
359
|
+
/** A date-granular column (`YYYY-MM-DD`), identical on every engine. */
|
|
360
|
+
const day = z.string();
|
|
361
|
+
/** `{ gx, gy, count }` — a screen/UV-space heatmap bin. */
|
|
362
|
+
const heatmapBinRow = z.object({ gx: int, gy: int, count: int });
|
|
363
|
+
/** `{ vx, vy, vz, count }` — an occupied voxel of a world-space heatmap. */
|
|
364
|
+
const voxelCountRow = z.object({ vx: int, vy: int, vz: int, count: int });
|
|
365
|
+
/** `{ cells, hits }` — un-truncated totals behind a top-N voxel list. */
|
|
366
|
+
const spatialStatsRow = z.object({ cells: int, hits: int });
|
|
367
|
+
// --- Reusable caveats -----------------------------------------------------
|
|
368
|
+
/** Continuous camera-pose capture is a sampling dial, not a census (ADR 0012). */
|
|
369
|
+
const CAMERA_SAMPLED = "`camera_sample` is a sampled channel (ADR 0012; ~1 Hz by default, idle samples suppressed). " +
|
|
370
|
+
"Counts are proportional to dwell, not exact, and change scale if `sampleCameraMs` / " +
|
|
371
|
+
"`sampling.camera` is retuned — compare shares, not absolute counts, across projects.";
|
|
372
|
+
/** Pointer movement is throttled; clicks are never dropped. */
|
|
373
|
+
const POINTER_SAMPLED = "`pointer_move` is throttled (`pointerMoveThrottleMs`, 250 ms by default, ADR 0012); " +
|
|
374
|
+
"`pointer_click` is a discrete event and is never sampled.";
|
|
375
|
+
/** The gaze raycast is opt-in. */
|
|
376
|
+
const GAZE_OPT_IN = "Requires the gaze raycast capture option (`capture.gaze`, **off by default**, ADR 0030 / " +
|
|
377
|
+
"ADR 0012). Without it `camera_sample` carries no `hitPoint` and this metric is empty.";
|
|
378
|
+
/**
|
|
379
|
+
* Aggregates over an empty set are SQL-NULL, and a single-row summary still
|
|
380
|
+
* returns its row when nothing matched. Shared by every metric whose columns can
|
|
381
|
+
* therefore come back `null` on a fresh project or an over-narrow filter.
|
|
382
|
+
*/
|
|
383
|
+
const EMPTY_SET_NULLS = "Over a range or filter that matched no samples the row is still returned, with every " +
|
|
384
|
+
"aggregate column `null` rather than `0` (only the plain counts stay numeric). Read `null` " +
|
|
385
|
+
"as 'no data' — do not sum it as zero.";
|
|
386
|
+
/** Small-sample warning shared by per-row leaderboards. */
|
|
387
|
+
const SMALL_SAMPLE = "Rows backed by fewer than ~30 events are directional only — rank changes at that size are noise.";
|
|
388
|
+
/** Spatial cell size drives the shape of every voxel/bin result. */
|
|
389
|
+
const CELL_SIZE_SENSITIVE = "The result shape depends on `cellSize`: when it is omitted the collector derives it from the " +
|
|
390
|
+
"selected scene's registered bounds or the `region` box (ADR 0040 §1), so voxel indices are " +
|
|
391
|
+
"only comparable between calls that used the same effective cell size.";
|
|
392
|
+
/** Truncated top-N spatial lists need their `*_stats` companion for totals. */
|
|
393
|
+
const TRUNCATED_TOP_N = "Rows are the busiest cells only, capped by `limit`. Read the matching `*_stats` metric for the " +
|
|
394
|
+
"true occupied-cell and hit totals before computing shares.";
|
|
395
|
+
/** Per-session then aggregate (ADR 0028) explanation. */
|
|
396
|
+
const PER_SESSION_THEN_AGGREGATE = "Computed per session and then aggregated across sessions (ADR 0028 §1), so neither a long " +
|
|
397
|
+
"session nor a high-refresh device dominates the headline.";
|
|
398
|
+
/** Wall-clock projections are engine-formatted. */
|
|
399
|
+
const ENGINE_TIMESTAMPS = "`started_at` / `ended_at` are engine-formatted naive-UTC strings and are excluded from " +
|
|
400
|
+
"cross-engine parity; derive durations from them, do not compare them byte-for-byte.";
|
|
401
|
+
/**
|
|
402
|
+
* The metric registry: one entry per `build*` aggregation, plus the two
|
|
403
|
+
* builder-less resource reads that are part of the agent surface.
|
|
404
|
+
*
|
|
405
|
+
* Declared with `satisfies` (not a type annotation) so each entry keeps its
|
|
406
|
+
* literal `builder` type — which is what makes the compile-time coverage guard
|
|
407
|
+
* at the bottom of this file work. The `Record<MetricId, …>` target makes a
|
|
408
|
+
* missing metric a type error.
|
|
409
|
+
*/
|
|
410
|
+
export const METRIC_REGISTRY = {
|
|
411
|
+
// =========================================================================
|
|
412
|
+
// Sessions & orientation
|
|
413
|
+
// =========================================================================
|
|
414
|
+
list_sessions: {
|
|
415
|
+
id: "list_sessions",
|
|
416
|
+
title: "Recent sessions",
|
|
417
|
+
description: "One row per session seen in the range: its id, the server-derived daily-rotating visitor " +
|
|
418
|
+
"hash, how many events it produced, and its first/last event timestamps. The entry point " +
|
|
419
|
+
"for 'what traffic did this project get' and for picking a session to drill into.",
|
|
420
|
+
builder: "buildListSessions",
|
|
421
|
+
endpoint: { method: "GET", path: "/api/v1/sessions" },
|
|
422
|
+
grain: "session",
|
|
423
|
+
dimensions: ["session"],
|
|
424
|
+
filters: ["since", "until", "bins", "limit", "cameraMode", "format"],
|
|
425
|
+
row: z.object({
|
|
426
|
+
session_id: text,
|
|
427
|
+
visitor_id: text,
|
|
428
|
+
events: int,
|
|
429
|
+
started_at: ts,
|
|
430
|
+
ended_at: ts,
|
|
431
|
+
}),
|
|
432
|
+
columns: {
|
|
433
|
+
session_id: { description: "Session identifier.", unit: "id", label: true },
|
|
434
|
+
visitor_id: {
|
|
435
|
+
description: "Server-side daily-rotating visitor hash (ADR 0003). Not stable across days, never a PII identifier.",
|
|
436
|
+
unit: "id",
|
|
437
|
+
},
|
|
438
|
+
events: {
|
|
439
|
+
description: "Events recorded for the session in range.",
|
|
440
|
+
unit: "count",
|
|
441
|
+
measure: true,
|
|
442
|
+
},
|
|
443
|
+
started_at: { description: "First event timestamp.", unit: "timestamp" },
|
|
444
|
+
ended_at: { description: "Last event timestamp.", unit: "timestamp" },
|
|
445
|
+
},
|
|
446
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
447
|
+
interpretation: "Session length is `ended_at - started_at`; a very short span with a high `events` count is " +
|
|
448
|
+
"a bursty load, the reverse is an idle tab. `visitor_id` rotates daily, so it identifies a " +
|
|
449
|
+
"returning visitor within a day only.",
|
|
450
|
+
caveats: [
|
|
451
|
+
"`bins` is accepted by the shared range schema but ignored by this metric.",
|
|
452
|
+
"Event counts scale with the capture fidelity dial (ADR 0012), so they are not comparable across projects with different sampling profiles.",
|
|
453
|
+
ENGINE_TIMESTAMPS,
|
|
454
|
+
],
|
|
455
|
+
sourceChannels: [],
|
|
456
|
+
related: ["session_meta", "list_scenes", "timeseries"],
|
|
457
|
+
comparable: { primary: "events", direction: "neutral", minSample: 30 },
|
|
458
|
+
category: "sessions",
|
|
459
|
+
},
|
|
460
|
+
session_meta: {
|
|
461
|
+
id: "session_meta",
|
|
462
|
+
title: "Session descriptor",
|
|
463
|
+
description: "The coarse descriptor for one session — start time, the device/graphics block reported at " +
|
|
464
|
+
"`session_start`, the scene metadata and the app-supplied anonymous user descriptor. A " +
|
|
465
|
+
"single-object resource read from the store, not an aggregation, and deliberately not the " +
|
|
466
|
+
"raw event stream.",
|
|
467
|
+
endpoint: { method: "GET", path: "/api/v1/sessions/:id/meta", pathParams: ["session"] },
|
|
468
|
+
grain: "session",
|
|
469
|
+
dimensions: ["session"],
|
|
470
|
+
filters: [],
|
|
471
|
+
row: z.object({
|
|
472
|
+
sessionId: text,
|
|
473
|
+
startedAt: ts.optional(),
|
|
474
|
+
device: z.unknown().optional(),
|
|
475
|
+
scene: z.unknown().optional(),
|
|
476
|
+
user: z.unknown().optional(),
|
|
477
|
+
}),
|
|
478
|
+
columns: {
|
|
479
|
+
sessionId: { description: "Session identifier.", unit: "id", label: true },
|
|
480
|
+
startedAt: { description: "Session start time, when known.", unit: "timestamp" },
|
|
481
|
+
device: {
|
|
482
|
+
description: "Device/graphics block from `session_start` (engine, renderer, mobile flag, derived browser/OS).",
|
|
483
|
+
},
|
|
484
|
+
scene: {
|
|
485
|
+
description: "Scene metadata from `session_start` (scene id, camera type, description).",
|
|
486
|
+
},
|
|
487
|
+
user: {
|
|
488
|
+
description: "App-supplied anonymous user descriptor; absent unless the host app set one.",
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
limits: { maxRows: 1, maxSummaryRows: 1 },
|
|
492
|
+
interpretation: "Use it to attribute an outlier session (a bad FPS row, an abandoned XR session) to a device " +
|
|
493
|
+
"or camera model before drawing a conclusion from it.",
|
|
494
|
+
caveats: [
|
|
495
|
+
"Returns 404 when the session id is unknown to the project.",
|
|
496
|
+
"Carries no PII by default (ADR 0003); `user` holds only what the host app chose to attach.",
|
|
497
|
+
"Not an aggregation — there is no `build*` builder, and it takes no time range.",
|
|
498
|
+
],
|
|
499
|
+
sourceChannels: ["session_start"],
|
|
500
|
+
related: ["list_sessions", "perf_by_device"],
|
|
501
|
+
category: "sessions",
|
|
502
|
+
},
|
|
503
|
+
scene_representation: {
|
|
504
|
+
id: "scene_representation",
|
|
505
|
+
title: "Scene representation",
|
|
506
|
+
description: "The registered proxy geometry for one scene (ADR 0014): its world bounds, up-axis and unit " +
|
|
507
|
+
"scale, and the named proxy boxes when one was uploaded. A metadata resource read, not an " +
|
|
508
|
+
"aggregation — it is what turns the voxel coordinates of the spatial metrics into named places.",
|
|
509
|
+
endpoint: {
|
|
510
|
+
method: "GET",
|
|
511
|
+
path: "/api/v1/scenes/:sceneId/representation",
|
|
512
|
+
pathParams: ["scene"],
|
|
513
|
+
},
|
|
514
|
+
grain: "scene",
|
|
515
|
+
dimensions: ["scene"],
|
|
516
|
+
filters: [],
|
|
517
|
+
row: z.object({
|
|
518
|
+
projectId: text,
|
|
519
|
+
sceneId: text,
|
|
520
|
+
label: text.nullable(),
|
|
521
|
+
kind: text,
|
|
522
|
+
upAxis: text,
|
|
523
|
+
unitScale: num,
|
|
524
|
+
bounds: z.array(num).nullable(),
|
|
525
|
+
proxy: z.unknown().nullable(),
|
|
526
|
+
assetUrl: text.nullable(),
|
|
527
|
+
contentHash: text.nullable(),
|
|
528
|
+
proxyVersion: int.nullable(),
|
|
529
|
+
capturedAt: z.unknown().nullable(),
|
|
530
|
+
updatedAt: z.unknown(),
|
|
531
|
+
}),
|
|
532
|
+
columns: {
|
|
533
|
+
projectId: { description: "Owning project.", unit: "id" },
|
|
534
|
+
sceneId: { description: "Developer-assigned scene id.", unit: "id", label: true },
|
|
535
|
+
label: {
|
|
536
|
+
description: "Human-friendly scene name, when the developer supplied one.",
|
|
537
|
+
unit: "label",
|
|
538
|
+
},
|
|
539
|
+
kind: {
|
|
540
|
+
description: "`proxy`, `asset` or `none` — what geometry is registered.",
|
|
541
|
+
unit: "label",
|
|
542
|
+
},
|
|
543
|
+
upAxis: {
|
|
544
|
+
description: "Canonical up axis of the stored geometry (`y` or `z`, ADR 0018).",
|
|
545
|
+
unit: "label",
|
|
546
|
+
},
|
|
547
|
+
unitScale: { description: "World units per metre.", unit: "ratio" },
|
|
548
|
+
bounds: {
|
|
549
|
+
description: "Scene AABB `[minX,minY,minZ,maxX,maxY,maxZ]`, when known.",
|
|
550
|
+
unit: "world-units",
|
|
551
|
+
},
|
|
552
|
+
proxy: { description: "Full proxy geometry (named boxes) when `kind` is `proxy`." },
|
|
553
|
+
assetUrl: { description: "External asset URL when `kind` is `asset`." },
|
|
554
|
+
contentHash: { description: "Content digest for cache validation.", unit: "id" },
|
|
555
|
+
proxyVersion: { description: "Proxy wire-format version.", unit: "index" },
|
|
556
|
+
capturedAt: { description: "When the geometry was captured.", unit: "timestamp" },
|
|
557
|
+
updatedAt: { description: "When the registration was last written.", unit: "timestamp" },
|
|
558
|
+
},
|
|
559
|
+
limits: { maxRows: 1, maxSummaryRows: 1 },
|
|
560
|
+
interpretation: "Read `bounds` before interpreting any voxel metric: it is what turns a voxel index into a " +
|
|
561
|
+
"world position, and what a coverage percentage is measured against.",
|
|
562
|
+
caveats: [
|
|
563
|
+
"Returns 404 when the scene has no registration — spatial metrics still work, but their coordinates cannot be named.",
|
|
564
|
+
"Registration is an explicit developer action (`scanSceneProxy` or the CLI); most projects have none at first.",
|
|
565
|
+
"Not an aggregation — there is no `build*` builder, and it takes no time range.",
|
|
566
|
+
],
|
|
567
|
+
sourceChannels: [],
|
|
568
|
+
related: ["scene_coverage", "world_heatmap", "gaze_heatmap", "list_scenes"],
|
|
569
|
+
category: "sessions",
|
|
570
|
+
},
|
|
571
|
+
list_scenes: {
|
|
572
|
+
id: "list_scenes",
|
|
573
|
+
title: "Active scenes",
|
|
574
|
+
description: "The distinct developer-assigned scenes (ADR 0010) that saw activity in the range, with " +
|
|
575
|
+
"their event count and most recent activity. One row per scene; the orientation query before " +
|
|
576
|
+
"any scene-scoped question.",
|
|
577
|
+
builder: "buildDistinctScenes",
|
|
578
|
+
endpoint: { method: "GET", path: "/api/v1/scenes" },
|
|
579
|
+
grain: "scene",
|
|
580
|
+
dimensions: ["scene"],
|
|
581
|
+
filters: ["since", "until", "limit", "format"],
|
|
582
|
+
row: z.object({ scene_id: text, events: int, last_seen: ts }),
|
|
583
|
+
columns: {
|
|
584
|
+
scene_id: { description: "Developer-assigned scene id.", unit: "id", label: true },
|
|
585
|
+
events: {
|
|
586
|
+
description: "Events recorded for the scene in range.",
|
|
587
|
+
unit: "count",
|
|
588
|
+
measure: true,
|
|
589
|
+
},
|
|
590
|
+
last_seen: { description: "Most recent event timestamp for the scene.", unit: "timestamp" },
|
|
591
|
+
},
|
|
592
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
593
|
+
interpretation: "An app that never calls `setScene(...)` reports a single `default` scene (ADR 0010) — that " +
|
|
594
|
+
"is not a bug, it means the app is not scene-tagged.",
|
|
595
|
+
caveats: [
|
|
596
|
+
"Event counts scale with the capture fidelity dial (ADR 0012), so a 'busy' scene may simply sample more.",
|
|
597
|
+
ENGINE_TIMESTAMPS,
|
|
598
|
+
],
|
|
599
|
+
sourceChannels: [],
|
|
600
|
+
related: ["scene_representation", "perf_by_scene", "scene_retention"],
|
|
601
|
+
comparable: { primary: "events", direction: "neutral", minSample: 50 },
|
|
602
|
+
category: "sessions",
|
|
603
|
+
},
|
|
604
|
+
timeseries: {
|
|
605
|
+
id: "timeseries",
|
|
606
|
+
title: "Event volume over time",
|
|
607
|
+
description: "Event volume bucketed into fixed `interval`-second windows, with the average FPS of any " +
|
|
608
|
+
"`frame_perf` samples in the same bucket. One row per bucket: the shape of traffic with the " +
|
|
609
|
+
"coarse perf trend beside it.",
|
|
610
|
+
builder: "buildTimeseries",
|
|
611
|
+
endpoint: { method: "GET", path: "/api/v1/timeseries" },
|
|
612
|
+
grain: "bucket",
|
|
613
|
+
dimensions: ["scene", "event_type"],
|
|
614
|
+
filters: ["since", "until", "interval", "scene", "type", "format"],
|
|
615
|
+
row: z.object({ bucket: int, events: int, avg_fps: num }),
|
|
616
|
+
columns: {
|
|
617
|
+
bucket: {
|
|
618
|
+
description: "Bucket start as epoch milliseconds.",
|
|
619
|
+
unit: "epoch-ms",
|
|
620
|
+
label: true,
|
|
621
|
+
axis: true,
|
|
622
|
+
},
|
|
623
|
+
events: { description: "Events in the bucket.", unit: "count", measure: true },
|
|
624
|
+
avg_fps: {
|
|
625
|
+
description: "Mean FPS of the `frame_perf` samples in the bucket; `0` when there were none.",
|
|
626
|
+
unit: "fps",
|
|
627
|
+
},
|
|
628
|
+
},
|
|
629
|
+
limits: { maxRows: 1000, maxSummaryRows: 8 },
|
|
630
|
+
interpretation: "Read it as a trend, not a level. A bucket with no `frame_perf` samples reports `avg_fps` 0, " +
|
|
631
|
+
"so treat a zero as 'no perf data', never as a stall.",
|
|
632
|
+
caveats: [
|
|
633
|
+
"Buckets are fixed width; a range shorter than one `interval` collapses to a single bucket.",
|
|
634
|
+
"Volume scales with the capture fidelity dial (ADR 0012) — a rise can be a sampling change rather than traffic.",
|
|
635
|
+
"`avg_fps` pools raw samples, so a chatty session dominates its bucket; use `perf_distribution` for an honest headline.",
|
|
636
|
+
],
|
|
637
|
+
sourceChannels: ["frame_perf"],
|
|
638
|
+
related: ["event_counts", "perf_distribution", "events_daily"],
|
|
639
|
+
comparable: { primary: "events", direction: "neutral", minSample: 100 },
|
|
640
|
+
category: "sessions",
|
|
641
|
+
},
|
|
642
|
+
event_counts: {
|
|
643
|
+
id: "event_counts",
|
|
644
|
+
title: "Counts per event type",
|
|
645
|
+
description: "How many events of each type were recorded in the range, optionally for one scene. One row " +
|
|
646
|
+
"per event type. The scene-health overview: error rate, context losses, focus/visibility " +
|
|
647
|
+
"gaps and interaction volume all read off this single query.",
|
|
648
|
+
builder: "buildEventTypeCounts",
|
|
649
|
+
endpoint: { method: "GET", path: "/api/v1/event-counts" },
|
|
650
|
+
grain: "row",
|
|
651
|
+
dimensions: ["event_type", "scene"],
|
|
652
|
+
filters: ["since", "until", "scene", "format"],
|
|
653
|
+
row: z.object({ event_type: text, count: int }),
|
|
654
|
+
columns: {
|
|
655
|
+
event_type: {
|
|
656
|
+
description: "Event type identifier from `@uptimizr/schema`.",
|
|
657
|
+
unit: "label",
|
|
658
|
+
label: true,
|
|
659
|
+
},
|
|
660
|
+
count: { description: "Events of that type in range.", unit: "count", measure: true },
|
|
661
|
+
},
|
|
662
|
+
limits: { maxRows: 100, maxSummaryRows: 10 },
|
|
663
|
+
interpretation: "An event type missing from the result means that channel produced nothing — usually its " +
|
|
664
|
+
"capture option is off (ADR 0012), not that the behaviour never happened. Check the absence " +
|
|
665
|
+
"before concluding anything from it.",
|
|
666
|
+
caveats: [
|
|
667
|
+
"Continuous channels (`camera_sample`, `pointer_move`, `frame_perf`) dwarf discrete ones by construction; compare like with like.",
|
|
668
|
+
"Counts scale with the capture fidelity dial (ADR 0012).",
|
|
669
|
+
],
|
|
670
|
+
sourceChannels: [],
|
|
671
|
+
related: ["timeseries", "stability_counts", "events_daily"],
|
|
672
|
+
comparable: { primary: "count", direction: "neutral", minSample: 50 },
|
|
673
|
+
category: "sessions",
|
|
674
|
+
},
|
|
675
|
+
events_daily: {
|
|
676
|
+
id: "events_daily",
|
|
677
|
+
title: "Daily event-count trend",
|
|
678
|
+
description: "Per-day, per-event-type event counts read from the `events_daily` rollup. One row per " +
|
|
679
|
+
"(day, event type). The long-horizon companion to `timeseries` for trend questions that " +
|
|
680
|
+
"span weeks.",
|
|
681
|
+
builder: "buildEventsDaily",
|
|
682
|
+
grain: "bucket",
|
|
683
|
+
dimensions: ["event_type"],
|
|
684
|
+
filters: [],
|
|
685
|
+
row: z.object({ day: day, event_type: text, events: int }),
|
|
686
|
+
columns: {
|
|
687
|
+
day: {
|
|
688
|
+
description: "Calendar day (UTC) as `YYYY-MM-DD`.",
|
|
689
|
+
unit: "label",
|
|
690
|
+
label: true,
|
|
691
|
+
axis: true,
|
|
692
|
+
},
|
|
693
|
+
event_type: { description: "Event type identifier.", unit: "label" },
|
|
694
|
+
events: { description: "Events of that type on that day.", unit: "count", measure: true },
|
|
695
|
+
},
|
|
696
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
697
|
+
interpretation: "Day boundaries are UTC. The upper bound of the range is exclusive at date granularity, so " +
|
|
698
|
+
"span a full day on either side when you want a specific day included.",
|
|
699
|
+
caveats: [
|
|
700
|
+
"No collector endpoint serves this builder today — it is reachable through the store / `QuerySpec` API only.",
|
|
701
|
+
"On the OSS DuckDB store this is a query-time aggregation; on the ClickHouse scale tier it reads a materialized view whose counts are merged.",
|
|
702
|
+
"Counts scale with the capture fidelity dial (ADR 0012).",
|
|
703
|
+
],
|
|
704
|
+
sourceChannels: [],
|
|
705
|
+
related: ["timeseries", "event_counts", "perf_daily"],
|
|
706
|
+
comparable: { primary: "events", direction: "neutral", minSample: 100 },
|
|
707
|
+
category: "sessions",
|
|
708
|
+
},
|
|
709
|
+
// =========================================================================
|
|
710
|
+
// Attention & spatial
|
|
711
|
+
// =========================================================================
|
|
712
|
+
pointer_heatmap: {
|
|
713
|
+
id: "pointer_heatmap",
|
|
714
|
+
title: "2D pointer heatmap",
|
|
715
|
+
description: "Screen-space pointer activity binned into a `bins × bins` grid over the normalized viewport. " +
|
|
716
|
+
"One row per occupied cell. Answers 'where on screen do people point and click' — the classic " +
|
|
717
|
+
"web heatmap, for a 3D canvas.",
|
|
718
|
+
builder: "buildPointerHeatmap",
|
|
719
|
+
endpoint: { method: "GET", path: "/api/v1/heatmaps/pointer" },
|
|
720
|
+
grain: "bin",
|
|
721
|
+
dimensions: ["scene", "session", "source", "cameraMode"],
|
|
722
|
+
filters: [
|
|
723
|
+
"since",
|
|
724
|
+
"until",
|
|
725
|
+
"bins",
|
|
726
|
+
"limit",
|
|
727
|
+
"scene",
|
|
728
|
+
"session",
|
|
729
|
+
"source",
|
|
730
|
+
"cameraMode",
|
|
731
|
+
"format",
|
|
732
|
+
],
|
|
733
|
+
row: heatmapBinRow,
|
|
734
|
+
columns: {
|
|
735
|
+
gx: { description: "Horizontal cell index, `0 .. bins-1`.", unit: "index", label: true },
|
|
736
|
+
gy: { description: "Vertical cell index, `0 .. bins-1`.", unit: "index" },
|
|
737
|
+
count: { description: "Pointer events in the cell.", unit: "count", measure: true },
|
|
738
|
+
},
|
|
739
|
+
limits: { maxRows: 1000, maxSummaryRows: 8 },
|
|
740
|
+
interpretation: "Cells are normalized viewport fractions, so the map is resolution-independent but not " +
|
|
741
|
+
"aspect-ratio-independent — a scene played on phones and desktops mixes two framings in one " +
|
|
742
|
+
"grid. Segment by `cameraMode` or `source` when that matters.",
|
|
743
|
+
caveats: [
|
|
744
|
+
POINTER_SAMPLED,
|
|
745
|
+
"Covers `pointer_move` and `pointer_click` together, so dwell (moves) dominates intent (clicks). Use `dead_clicks` / `rage_clicks` for click-only questions.",
|
|
746
|
+
"`limit` is accepted by the shared range schema but the builder returns every occupied cell up to its own cap.",
|
|
747
|
+
],
|
|
748
|
+
sourceChannels: ["pointer_move", "pointer_click"],
|
|
749
|
+
related: ["world_heatmap", "mesh_uv_heatmap", "click_rays", "top_meshes"],
|
|
750
|
+
comparable: { primary: "count", direction: "neutral", minSample: 100 },
|
|
751
|
+
category: "attention",
|
|
752
|
+
},
|
|
753
|
+
mesh_uv_heatmap: {
|
|
754
|
+
id: "mesh_uv_heatmap",
|
|
755
|
+
title: "Per-mesh UV (texture-space) heatmap",
|
|
756
|
+
description: "Interaction hits on one object binned into a `bins × bins` grid over that object's own " +
|
|
757
|
+
"`[0,1]` UV space (#149). One row per occupied cell. Answers 'which part of this product " +
|
|
758
|
+
"model gets attention', independent of where the object sits in the scene.",
|
|
759
|
+
builder: "buildMeshUvHeatmap",
|
|
760
|
+
endpoint: { method: "GET", path: "/api/v1/heatmaps/mesh-uv" },
|
|
761
|
+
grain: "bin",
|
|
762
|
+
dimensions: ["scene", "session", "source", "mesh"],
|
|
763
|
+
filters: ["since", "until", "bins", "limit", "scene", "session", "source", "mesh", "format"],
|
|
764
|
+
row: heatmapBinRow,
|
|
765
|
+
columns: {
|
|
766
|
+
gx: { description: "U cell index, `0 .. bins-1`.", unit: "index", label: true },
|
|
767
|
+
gy: { description: "V cell index, `0 .. bins-1`.", unit: "index" },
|
|
768
|
+
count: { description: "Interaction hits in the cell.", unit: "count", measure: true },
|
|
769
|
+
},
|
|
770
|
+
limits: { maxRows: 1000, maxSummaryRows: 8 },
|
|
771
|
+
interpretation: "The grid is the object's texture space, so it only reads meaningfully for a mesh with sane, " +
|
|
772
|
+
"non-overlapping UVs. Overlapping UV islands fold distinct surfaces into one cell.",
|
|
773
|
+
caveats: [
|
|
774
|
+
"`mesh` is **required** — the metric is per-object by construction.",
|
|
775
|
+
"`uv` rides in the event payload rather than a promoted column, so only events whose connector reported it participate.",
|
|
776
|
+
"Counts `pointer_click`, `mesh_interaction` and `hover_dwell`; `hover_dwell` needs its opt-in capture option (ADR 0012).",
|
|
777
|
+
],
|
|
778
|
+
sourceChannels: ["pointer_click", "mesh_interaction", "hover_dwell"],
|
|
779
|
+
related: ["pointer_heatmap", "top_meshes", "mesh_interaction_kinds"],
|
|
780
|
+
comparable: { primary: "count", direction: "neutral", minSample: 50 },
|
|
781
|
+
category: "attention",
|
|
782
|
+
},
|
|
783
|
+
world_heatmap: {
|
|
784
|
+
id: "world_heatmap",
|
|
785
|
+
title: "3D world-space pointer heatmap",
|
|
786
|
+
description: "Pointer raycast hit points voxel-binned into a uniform grid of `cellSize`-sized cubes. One " +
|
|
787
|
+
"row per occupied voxel, busiest first. Answers 'where in the scene do people point and " +
|
|
788
|
+
"click' in world coordinates rather than on screen.",
|
|
789
|
+
builder: "buildWorldHeatmap",
|
|
790
|
+
endpoint: { method: "GET", path: "/api/v1/heatmaps/world" },
|
|
791
|
+
grain: "voxel",
|
|
792
|
+
dimensions: ["scene", "source", "cameraMode"],
|
|
793
|
+
filters: [
|
|
794
|
+
"since",
|
|
795
|
+
"until",
|
|
796
|
+
"cellSize",
|
|
797
|
+
"limit",
|
|
798
|
+
"scene",
|
|
799
|
+
"source",
|
|
800
|
+
"cameraMode",
|
|
801
|
+
"region",
|
|
802
|
+
"format",
|
|
803
|
+
],
|
|
804
|
+
row: voxelCountRow,
|
|
805
|
+
columns: {
|
|
806
|
+
vx: { description: "Voxel X index (`floor(x / cellSize)`).", unit: "index", label: true },
|
|
807
|
+
vy: { description: "Voxel Y index.", unit: "index" },
|
|
808
|
+
vz: { description: "Voxel Z index.", unit: "index" },
|
|
809
|
+
count: { description: "Pointer hits in the voxel.", unit: "count", measure: true },
|
|
810
|
+
},
|
|
811
|
+
limits: { maxRows: 1000, maxSummaryRows: 8 },
|
|
812
|
+
interpretation: "Multiply a voxel index by the effective `cellSize` to get its world corner. Pair with " +
|
|
813
|
+
"`scene_representation` to name what is at a hotspot, and with `world_heatmap_stats` to know " +
|
|
814
|
+
"what share of activity the returned voxels represent.",
|
|
815
|
+
caveats: [CELL_SIZE_SENSITIVE, TRUNCATED_TOP_N, POINTER_SAMPLED],
|
|
816
|
+
sourceChannels: ["pointer_move", "pointer_click"],
|
|
817
|
+
related: ["world_heatmap_stats", "gaze_heatmap", "pointer_heatmap", "click_rays"],
|
|
818
|
+
comparable: { primary: "count", direction: "neutral", minSample: 100 },
|
|
819
|
+
category: "attention",
|
|
820
|
+
},
|
|
821
|
+
world_heatmap_stats: {
|
|
822
|
+
id: "world_heatmap_stats",
|
|
823
|
+
title: "World heatmap totals",
|
|
824
|
+
description: "The un-truncated totals behind `world_heatmap` (ADR 0040 §3): how many voxels are occupied " +
|
|
825
|
+
"and how many hits they hold, computed with no row cap. Always a single row.",
|
|
826
|
+
builder: "buildWorldHeatmapStats",
|
|
827
|
+
endpoint: { method: "GET", path: "/api/v1/heatmaps/world/stats" },
|
|
828
|
+
grain: "project",
|
|
829
|
+
dimensions: ["scene", "source", "cameraMode"],
|
|
830
|
+
filters: ["since", "until", "cellSize", "scene", "source", "cameraMode", "region", "format"],
|
|
831
|
+
row: spatialStatsRow,
|
|
832
|
+
columns: {
|
|
833
|
+
cells: { description: "Occupied voxels across the whole scene or region.", unit: "count" },
|
|
834
|
+
hits: {
|
|
835
|
+
description: "Total pointer hits across those voxels.",
|
|
836
|
+
unit: "count",
|
|
837
|
+
measure: true,
|
|
838
|
+
},
|
|
839
|
+
},
|
|
840
|
+
limits: { maxRows: 1, maxSummaryRows: 1 },
|
|
841
|
+
interpretation: "Use `hits` as the denominator when turning a truncated `world_heatmap` into shares, and " +
|
|
842
|
+
"`cells` to say 'showing the top N of M occupied cells'.",
|
|
843
|
+
caveats: [
|
|
844
|
+
CELL_SIZE_SENSITIVE,
|
|
845
|
+
"Must be called with exactly the same filters and `cellSize` as the `world_heatmap` it describes, or the totals do not match.",
|
|
846
|
+
],
|
|
847
|
+
sourceChannels: ["pointer_move", "pointer_click"],
|
|
848
|
+
related: ["world_heatmap"],
|
|
849
|
+
category: "attention",
|
|
850
|
+
},
|
|
851
|
+
gaze_heatmap: {
|
|
852
|
+
id: "gaze_heatmap",
|
|
853
|
+
title: "World-space gaze heatmap",
|
|
854
|
+
description: "Where the camera-forward (gaze) ray landed on real geometry, voxel-binned into a uniform " +
|
|
855
|
+
"grid (ADR 0030). One row per occupied voxel, busiest first. This is 'what did people " +
|
|
856
|
+
"actually look at', as opposed to what they clicked.",
|
|
857
|
+
builder: "buildGazeHeatmap",
|
|
858
|
+
endpoint: { method: "GET", path: "/api/v1/heatmaps/gaze" },
|
|
859
|
+
grain: "voxel",
|
|
860
|
+
dimensions: ["scene", "session", "cameraMode"],
|
|
861
|
+
filters: [
|
|
862
|
+
"since",
|
|
863
|
+
"until",
|
|
864
|
+
"cellSize",
|
|
865
|
+
"limit",
|
|
866
|
+
"scene",
|
|
867
|
+
"session",
|
|
868
|
+
"cameraMode",
|
|
869
|
+
"region",
|
|
870
|
+
"format",
|
|
871
|
+
],
|
|
872
|
+
row: voxelCountRow,
|
|
873
|
+
columns: {
|
|
874
|
+
vx: { description: "Voxel X index.", unit: "index", label: true },
|
|
875
|
+
vy: { description: "Voxel Y index.", unit: "index" },
|
|
876
|
+
vz: { description: "Voxel Z index.", unit: "index" },
|
|
877
|
+
count: { description: "Gaze hits in the voxel.", unit: "count", measure: true },
|
|
878
|
+
},
|
|
879
|
+
limits: { maxRows: 1000, maxSummaryRows: 8 },
|
|
880
|
+
interpretation: "Gaze counts are dwell-weighted: each sample is one tick of looking, so a voxel's count is " +
|
|
881
|
+
"roughly attention-seconds divided by the sample interval, not a number of visitors.",
|
|
882
|
+
caveats: [
|
|
883
|
+
GAZE_OPT_IN,
|
|
884
|
+
CAMERA_SAMPLED,
|
|
885
|
+
CELL_SIZE_SENSITIVE,
|
|
886
|
+
TRUNCATED_TOP_N,
|
|
887
|
+
"Gaze has no input source, so there is no `source` filter.",
|
|
888
|
+
],
|
|
889
|
+
sourceChannels: ["camera_sample"],
|
|
890
|
+
related: ["gaze_heatmap_stats", "world_heatmap", "camera_heatmap", "mesh_blind_spots"],
|
|
891
|
+
comparable: { primary: "count", direction: "neutral", minSample: 100 },
|
|
892
|
+
category: "attention",
|
|
893
|
+
},
|
|
894
|
+
gaze_heatmap_stats: {
|
|
895
|
+
id: "gaze_heatmap_stats",
|
|
896
|
+
title: "Gaze heatmap totals",
|
|
897
|
+
description: "The un-truncated totals behind `gaze_heatmap` (ADR 0040 §3): occupied voxels and total gaze " +
|
|
898
|
+
"hits, with no row cap. Always a single row.",
|
|
899
|
+
builder: "buildGazeHeatmapStats",
|
|
900
|
+
endpoint: { method: "GET", path: "/api/v1/heatmaps/gaze/stats" },
|
|
901
|
+
grain: "project",
|
|
902
|
+
dimensions: ["scene", "session", "cameraMode"],
|
|
903
|
+
filters: ["since", "until", "cellSize", "scene", "session", "cameraMode", "region", "format"],
|
|
904
|
+
row: spatialStatsRow,
|
|
905
|
+
columns: {
|
|
906
|
+
cells: {
|
|
907
|
+
description: "Occupied gaze voxels across the whole scene or region.",
|
|
908
|
+
unit: "count",
|
|
909
|
+
},
|
|
910
|
+
hits: { description: "Total gaze hits across those voxels.", unit: "count", measure: true },
|
|
911
|
+
},
|
|
912
|
+
limits: { maxRows: 1, maxSummaryRows: 1 },
|
|
913
|
+
interpretation: "Use `hits` as the denominator for shares over a truncated `gaze_heatmap`, and `cells` for " +
|
|
914
|
+
"coverage and cold-spot reasoning.",
|
|
915
|
+
caveats: [
|
|
916
|
+
GAZE_OPT_IN,
|
|
917
|
+
CELL_SIZE_SENSITIVE,
|
|
918
|
+
"Must be called with exactly the same filters and `cellSize` as the `gaze_heatmap` it describes.",
|
|
919
|
+
],
|
|
920
|
+
sourceChannels: ["camera_sample"],
|
|
921
|
+
related: ["gaze_heatmap"],
|
|
922
|
+
category: "attention",
|
|
923
|
+
},
|
|
924
|
+
camera_heatmap: {
|
|
925
|
+
id: "camera_heatmap",
|
|
926
|
+
title: "View-direction heatmap",
|
|
927
|
+
description: "Camera forward vectors binned by spherical angle into a `bins × bins` azimuth/elevation " +
|
|
928
|
+
"grid. One row per occupied direction bin. The abstract 'which way did people look' dome — " +
|
|
929
|
+
"it needs no scene geometry, so it works even without the gaze raycast.",
|
|
930
|
+
builder: "buildCameraDirectionHeatmap",
|
|
931
|
+
endpoint: { method: "GET", path: "/api/v1/heatmaps/camera" },
|
|
932
|
+
grain: "bin",
|
|
933
|
+
dimensions: ["scene", "session", "cameraMode"],
|
|
934
|
+
filters: ["since", "until", "bins", "limit", "scene", "session", "cameraMode", "format"],
|
|
935
|
+
row: z.object({ azimuth_bin: int, elevation_bin: int, count: int }),
|
|
936
|
+
columns: {
|
|
937
|
+
azimuth_bin: {
|
|
938
|
+
description: "Horizontal angle bin from `atan2(z, x)`, `0 .. bins-1`.",
|
|
939
|
+
unit: "index",
|
|
940
|
+
label: true,
|
|
941
|
+
},
|
|
942
|
+
elevation_bin: {
|
|
943
|
+
description: "Vertical angle bin from `asin(y / |v|)`, `0 .. bins-1`.",
|
|
944
|
+
unit: "index",
|
|
945
|
+
},
|
|
946
|
+
count: { description: "Camera samples pointing into the bin.", unit: "count", measure: true },
|
|
947
|
+
},
|
|
948
|
+
limits: { maxRows: 1000, maxSummaryRows: 8 },
|
|
949
|
+
interpretation: "Bins are equal-angle, not equal-area, so the poles (`elevation_bin` at either extreme) cover " +
|
|
950
|
+
"less solid angle than the equator. Compare bins at similar elevations.",
|
|
951
|
+
caveats: [
|
|
952
|
+
CAMERA_SAMPLED,
|
|
953
|
+
"Direction only — it says where people faced, not what was in front of them. Use `gaze_heatmap` for surfaces.",
|
|
954
|
+
"An orbit/viewer scene concentrates direction around the orbit axis by construction; segment with `cameraMode`.",
|
|
955
|
+
],
|
|
956
|
+
sourceChannels: ["camera_sample"],
|
|
957
|
+
related: ["gaze_heatmap", "view_coverage_histogram", "flow_links"],
|
|
958
|
+
comparable: { primary: "count", direction: "neutral", minSample: 100 },
|
|
959
|
+
category: "attention",
|
|
960
|
+
},
|
|
961
|
+
view_coverage_histogram: {
|
|
962
|
+
id: "view_coverage_histogram",
|
|
963
|
+
title: "360° view-coverage histogram",
|
|
964
|
+
description: "How much of the view dome each session actually looked at, bucketed across sessions (#146). " +
|
|
965
|
+
"One row per 25-point coverage band. Answers 'how many visitors saw less than a quarter of " +
|
|
966
|
+
"the product'.",
|
|
967
|
+
builder: "buildViewCoverageHistogram",
|
|
968
|
+
endpoint: { method: "GET", path: "/api/v1/coverage/view-histogram" },
|
|
969
|
+
grain: "bucket",
|
|
970
|
+
dimensions: ["scene", "session", "cameraMode"],
|
|
971
|
+
filters: ["since", "until", "bins", "limit", "scene", "session", "cameraMode", "format"],
|
|
972
|
+
row: z.object({ bucket: int, sessions: int }),
|
|
973
|
+
columns: {
|
|
974
|
+
bucket: {
|
|
975
|
+
description: "Inclusive lower bound of the coverage band in percent (`0`, `25`, `50`, `75`).",
|
|
976
|
+
unit: "percent",
|
|
977
|
+
label: true,
|
|
978
|
+
axis: true,
|
|
979
|
+
},
|
|
980
|
+
sessions: {
|
|
981
|
+
description: "Sessions whose coverage fell in the band.",
|
|
982
|
+
unit: "sessions",
|
|
983
|
+
measure: true,
|
|
984
|
+
},
|
|
985
|
+
},
|
|
986
|
+
limits: { maxRows: 4, maxSummaryRows: 4 },
|
|
987
|
+
interpretation: "Coverage is the fraction of the `bins × bins` direction grid a session visited, so it is " +
|
|
988
|
+
"relative to `bins`: a coarser grid makes every session look more thorough. Keep `bins` fixed " +
|
|
989
|
+
"when comparing periods.",
|
|
990
|
+
caveats: [
|
|
991
|
+
CAMERA_SAMPLED,
|
|
992
|
+
"A 100%-coverage session folds into the top (`75`) bucket rather than a fifth bucket.",
|
|
993
|
+
"Very short sessions land in the `0` bucket simply for lack of samples, not for lack of interest.",
|
|
994
|
+
],
|
|
995
|
+
sourceChannels: ["camera_sample"],
|
|
996
|
+
related: ["camera_heatmap", "scene_coverage", "gaze_heatmap"],
|
|
997
|
+
comparable: { primary: "sessions", direction: "neutral", minSample: 30 },
|
|
998
|
+
category: "attention",
|
|
999
|
+
},
|
|
1000
|
+
position_heatmap: {
|
|
1001
|
+
id: "position_heatmap",
|
|
1002
|
+
title: "Floor-plan camera-position heatmap",
|
|
1003
|
+
description: "Camera positions binned onto the X/Z ground plane in `cellSize`-sized cells, with the mean " +
|
|
1004
|
+
"height per cell (ADR 0026). One row per occupied cell, busiest first. The 'where do visitors " +
|
|
1005
|
+
"stand and linger' map for a walkable scene.",
|
|
1006
|
+
builder: "buildCameraPositionHeatmap",
|
|
1007
|
+
endpoint: { method: "GET", path: "/api/v1/heatmaps/position" },
|
|
1008
|
+
grain: "bin",
|
|
1009
|
+
dimensions: ["scene", "session", "cameraMode"],
|
|
1010
|
+
filters: [
|
|
1011
|
+
"since",
|
|
1012
|
+
"until",
|
|
1013
|
+
"cellSize",
|
|
1014
|
+
"limit",
|
|
1015
|
+
"scene",
|
|
1016
|
+
"session",
|
|
1017
|
+
"cameraMode",
|
|
1018
|
+
"region",
|
|
1019
|
+
"format",
|
|
1020
|
+
],
|
|
1021
|
+
row: z.object({ gx: int, gz: int, avg_y: num, count: int }),
|
|
1022
|
+
columns: {
|
|
1023
|
+
gx: { description: "Ground cell X index.", unit: "index", label: true },
|
|
1024
|
+
gz: { description: "Ground cell Z index.", unit: "index" },
|
|
1025
|
+
avg_y: { description: "Mean camera height in the cell.", unit: "world-units" },
|
|
1026
|
+
count: { description: "Camera samples in the cell.", unit: "count", measure: true },
|
|
1027
|
+
},
|
|
1028
|
+
limits: { maxRows: 10000, maxSummaryRows: 8 },
|
|
1029
|
+
interpretation: "Because camera pose is sampled on a timer, `count` is dwell time in sample ticks — a bright " +
|
|
1030
|
+
"cell is where people stood still, not necessarily where most people walked. Cross-check " +
|
|
1031
|
+
"with `aggregate_paths` for flow.",
|
|
1032
|
+
caveats: [
|
|
1033
|
+
CAMERA_SAMPLED,
|
|
1034
|
+
CELL_SIZE_SENSITIVE,
|
|
1035
|
+
"Meaningless for an orbit/viewer scene, where the camera position is an artefact of the orbit — filter `cameraMode=first-person`.",
|
|
1036
|
+
],
|
|
1037
|
+
sourceChannels: ["camera_sample"],
|
|
1038
|
+
related: ["aggregate_paths", "scene_coverage", "navigation_stats", "backtrack_ratio"],
|
|
1039
|
+
comparable: { primary: "count", direction: "neutral", minSample: 100 },
|
|
1040
|
+
category: "navigation",
|
|
1041
|
+
},
|
|
1042
|
+
session_trajectory: {
|
|
1043
|
+
id: "session_trajectory",
|
|
1044
|
+
title: "Session walked path",
|
|
1045
|
+
description: "One session's ordered camera positions, oldest first (ADR 0026). One row per sampled point. " +
|
|
1046
|
+
"The single-visitor path behind the crowd view in `aggregate_paths`.",
|
|
1047
|
+
builder: "buildSessionTrajectory",
|
|
1048
|
+
endpoint: {
|
|
1049
|
+
method: "GET",
|
|
1050
|
+
path: "/api/v1/sessions/:sessionId/trajectory",
|
|
1051
|
+
pathParams: ["session"],
|
|
1052
|
+
},
|
|
1053
|
+
grain: "row",
|
|
1054
|
+
dimensions: ["session", "scene"],
|
|
1055
|
+
filters: ["since", "until", "limit", "scene", "format"],
|
|
1056
|
+
row: z.object({ ts: int, x: num, y: num, z: num }),
|
|
1057
|
+
columns: {
|
|
1058
|
+
ts: {
|
|
1059
|
+
description: "Sample time as epoch milliseconds (the query projects `epochMs(ts)`, so this is a number even though `TrajectoryPointRow.ts` is declared `string` in `types.ts`).",
|
|
1060
|
+
unit: "epoch-ms",
|
|
1061
|
+
label: true,
|
|
1062
|
+
},
|
|
1063
|
+
x: { description: "Camera X in canonical world space.", unit: "world-units" },
|
|
1064
|
+
y: { description: "Camera Y (height).", unit: "world-units" },
|
|
1065
|
+
z: { description: "Camera Z.", unit: "world-units" },
|
|
1066
|
+
},
|
|
1067
|
+
limits: { maxRows: 10000, maxSummaryRows: 10 },
|
|
1068
|
+
interpretation: "Points are unbinned world positions in the canonical frame (ADR 0018). Gaps between " +
|
|
1069
|
+
"consecutive timestamps are dwell, not teleportation.",
|
|
1070
|
+
caveats: [
|
|
1071
|
+
CAMERA_SAMPLED,
|
|
1072
|
+
"The session id is a path parameter, not a querystring filter.",
|
|
1073
|
+
"Aggregate-safe: this is a pose track, not the raw event stream, and is not gated by `ENABLE_RAW_SESSION_RETENTION`.",
|
|
1074
|
+
],
|
|
1075
|
+
sourceChannels: ["camera_sample"],
|
|
1076
|
+
related: ["aggregate_paths", "position_heatmap", "navigation_stats"],
|
|
1077
|
+
category: "navigation",
|
|
1078
|
+
},
|
|
1079
|
+
aggregate_paths: {
|
|
1080
|
+
id: "aggregate_paths",
|
|
1081
|
+
title: "Aggregate desire-line paths",
|
|
1082
|
+
description: "Every session's camera path binned onto the ground grid and returned as ordered, " +
|
|
1083
|
+
"session-keyed points (#73, ADR 0037). One row per (session, sampled point). Overlaying the " +
|
|
1084
|
+
"poly-lines makes the routes visitors actually walk self-reinforce into desire lines.",
|
|
1085
|
+
builder: "buildAggregateTrajectories",
|
|
1086
|
+
endpoint: { method: "GET", path: "/api/v1/paths" },
|
|
1087
|
+
grain: "row",
|
|
1088
|
+
dimensions: ["session", "scene", "cameraMode"],
|
|
1089
|
+
filters: ["since", "until", "cellSize", "limit", "scene", "cameraMode", "format"],
|
|
1090
|
+
row: z.object({ session_id: text, ts: int, gx: int, gz: int }),
|
|
1091
|
+
columns: {
|
|
1092
|
+
session_id: { description: "Session the point belongs to.", unit: "id", label: true },
|
|
1093
|
+
ts: {
|
|
1094
|
+
description: "Sample time as epoch milliseconds; order points by it per session. (The query projects `epochMs(ts)`, so this is a number even though `AggregateTrajectoryPointRow.ts` is declared `string` in `types.ts`.)",
|
|
1095
|
+
unit: "epoch-ms",
|
|
1096
|
+
},
|
|
1097
|
+
gx: { description: "Ground cell X index.", unit: "index" },
|
|
1098
|
+
gz: { description: "Ground cell Z index.", unit: "index" },
|
|
1099
|
+
},
|
|
1100
|
+
limits: { maxRows: 50000, maxSummaryRows: 10 },
|
|
1101
|
+
interpretation: "Group by `session_id`, order by `ts`, and de-duplicate consecutive identical cells — the " +
|
|
1102
|
+
"binning already removes sub-cell jitter, so repeated cells mean standing still.",
|
|
1103
|
+
caveats: [
|
|
1104
|
+
CAMERA_SAMPLED,
|
|
1105
|
+
CELL_SIZE_SENSITIVE,
|
|
1106
|
+
"No single-session filter by design — this is the crowd view; use `session_trajectory` for one visitor.",
|
|
1107
|
+
"The row cap truncates *points*, not sessions, so a busy range can return partial paths.",
|
|
1108
|
+
],
|
|
1109
|
+
sourceChannels: ["camera_sample"],
|
|
1110
|
+
related: ["session_trajectory", "position_heatmap", "backtrack_ratio"],
|
|
1111
|
+
category: "navigation",
|
|
1112
|
+
},
|
|
1113
|
+
scene_coverage: {
|
|
1114
|
+
id: "scene_coverage",
|
|
1115
|
+
title: "Scene coverage / dead zones",
|
|
1116
|
+
description: "Camera *positions* voxel-binned into a uniform 3D grid. One row per occupied voxel with its " +
|
|
1117
|
+
"visit count. Exploration completeness and never-visited regions are computed by comparing " +
|
|
1118
|
+
"the occupied voxels against the scene's registered bounds.",
|
|
1119
|
+
builder: "buildSceneCoverage",
|
|
1120
|
+
endpoint: { method: "GET", path: "/api/v1/coverage" },
|
|
1121
|
+
grain: "voxel",
|
|
1122
|
+
dimensions: ["scene", "session"],
|
|
1123
|
+
filters: ["since", "until", "cellSize", "limit", "scene", "session", "format"],
|
|
1124
|
+
row: voxelCountRow,
|
|
1125
|
+
columns: {
|
|
1126
|
+
vx: { description: "Voxel X index.", unit: "index", label: true },
|
|
1127
|
+
vy: { description: "Voxel Y index.", unit: "index" },
|
|
1128
|
+
vz: { description: "Voxel Z index.", unit: "index" },
|
|
1129
|
+
count: { description: "Camera samples in the voxel.", unit: "count", measure: true },
|
|
1130
|
+
},
|
|
1131
|
+
limits: { maxRows: 10000, maxSummaryRows: 8 },
|
|
1132
|
+
interpretation: "'Coverage' is only a percentage once you divide the occupied voxel count by the voxel count " +
|
|
1133
|
+
"of the scene AABB — read `scene_representation` for the bounds. Without a registered scene " +
|
|
1134
|
+
"this metric shows visited volume but no denominator.",
|
|
1135
|
+
caveats: [
|
|
1136
|
+
CAMERA_SAMPLED,
|
|
1137
|
+
CELL_SIZE_SENSITIVE,
|
|
1138
|
+
"Positions, not gaze: a region can be well covered on foot and still never looked at.",
|
|
1139
|
+
],
|
|
1140
|
+
sourceChannels: ["camera_sample"],
|
|
1141
|
+
related: ["scene_representation", "position_heatmap", "view_coverage_histogram"],
|
|
1142
|
+
comparable: { primary: "count", direction: "neutral", minSample: 100 },
|
|
1143
|
+
category: "navigation",
|
|
1144
|
+
},
|
|
1145
|
+
camera_distance: {
|
|
1146
|
+
id: "camera_distance",
|
|
1147
|
+
title: "Camera distance / zoom distribution",
|
|
1148
|
+
description: "Histogram of the distance from each camera sample to a reference point (by default the " +
|
|
1149
|
+
"world origin; pass the scene-AABB centre for a product view). One row per `bucketSize`-wide " +
|
|
1150
|
+
"distance band. A proxy for engagement intensity — how close visitors get to the subject.",
|
|
1151
|
+
builder: "buildCameraDistance",
|
|
1152
|
+
endpoint: { method: "GET", path: "/api/v1/camera/distance" },
|
|
1153
|
+
grain: "bucket",
|
|
1154
|
+
dimensions: ["scene", "session"],
|
|
1155
|
+
filters: [
|
|
1156
|
+
"since",
|
|
1157
|
+
"until",
|
|
1158
|
+
"centerX",
|
|
1159
|
+
"centerY",
|
|
1160
|
+
"centerZ",
|
|
1161
|
+
"bucketSize",
|
|
1162
|
+
"limit",
|
|
1163
|
+
"scene",
|
|
1164
|
+
"session",
|
|
1165
|
+
"format",
|
|
1166
|
+
],
|
|
1167
|
+
row: z.object({ bucket: int, count: int }),
|
|
1168
|
+
columns: {
|
|
1169
|
+
bucket: {
|
|
1170
|
+
description: "Band index; the band covers `bucket * bucketSize` to `(bucket+1) * bucketSize`.",
|
|
1171
|
+
unit: "index",
|
|
1172
|
+
label: true,
|
|
1173
|
+
axis: true,
|
|
1174
|
+
},
|
|
1175
|
+
count: { description: "Camera samples in the band.", unit: "count", measure: true },
|
|
1176
|
+
},
|
|
1177
|
+
limits: { maxRows: 1000, maxSummaryRows: 8 },
|
|
1178
|
+
interpretation: "Distances are measured from the supplied `center`, which defaults to the origin — leaving it " +
|
|
1179
|
+
"unset in a scene whose subject is not at the origin makes the histogram meaningless.",
|
|
1180
|
+
caveats: [
|
|
1181
|
+
CAMERA_SAMPLED,
|
|
1182
|
+
"Bands are unlabelled; multiply by `bucketSize` to recover world units.",
|
|
1183
|
+
"A first-person scene spreads over many bands by construction; the metric is most informative for orbit/viewer scenes.",
|
|
1184
|
+
],
|
|
1185
|
+
sourceChannels: ["camera_sample"],
|
|
1186
|
+
related: ["position_heatmap", "camera_gestures", "scene_coverage"],
|
|
1187
|
+
category: "navigation",
|
|
1188
|
+
},
|
|
1189
|
+
click_rays: {
|
|
1190
|
+
id: "click_rays",
|
|
1191
|
+
title: "View-gated click rays",
|
|
1192
|
+
description: "Each click aggregated into a ray from an origin voxel to the hit voxel, sharing the world " +
|
|
1193
|
+
"heatmap's grid. One row per (origin voxel, hit voxel, mesh). Shows not just *what* was " +
|
|
1194
|
+
"clicked but *from where* — the standpoint an interaction was made from.",
|
|
1195
|
+
builder: "buildClickGazeRay",
|
|
1196
|
+
endpoint: { method: "GET", path: "/api/v1/heatmaps/click-rays" },
|
|
1197
|
+
grain: "voxel",
|
|
1198
|
+
dimensions: ["scene", "session", "source", "mesh"],
|
|
1199
|
+
filters: ["since", "until", "cellSize", "limit", "scene", "source", "session", "format"],
|
|
1200
|
+
row: z.object({
|
|
1201
|
+
cam_vx: int,
|
|
1202
|
+
cam_vy: int,
|
|
1203
|
+
cam_vz: int,
|
|
1204
|
+
origin_x: num,
|
|
1205
|
+
origin_y: num,
|
|
1206
|
+
origin_z: num,
|
|
1207
|
+
hit_vx: int,
|
|
1208
|
+
hit_vy: int,
|
|
1209
|
+
hit_vz: int,
|
|
1210
|
+
hit_x: num,
|
|
1211
|
+
hit_y: num,
|
|
1212
|
+
hit_z: num,
|
|
1213
|
+
mesh: text,
|
|
1214
|
+
count: int,
|
|
1215
|
+
}),
|
|
1216
|
+
columns: {
|
|
1217
|
+
cam_vx: { description: "Origin voxel X index.", unit: "index" },
|
|
1218
|
+
cam_vy: { description: "Origin voxel Y index.", unit: "index" },
|
|
1219
|
+
cam_vz: { description: "Origin voxel Z index.", unit: "index" },
|
|
1220
|
+
origin_x: { description: "Mean origin X within the voxel.", unit: "world-units" },
|
|
1221
|
+
origin_y: { description: "Mean origin Y within the voxel.", unit: "world-units" },
|
|
1222
|
+
origin_z: { description: "Mean origin Z within the voxel.", unit: "world-units" },
|
|
1223
|
+
hit_vx: { description: "Hit voxel X index.", unit: "index" },
|
|
1224
|
+
hit_vy: { description: "Hit voxel Y index.", unit: "index" },
|
|
1225
|
+
hit_vz: { description: "Hit voxel Z index.", unit: "index" },
|
|
1226
|
+
hit_x: { description: "Mean hit X within the voxel.", unit: "world-units" },
|
|
1227
|
+
hit_y: { description: "Mean hit Y within the voxel.", unit: "world-units" },
|
|
1228
|
+
hit_z: { description: "Mean hit Z within the voxel.", unit: "world-units" },
|
|
1229
|
+
mesh: { description: "Clicked mesh name.", unit: "label", label: true },
|
|
1230
|
+
count: { description: "Clicks on this ray.", unit: "count", measure: true },
|
|
1231
|
+
},
|
|
1232
|
+
limits: { maxRows: 1000, maxSummaryRows: 8 },
|
|
1233
|
+
interpretation: "The origin is the true pointing origin: a pose source (XR controller, hand, gaze) supplies " +
|
|
1234
|
+
"its own ray origin, a flat pointer is un-projected onto the camera near plane (ADR 0043), " +
|
|
1235
|
+
"and anything else falls back to the nearest preceding camera position.",
|
|
1236
|
+
caveats: [
|
|
1237
|
+
CELL_SIZE_SENSITIVE,
|
|
1238
|
+
"Flat-pointer origins need the camera intrinsics (`fov`/`aspect`/`near`) on the joined camera sample; older data falls back to the camera position.",
|
|
1239
|
+
"Clicks with neither a ray, a reconstructable near-plane point, nor a camera origin are dropped entirely.",
|
|
1240
|
+
"The camera join is nearest-preceding-in-time, so a long `sampleCameraMs` makes the origin approximate.",
|
|
1241
|
+
],
|
|
1242
|
+
sourceChannels: ["pointer_click", "camera_sample"],
|
|
1243
|
+
related: ["flow_links", "world_heatmap", "mesh_reachability", "top_meshes"],
|
|
1244
|
+
comparable: { primary: "count", direction: "neutral", minSample: 50 },
|
|
1245
|
+
category: "interaction",
|
|
1246
|
+
},
|
|
1247
|
+
flow_links: {
|
|
1248
|
+
id: "flow_links",
|
|
1249
|
+
title: "Gaze → mesh flow links",
|
|
1250
|
+
description: "Weighted links from a camera-direction bin to the mesh that was clicked while facing that " +
|
|
1251
|
+
"way. One row per (direction bin, mesh), or per (standpoint voxel, direction bin, mesh) in " +
|
|
1252
|
+
"position-aware mode. Connects where people looked from to what they acted on.",
|
|
1253
|
+
builder: "buildFlowHeatmap",
|
|
1254
|
+
endpoint: { method: "GET", path: "/api/v1/heatmaps/flow" },
|
|
1255
|
+
grain: "bin",
|
|
1256
|
+
dimensions: ["scene", "session", "mesh", "cameraMode"],
|
|
1257
|
+
filters: [
|
|
1258
|
+
"since",
|
|
1259
|
+
"until",
|
|
1260
|
+
"bins",
|
|
1261
|
+
"limit",
|
|
1262
|
+
"scene",
|
|
1263
|
+
"session",
|
|
1264
|
+
"cameraMode",
|
|
1265
|
+
"cellSize",
|
|
1266
|
+
"groupByOrigin",
|
|
1267
|
+
"originVoxel",
|
|
1268
|
+
"format",
|
|
1269
|
+
],
|
|
1270
|
+
row: z.object({
|
|
1271
|
+
azimuth_bin: int,
|
|
1272
|
+
elevation_bin: int,
|
|
1273
|
+
mesh: text,
|
|
1274
|
+
count: int,
|
|
1275
|
+
origin_vx: int.optional(),
|
|
1276
|
+
origin_vy: int.optional(),
|
|
1277
|
+
origin_vz: int.optional(),
|
|
1278
|
+
origin_x: num.optional(),
|
|
1279
|
+
origin_y: num.optional(),
|
|
1280
|
+
origin_z: num.optional(),
|
|
1281
|
+
}),
|
|
1282
|
+
columns: {
|
|
1283
|
+
azimuth_bin: { description: "Horizontal view-direction bin.", unit: "index" },
|
|
1284
|
+
elevation_bin: { description: "Vertical view-direction bin.", unit: "index" },
|
|
1285
|
+
mesh: { description: "Clicked mesh name.", unit: "label", label: true },
|
|
1286
|
+
count: { description: "Clicks on this link.", unit: "count", measure: true },
|
|
1287
|
+
origin_vx: {
|
|
1288
|
+
description: "Standpoint voxel X index; present only in position-aware mode.",
|
|
1289
|
+
unit: "index",
|
|
1290
|
+
},
|
|
1291
|
+
origin_vy: {
|
|
1292
|
+
description: "Standpoint voxel Y index; position-aware mode only.",
|
|
1293
|
+
unit: "index",
|
|
1294
|
+
},
|
|
1295
|
+
origin_vz: {
|
|
1296
|
+
description: "Standpoint voxel Z index; position-aware mode only.",
|
|
1297
|
+
unit: "index",
|
|
1298
|
+
},
|
|
1299
|
+
origin_x: {
|
|
1300
|
+
description: "Mean standpoint X for the voxel; position-aware mode only.",
|
|
1301
|
+
unit: "world-units",
|
|
1302
|
+
},
|
|
1303
|
+
origin_y: {
|
|
1304
|
+
description: "Mean standpoint Y; position-aware mode only.",
|
|
1305
|
+
unit: "world-units",
|
|
1306
|
+
},
|
|
1307
|
+
origin_z: {
|
|
1308
|
+
description: "Mean standpoint Z; position-aware mode only.",
|
|
1309
|
+
unit: "world-units",
|
|
1310
|
+
},
|
|
1311
|
+
},
|
|
1312
|
+
limits: { maxRows: 1000, maxSummaryRows: 8 },
|
|
1313
|
+
interpretation: "Without `groupByOrigin` or `originVoxel` the six `origin_*` columns are absent and each row " +
|
|
1314
|
+
"is a pure direction→mesh link. Setting either adds the standpoint dimension and multiplies " +
|
|
1315
|
+
"the row count, so raise `limit` accordingly.",
|
|
1316
|
+
caveats: [
|
|
1317
|
+
"Clicks are ASOF-joined to the nearest preceding camera sample; a coarse `sampleCameraMs` blurs the direction attributed to a click.",
|
|
1318
|
+
"Only clicks that hit a mesh participate — empty-space clicks are the `dead_clicks` signal.",
|
|
1319
|
+
CELL_SIZE_SENSITIVE,
|
|
1320
|
+
],
|
|
1321
|
+
sourceChannels: ["pointer_click", "camera_sample"],
|
|
1322
|
+
related: ["click_rays", "camera_heatmap", "top_meshes"],
|
|
1323
|
+
comparable: { primary: "count", direction: "neutral", minSample: 50 },
|
|
1324
|
+
category: "interaction",
|
|
1325
|
+
},
|
|
1326
|
+
// =========================================================================
|
|
1327
|
+
// Meshes & interaction
|
|
1328
|
+
// =========================================================================
|
|
1329
|
+
top_meshes: {
|
|
1330
|
+
id: "top_meshes",
|
|
1331
|
+
title: "Most-interacted meshes",
|
|
1332
|
+
description: "Meshes ranked by how many events referenced them. One row per mesh. The 3D analogue of a " +
|
|
1333
|
+
"top-pages report: which objects in the scene draw activity.",
|
|
1334
|
+
builder: "buildTopMeshes",
|
|
1335
|
+
endpoint: { method: "GET", path: "/api/v1/meshes/top" },
|
|
1336
|
+
grain: "mesh",
|
|
1337
|
+
dimensions: ["mesh", "session"],
|
|
1338
|
+
filters: ["since", "until", "bins", "limit", "session", "format"],
|
|
1339
|
+
row: z.object({ mesh: text, count: int }),
|
|
1340
|
+
columns: {
|
|
1341
|
+
mesh: { description: "Mesh / object name.", unit: "label", label: true },
|
|
1342
|
+
count: { description: "Events referencing the mesh.", unit: "count", measure: true },
|
|
1343
|
+
},
|
|
1344
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
1345
|
+
interpretation: "This counts **every** mesh-referencing event, including passive gaze hits — so it measures " +
|
|
1346
|
+
"attention, not intent. Use `mesh_sources` for active interactions only, and " +
|
|
1347
|
+
"`mesh_interaction_kinds` for how people acted.",
|
|
1348
|
+
caveats: [
|
|
1349
|
+
"No scene filter on this builder; scope by time or session instead.",
|
|
1350
|
+
"Passive `camera_sample` gaze hits inflate the count when the gaze raycast is on (ADR 0030).",
|
|
1351
|
+
SMALL_SAMPLE,
|
|
1352
|
+
"`bins` is accepted by the shared range schema but ignored.",
|
|
1353
|
+
],
|
|
1354
|
+
sourceChannels: ["mesh_interaction", "pointer_click", "camera_sample"],
|
|
1355
|
+
related: ["mesh_sources", "mesh_interaction_kinds", "mesh_dwell", "mesh_blind_spots"],
|
|
1356
|
+
comparable: { primary: "count", direction: "neutral", minSample: 30 },
|
|
1357
|
+
category: "interaction",
|
|
1358
|
+
},
|
|
1359
|
+
mesh_sources: {
|
|
1360
|
+
id: "mesh_sources",
|
|
1361
|
+
title: "Mesh interactions by input source",
|
|
1362
|
+
description: "The mesh leaderboard broken out by the input source that drove each interaction (#74, " +
|
|
1363
|
+
"ADR 0011). One row per (mesh, source). Scoped to **active** interactions, so passive gaze " +
|
|
1364
|
+
"never inflates popularity.",
|
|
1365
|
+
builder: "buildTopMeshesBySource",
|
|
1366
|
+
endpoint: { method: "GET", path: "/api/v1/meshes/sources" },
|
|
1367
|
+
grain: "mesh",
|
|
1368
|
+
dimensions: ["mesh", "source", "scene", "session", "cameraMode"],
|
|
1369
|
+
filters: [
|
|
1370
|
+
"since",
|
|
1371
|
+
"until",
|
|
1372
|
+
"bins",
|
|
1373
|
+
"limit",
|
|
1374
|
+
"scene",
|
|
1375
|
+
"session",
|
|
1376
|
+
"source",
|
|
1377
|
+
"cameraMode",
|
|
1378
|
+
"format",
|
|
1379
|
+
],
|
|
1380
|
+
row: z.object({ mesh: text, source: text, count: int }),
|
|
1381
|
+
columns: {
|
|
1382
|
+
mesh: { description: "Mesh / object name.", unit: "label", label: true },
|
|
1383
|
+
source: { description: "Input source that drove the interaction.", unit: "label" },
|
|
1384
|
+
count: { description: "Active interactions for the pairing.", unit: "count", measure: true },
|
|
1385
|
+
},
|
|
1386
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
1387
|
+
interpretation: "Summing a mesh's rows gives its active-interaction total — the honest 'most used object' " +
|
|
1388
|
+
"number, unlike `top_meshes` which also counts gaze.",
|
|
1389
|
+
caveats: [
|
|
1390
|
+
"Counts `mesh_interaction` and `pointer_click` only.",
|
|
1391
|
+
"A connector that never reports `source` groups everything under the realized default (`mouse`, ADR 0011).",
|
|
1392
|
+
SMALL_SAMPLE,
|
|
1393
|
+
"`bins` is accepted by the shared schema but ignored.",
|
|
1394
|
+
],
|
|
1395
|
+
sourceChannels: ["mesh_interaction", "pointer_click"],
|
|
1396
|
+
related: ["top_meshes", "interaction_sources", "mesh_interaction_kinds"],
|
|
1397
|
+
comparable: { primary: "count", direction: "neutral", minSample: 30 },
|
|
1398
|
+
category: "interaction",
|
|
1399
|
+
},
|
|
1400
|
+
mesh_trend: {
|
|
1401
|
+
id: "mesh_trend",
|
|
1402
|
+
title: "Per-mesh interaction trend",
|
|
1403
|
+
description: "The active-interaction tally per mesh, bucketed into fixed `interval`-second windows (#74). " +
|
|
1404
|
+
"One row per (mesh, bucket), oldest bucket first — the per-mesh sparkline behind the " +
|
|
1405
|
+
"leaderboard.",
|
|
1406
|
+
builder: "buildTopMeshesTrend",
|
|
1407
|
+
endpoint: { method: "GET", path: "/api/v1/meshes/trend" },
|
|
1408
|
+
grain: "bucket",
|
|
1409
|
+
dimensions: ["mesh", "scene", "session", "source", "cameraMode"],
|
|
1410
|
+
filters: [
|
|
1411
|
+
"since",
|
|
1412
|
+
"until",
|
|
1413
|
+
"bins",
|
|
1414
|
+
"limit",
|
|
1415
|
+
"scene",
|
|
1416
|
+
"session",
|
|
1417
|
+
"source",
|
|
1418
|
+
"cameraMode",
|
|
1419
|
+
"interval",
|
|
1420
|
+
"format",
|
|
1421
|
+
],
|
|
1422
|
+
row: z.object({ mesh: text, bucket: int, count: int }),
|
|
1423
|
+
columns: {
|
|
1424
|
+
mesh: { description: "Mesh / object name.", unit: "label", label: true },
|
|
1425
|
+
bucket: { description: "Window start as epoch milliseconds.", unit: "epoch-ms", axis: true },
|
|
1426
|
+
count: { description: "Active interactions in the window.", unit: "count", measure: true },
|
|
1427
|
+
},
|
|
1428
|
+
limits: { maxRows: 2000, maxSummaryRows: 10 },
|
|
1429
|
+
interpretation: "Order buckets per mesh and compare the recent half against the earlier half for a " +
|
|
1430
|
+
"rising/falling delta. Buckets with no interaction are absent, not zero-filled.",
|
|
1431
|
+
caveats: [
|
|
1432
|
+
"Counts `mesh_interaction` and `pointer_click` only (passive gaze excluded).",
|
|
1433
|
+
"The row cap truncates (mesh, bucket) pairs, so a wide range with many meshes can lose the tail of the trend.",
|
|
1434
|
+
SMALL_SAMPLE,
|
|
1435
|
+
"`bins` is accepted by the shared schema but ignored.",
|
|
1436
|
+
],
|
|
1437
|
+
sourceChannels: ["mesh_interaction", "pointer_click"],
|
|
1438
|
+
related: ["top_meshes", "mesh_sources", "timeseries"],
|
|
1439
|
+
comparable: { primary: "count", direction: "neutral", minSample: 30 },
|
|
1440
|
+
category: "interaction",
|
|
1441
|
+
},
|
|
1442
|
+
mesh_dwell: {
|
|
1443
|
+
id: "mesh_dwell",
|
|
1444
|
+
title: "Per-object dwell / attention",
|
|
1445
|
+
description: "How long each object spent on screen and near the view centre, from `mesh_visibility` " +
|
|
1446
|
+
"summaries (#37). One row per mesh, ranked by total on-screen time. The 3D analogue of " +
|
|
1447
|
+
"time-on-element.",
|
|
1448
|
+
builder: "buildMeshDwell",
|
|
1449
|
+
endpoint: { method: "GET", path: "/api/v1/meshes/dwell" },
|
|
1450
|
+
grain: "mesh",
|
|
1451
|
+
dimensions: ["mesh", "scene", "session"],
|
|
1452
|
+
filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
|
|
1453
|
+
row: z.object({
|
|
1454
|
+
mesh: text,
|
|
1455
|
+
visible_ms: num,
|
|
1456
|
+
centered_ms: num,
|
|
1457
|
+
max_screen_fraction: num,
|
|
1458
|
+
samples: int,
|
|
1459
|
+
}),
|
|
1460
|
+
columns: {
|
|
1461
|
+
mesh: { description: "Mesh / object name.", unit: "label", label: true },
|
|
1462
|
+
visible_ms: {
|
|
1463
|
+
description: "Total on-screen time across the range.",
|
|
1464
|
+
unit: "ms",
|
|
1465
|
+
measure: true,
|
|
1466
|
+
},
|
|
1467
|
+
centered_ms: {
|
|
1468
|
+
description: "Time the object was near the view centre (a gaze proxy).",
|
|
1469
|
+
unit: "ms",
|
|
1470
|
+
},
|
|
1471
|
+
max_screen_fraction: {
|
|
1472
|
+
description: "Largest screen fraction the object reached (prominence proxy), 0..1.",
|
|
1473
|
+
unit: "ratio",
|
|
1474
|
+
},
|
|
1475
|
+
samples: { description: "Bucketed visibility summaries behind the totals.", unit: "count" },
|
|
1476
|
+
},
|
|
1477
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
1478
|
+
interpretation: "`centered_ms / visible_ms` is the share of on-screen time the object held the viewer's " +
|
|
1479
|
+
"attention rather than merely being in frame. High `visible_ms` with low `centered_ms` is " +
|
|
1480
|
+
"background scenery.",
|
|
1481
|
+
caveats: [
|
|
1482
|
+
"Requires the per-object dwell capture option (`capture.meshVisibility`, **off by default**, ADR 0012); without it the result is empty.",
|
|
1483
|
+
"Only objects the developer chose to track are reported — this is never the whole scene.",
|
|
1484
|
+
"Durations are bucketed summaries, not continuous timing; short glances below the bucket threshold are lost.",
|
|
1485
|
+
],
|
|
1486
|
+
sourceChannels: ["mesh_visibility"],
|
|
1487
|
+
related: ["mesh_blind_spots", "hover_dwell", "top_meshes", "gaze_heatmap"],
|
|
1488
|
+
comparable: { primary: "visible_ms", direction: "neutral", minSample: 10 },
|
|
1489
|
+
category: "attention",
|
|
1490
|
+
},
|
|
1491
|
+
mesh_blind_spots: {
|
|
1492
|
+
id: "mesh_blind_spots",
|
|
1493
|
+
title: "Blind spots / never-noticed meshes",
|
|
1494
|
+
description: "Per mesh, how long it was visible against how much it was engaged with (#143). One row per " +
|
|
1495
|
+
"mesh that was seen at least once, most-seen-yet-least-touched first. A product detail with " +
|
|
1496
|
+
"high visibility and near-zero interaction is a blind spot.",
|
|
1497
|
+
builder: "buildMeshBlindSpots",
|
|
1498
|
+
endpoint: { method: "GET", path: "/api/v1/meshes/blind-spots" },
|
|
1499
|
+
grain: "mesh",
|
|
1500
|
+
dimensions: ["mesh", "scene", "session"],
|
|
1501
|
+
filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
|
|
1502
|
+
row: z.object({
|
|
1503
|
+
mesh: text,
|
|
1504
|
+
visible_ms: num,
|
|
1505
|
+
vis_samples: int,
|
|
1506
|
+
interactions: int,
|
|
1507
|
+
hover_ms: num,
|
|
1508
|
+
hover_episodes: int,
|
|
1509
|
+
}),
|
|
1510
|
+
columns: {
|
|
1511
|
+
mesh: { description: "Mesh / object name.", unit: "label", label: true },
|
|
1512
|
+
visible_ms: {
|
|
1513
|
+
description: "Total on-screen time (> 0 by construction).",
|
|
1514
|
+
unit: "ms",
|
|
1515
|
+
measure: true,
|
|
1516
|
+
},
|
|
1517
|
+
vis_samples: { description: "Visibility summaries behind `visible_ms`.", unit: "count" },
|
|
1518
|
+
interactions: { description: "`mesh_interaction` events on the mesh.", unit: "count" },
|
|
1519
|
+
hover_ms: { description: "Hover-without-action time on the mesh.", unit: "ms" },
|
|
1520
|
+
hover_episodes: { description: "Hover hesitation episodes on the mesh.", unit: "count" },
|
|
1521
|
+
},
|
|
1522
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
1523
|
+
interpretation: "Read `interactions + hover_episodes` as engagement and `visible_ms` as opportunity. The " +
|
|
1524
|
+
"blind spots are the rows at the top: plenty of opportunity, no engagement.",
|
|
1525
|
+
caveats: [
|
|
1526
|
+
"Requires the per-object dwell capture option (`capture.meshVisibility`, **off by default**); `hover_ms`/`hover_episodes` additionally need `capture.hoverDwell`.",
|
|
1527
|
+
"Only meshes with non-zero visibility appear — a mesh nobody ever rendered cannot be a blind spot here.",
|
|
1528
|
+
"Raw `pointer_click` is deliberately excluded (a mesh-hitting click already surfaces as a `mesh_interaction`).",
|
|
1529
|
+
SMALL_SAMPLE,
|
|
1530
|
+
],
|
|
1531
|
+
sourceChannels: ["mesh_visibility", "mesh_interaction", "hover_dwell"],
|
|
1532
|
+
related: ["mesh_dwell", "hover_dwell", "top_meshes", "gaze_heatmap"],
|
|
1533
|
+
comparable: { primary: "interactions", direction: "up", minSample: 10 },
|
|
1534
|
+
category: "attention",
|
|
1535
|
+
},
|
|
1536
|
+
mesh_interaction_kinds: {
|
|
1537
|
+
id: "mesh_interaction_kinds",
|
|
1538
|
+
title: "Interaction kinds per mesh",
|
|
1539
|
+
description: "Per-mesh counts of each interaction *kind* — hover, pick, click, drag, select, squeeze, " +
|
|
1540
|
+
"grab, release, teleport (#72, ADR 0023). One row per (mesh, kind). Separates an object " +
|
|
1541
|
+
"that is merely hovered from one that is actually picked or dragged.",
|
|
1542
|
+
builder: "buildMeshInteractionKinds",
|
|
1543
|
+
endpoint: { method: "GET", path: "/api/v1/meshes/kinds" },
|
|
1544
|
+
grain: "mesh",
|
|
1545
|
+
dimensions: ["mesh", "name", "scene", "session", "source", "cameraMode"],
|
|
1546
|
+
filters: [
|
|
1547
|
+
"since",
|
|
1548
|
+
"until",
|
|
1549
|
+
"bins",
|
|
1550
|
+
"limit",
|
|
1551
|
+
"scene",
|
|
1552
|
+
"session",
|
|
1553
|
+
"source",
|
|
1554
|
+
"cameraMode",
|
|
1555
|
+
"format",
|
|
1556
|
+
],
|
|
1557
|
+
row: z.object({ mesh: text, kind: text, count: int }),
|
|
1558
|
+
columns: {
|
|
1559
|
+
mesh: { description: "Mesh / object name.", unit: "label", label: true },
|
|
1560
|
+
kind: { description: "Interaction kind from `mesh_interaction.kind`.", unit: "label" },
|
|
1561
|
+
count: {
|
|
1562
|
+
description: "Interactions of that kind on the mesh.",
|
|
1563
|
+
unit: "count",
|
|
1564
|
+
measure: true,
|
|
1565
|
+
},
|
|
1566
|
+
},
|
|
1567
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
1568
|
+
interpretation: "The mix matters more than the totals: a hover-only object looks interactive but is not being " +
|
|
1569
|
+
"used; a drag-heavy one is a manipulator.",
|
|
1570
|
+
caveats: [
|
|
1571
|
+
"`mesh_interaction` only — a `pointer_click` that hit a mesh but produced no interaction event is not counted.",
|
|
1572
|
+
"Which kinds a connector emits is engine-specific (ADR 0023); an absent kind may mean 'not emitted', not 'never done'.",
|
|
1573
|
+
SMALL_SAMPLE,
|
|
1574
|
+
"`bins` is accepted by the shared schema but ignored.",
|
|
1575
|
+
],
|
|
1576
|
+
sourceChannels: ["mesh_interaction"],
|
|
1577
|
+
related: ["top_meshes", "mesh_sources", "hover_dwell"],
|
|
1578
|
+
comparable: { primary: "count", direction: "neutral", minSample: 30 },
|
|
1579
|
+
category: "interaction",
|
|
1580
|
+
},
|
|
1581
|
+
mesh_reachability: {
|
|
1582
|
+
id: "mesh_reachability",
|
|
1583
|
+
title: "Mesh reachability by distance",
|
|
1584
|
+
description: "How far each interacted mesh sat from where the visitor actually stood (#151). One row per " +
|
|
1585
|
+
"(mesh, distance band) with the mean distance in the band. Meshes whose interactions cluster " +
|
|
1586
|
+
"in far bands are consistently reached from an uncomfortable range.",
|
|
1587
|
+
builder: "buildReachability",
|
|
1588
|
+
endpoint: { method: "GET", path: "/api/v1/meshes/reachability" },
|
|
1589
|
+
grain: "mesh",
|
|
1590
|
+
dimensions: ["mesh", "scene", "session", "source", "cameraMode"],
|
|
1591
|
+
filters: [
|
|
1592
|
+
"since",
|
|
1593
|
+
"until",
|
|
1594
|
+
"bins",
|
|
1595
|
+
"limit",
|
|
1596
|
+
"scene",
|
|
1597
|
+
"session",
|
|
1598
|
+
"source",
|
|
1599
|
+
"cameraMode",
|
|
1600
|
+
"bucketSize",
|
|
1601
|
+
"format",
|
|
1602
|
+
],
|
|
1603
|
+
row: z.object({ mesh: text, bucket: int, count: int, avg_distance: num }),
|
|
1604
|
+
columns: {
|
|
1605
|
+
mesh: { description: "Mesh / object name.", unit: "label", label: true },
|
|
1606
|
+
bucket: {
|
|
1607
|
+
description: "Distance band index; covers `bucket * bucketSize` to `(bucket+1) * bucketSize`.",
|
|
1608
|
+
unit: "index",
|
|
1609
|
+
},
|
|
1610
|
+
count: { description: "Interactions in the band.", unit: "count", measure: true },
|
|
1611
|
+
avg_distance: {
|
|
1612
|
+
description: "Mean standpoint→hit distance within the band.",
|
|
1613
|
+
unit: "world-units",
|
|
1614
|
+
},
|
|
1615
|
+
},
|
|
1616
|
+
limits: { maxRows: 10000, maxSummaryRows: 10 },
|
|
1617
|
+
interpretation: "Actionable for VR UI placement and first-person layout: an object whose mass sits beyond " +
|
|
1618
|
+
"arm's reach either needs moving or needs a ranged affordance.",
|
|
1619
|
+
caveats: [
|
|
1620
|
+
"Only `mesh_interaction` events that carry a world hit point participate.",
|
|
1621
|
+
"The standpoint is the nearest **preceding** camera sample; interactions with no camera sample in range are dropped entirely.",
|
|
1622
|
+
CAMERA_SAMPLED,
|
|
1623
|
+
"`source` constrains the interaction side only — a camera sample's source is the realized default (ADR 0011).",
|
|
1624
|
+
],
|
|
1625
|
+
sourceChannels: ["mesh_interaction", "camera_sample"],
|
|
1626
|
+
related: ["click_rays", "top_meshes", "xr_sources"],
|
|
1627
|
+
comparable: { primary: "avg_distance", direction: "down", minSample: 30 },
|
|
1628
|
+
category: "interaction",
|
|
1629
|
+
},
|
|
1630
|
+
dead_clicks: {
|
|
1631
|
+
id: "dead_clicks",
|
|
1632
|
+
title: "Dead-click rate",
|
|
1633
|
+
description: "Of all clicks in the range, how many hit nothing at all (#46). Always a single row. A high " +
|
|
1634
|
+
"dead-click share is a 3D discoverability problem: visitors click where they expect something " +
|
|
1635
|
+
"interactive and get no response.",
|
|
1636
|
+
builder: "buildDeadClicks",
|
|
1637
|
+
endpoint: { method: "GET", path: "/api/v1/clicks/dead" },
|
|
1638
|
+
grain: "project",
|
|
1639
|
+
dimensions: ["scene", "session", "source", "cameraMode"],
|
|
1640
|
+
filters: [
|
|
1641
|
+
"since",
|
|
1642
|
+
"until",
|
|
1643
|
+
"bins",
|
|
1644
|
+
"limit",
|
|
1645
|
+
"scene",
|
|
1646
|
+
"session",
|
|
1647
|
+
"source",
|
|
1648
|
+
"cameraMode",
|
|
1649
|
+
"format",
|
|
1650
|
+
],
|
|
1651
|
+
row: z.object({ total_clicks: int, dead_clicks: intOrNull }),
|
|
1652
|
+
columns: {
|
|
1653
|
+
total_clicks: { description: "Clicks in scope.", unit: "count" },
|
|
1654
|
+
dead_clicks: {
|
|
1655
|
+
description: "Clicks whose hit test missed every mesh.",
|
|
1656
|
+
unit: "count",
|
|
1657
|
+
measure: true,
|
|
1658
|
+
rateOf: "total_clicks",
|
|
1659
|
+
},
|
|
1660
|
+
},
|
|
1661
|
+
limits: { maxRows: 1, maxSummaryRows: 1 },
|
|
1662
|
+
interpretation: "The rate is `dead_clicks / total_clicks`. Some background clicking is normal in an orbit " +
|
|
1663
|
+
"scene (people click empty space to deselect); the signal is a *change* in the rate or an " +
|
|
1664
|
+
"unusually high rate in a scene meant to be clicked.",
|
|
1665
|
+
caveats: [
|
|
1666
|
+
EMPTY_SET_NULLS,
|
|
1667
|
+
"Clicks are never sampled, so this rate is exact for the captured sessions.",
|
|
1668
|
+
"A scene with no pickable geometry registered will report a 100% dead rate — check `top_meshes` is non-empty first.",
|
|
1669
|
+
"Fewer than ~50 clicks makes the rate too noisy to act on.",
|
|
1670
|
+
],
|
|
1671
|
+
sourceChannels: ["pointer_click"],
|
|
1672
|
+
related: ["rage_clicks", "hover_dwell", "top_meshes", "pointer_heatmap"],
|
|
1673
|
+
comparable: { primary: "dead_clicks", direction: "down", minSample: 50 },
|
|
1674
|
+
category: "interaction",
|
|
1675
|
+
},
|
|
1676
|
+
rage_clicks: {
|
|
1677
|
+
id: "rage_clicks",
|
|
1678
|
+
title: "Rage-click clusters",
|
|
1679
|
+
description: "Rapid repeated clicks on the same mesh inside one time window (#47) — the 'I keep clicking " +
|
|
1680
|
+
"and nothing happens' frustration signal. One row per (session, mesh, window) that reached " +
|
|
1681
|
+
"`minRepeats`, biggest burst first.",
|
|
1682
|
+
builder: "buildRageClicks",
|
|
1683
|
+
endpoint: { method: "GET", path: "/api/v1/clicks/rage" },
|
|
1684
|
+
grain: "row",
|
|
1685
|
+
dimensions: ["session", "mesh", "scene", "source", "cameraMode"],
|
|
1686
|
+
filters: [
|
|
1687
|
+
"since",
|
|
1688
|
+
"until",
|
|
1689
|
+
"bins",
|
|
1690
|
+
"limit",
|
|
1691
|
+
"scene",
|
|
1692
|
+
"session",
|
|
1693
|
+
"source",
|
|
1694
|
+
"cameraMode",
|
|
1695
|
+
"interval",
|
|
1696
|
+
"minRepeats",
|
|
1697
|
+
"format",
|
|
1698
|
+
],
|
|
1699
|
+
row: z.object({ session_id: text, mesh: text, bucket: int, clicks: int }),
|
|
1700
|
+
columns: {
|
|
1701
|
+
session_id: { description: "Session the burst happened in.", unit: "id" },
|
|
1702
|
+
mesh: { description: "Mesh that was clicked repeatedly.", unit: "label", label: true },
|
|
1703
|
+
bucket: { description: "Window start as epoch milliseconds.", unit: "epoch-ms" },
|
|
1704
|
+
clicks: {
|
|
1705
|
+
description: "Clicks on the mesh within the window.",
|
|
1706
|
+
unit: "count",
|
|
1707
|
+
measure: true,
|
|
1708
|
+
},
|
|
1709
|
+
},
|
|
1710
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
1711
|
+
interpretation: "Rows are bursts, not visitors: one frustrated session can produce several. Count distinct " +
|
|
1712
|
+
"`session_id` values before claiming how many people were affected.",
|
|
1713
|
+
caveats: [
|
|
1714
|
+
"Only clicks that *hit* a mesh count; rapid clicking on empty space is `dead_clicks`.",
|
|
1715
|
+
"Windows are fixed-width buckets, so a burst straddling a boundary can split into two smaller clusters that both fall below `minRepeats`.",
|
|
1716
|
+
"Legitimate rapid clicking (a rotate-by-clicking control, a rapid-fire game) reads as rage here — check the mesh's role.",
|
|
1717
|
+
],
|
|
1718
|
+
sourceChannels: ["pointer_click"],
|
|
1719
|
+
related: ["dead_clicks", "hover_dwell", "mesh_interaction_kinds"],
|
|
1720
|
+
comparable: { primary: "clicks", direction: "down", minSample: 10 },
|
|
1721
|
+
category: "interaction",
|
|
1722
|
+
},
|
|
1723
|
+
hover_dwell: {
|
|
1724
|
+
id: "hover_dwell",
|
|
1725
|
+
title: "Hover hesitation per object",
|
|
1726
|
+
description: "Per mesh, how long visitors lingered on an object *without clicking it*, over how many " +
|
|
1727
|
+
"episodes, and the longest single hover (#48). One row per mesh. High dwell with few " +
|
|
1728
|
+
"interactions flags objects that look interactive but are not.",
|
|
1729
|
+
builder: "buildHoverDwell",
|
|
1730
|
+
endpoint: { method: "GET", path: "/api/v1/hover/dwell" },
|
|
1731
|
+
grain: "mesh",
|
|
1732
|
+
dimensions: ["mesh", "scene", "session", "source", "cameraMode"],
|
|
1733
|
+
filters: [
|
|
1734
|
+
"since",
|
|
1735
|
+
"until",
|
|
1736
|
+
"bins",
|
|
1737
|
+
"limit",
|
|
1738
|
+
"scene",
|
|
1739
|
+
"session",
|
|
1740
|
+
"source",
|
|
1741
|
+
"cameraMode",
|
|
1742
|
+
"format",
|
|
1743
|
+
],
|
|
1744
|
+
row: z.object({ mesh: text, dwell_ms: num, max_dwell_ms: num, episodes: int }),
|
|
1745
|
+
columns: {
|
|
1746
|
+
mesh: { description: "Mesh / object name.", unit: "label", label: true },
|
|
1747
|
+
dwell_ms: { description: "Total hover-without-action time.", unit: "ms", measure: true },
|
|
1748
|
+
max_dwell_ms: { description: "Longest single hover episode.", unit: "ms" },
|
|
1749
|
+
episodes: { description: "Hover episodes contributing to the totals.", unit: "count" },
|
|
1750
|
+
},
|
|
1751
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
1752
|
+
interpretation: "`dwell_ms / episodes` is the average hesitation. A long average on an object that is not " +
|
|
1753
|
+
"meant to be interactive is a false affordance; on one that is, it is a discoverability gap.",
|
|
1754
|
+
caveats: [
|
|
1755
|
+
"Requires the hover-hesitation capture option (`capture.hoverDwell`, **off by default**, ADR 0012); without it the result is empty.",
|
|
1756
|
+
"Hover is a flat-pointer concept; XR sources rarely produce it.",
|
|
1757
|
+
"One bucketed episode is emitted per hover, so sub-threshold glances are not counted.",
|
|
1758
|
+
],
|
|
1759
|
+
sourceChannels: ["hover_dwell"],
|
|
1760
|
+
related: ["mesh_blind_spots", "dead_clicks", "mesh_dwell", "mesh_interaction_kinds"],
|
|
1761
|
+
comparable: { primary: "dwell_ms", direction: "down", minSample: 10 },
|
|
1762
|
+
category: "attention",
|
|
1763
|
+
},
|
|
1764
|
+
interaction_sources: {
|
|
1765
|
+
id: "interaction_sources",
|
|
1766
|
+
title: "Interactions by input source",
|
|
1767
|
+
description: "For every interaction event that carries an input source, how many fired per (event type, " +
|
|
1768
|
+
"source) and across how many distinct sessions (ADR 0011). One row per pairing. Turns " +
|
|
1769
|
+
"`source` from a filter into the modality mix of the audience.",
|
|
1770
|
+
builder: "buildInteractionsBySource",
|
|
1771
|
+
endpoint: { method: "GET", path: "/api/v1/interactions/sources" },
|
|
1772
|
+
grain: "row",
|
|
1773
|
+
dimensions: ["event_type", "source", "scene", "session", "cameraMode"],
|
|
1774
|
+
filters: [
|
|
1775
|
+
"since",
|
|
1776
|
+
"until",
|
|
1777
|
+
"bins",
|
|
1778
|
+
"limit",
|
|
1779
|
+
"scene",
|
|
1780
|
+
"session",
|
|
1781
|
+
"source",
|
|
1782
|
+
"cameraMode",
|
|
1783
|
+
"format",
|
|
1784
|
+
],
|
|
1785
|
+
row: z.object({ event_type: text, source: text, count: int, sessions: int }),
|
|
1786
|
+
columns: {
|
|
1787
|
+
event_type: { description: "The interaction event type.", unit: "label" },
|
|
1788
|
+
source: { description: "Input source that triggered it.", unit: "label", label: true },
|
|
1789
|
+
count: { description: "Events for the pairing.", unit: "count", measure: true },
|
|
1790
|
+
sessions: { description: "Distinct sessions that produced the pairing.", unit: "sessions" },
|
|
1791
|
+
},
|
|
1792
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
1793
|
+
interpretation: "Compare `sessions` as well as `count`: a single power user on a gamepad can dominate `count` " +
|
|
1794
|
+
"while representing one visitor.",
|
|
1795
|
+
caveats: [
|
|
1796
|
+
"Only events that carry a non-empty `source` participate.",
|
|
1797
|
+
"A connector that does not report `source` realizes the `mouse` default (ADR 0011), so a flat-only project looks 100% mouse by construction.",
|
|
1798
|
+
SMALL_SAMPLE,
|
|
1799
|
+
],
|
|
1800
|
+
sourceChannels: ["pointer_click", "pointer_move", "mesh_interaction", "input_action"],
|
|
1801
|
+
related: ["xr_sources", "mesh_sources", "top_input_actions"],
|
|
1802
|
+
comparable: { primary: "count", direction: "neutral", minSample: 30 },
|
|
1803
|
+
category: "interaction",
|
|
1804
|
+
},
|
|
1805
|
+
top_input_actions: {
|
|
1806
|
+
id: "top_input_actions",
|
|
1807
|
+
title: "Most-used shortcuts and actions",
|
|
1808
|
+
description: "App-level `input_action` labels — bound keyboard chords and gamepad buttons — ranked by how " +
|
|
1809
|
+
"often they fired, split by input source (#75, ADR 0023). One row per (action, source).",
|
|
1810
|
+
builder: "buildTopInputActions",
|
|
1811
|
+
endpoint: { method: "GET", path: "/api/v1/input-actions/top" },
|
|
1812
|
+
grain: "row",
|
|
1813
|
+
dimensions: ["name", "source", "scene", "session", "cameraMode"],
|
|
1814
|
+
filters: [
|
|
1815
|
+
"since",
|
|
1816
|
+
"until",
|
|
1817
|
+
"bins",
|
|
1818
|
+
"limit",
|
|
1819
|
+
"scene",
|
|
1820
|
+
"session",
|
|
1821
|
+
"source",
|
|
1822
|
+
"cameraMode",
|
|
1823
|
+
"format",
|
|
1824
|
+
],
|
|
1825
|
+
row: z.object({ action: text, source: text, count: int }),
|
|
1826
|
+
columns: {
|
|
1827
|
+
action: {
|
|
1828
|
+
description: "Developer-assigned action label, e.g. `rotate-left`.",
|
|
1829
|
+
unit: "label",
|
|
1830
|
+
label: true,
|
|
1831
|
+
},
|
|
1832
|
+
source: { description: "Input source (keyboard, gamepad, …).", unit: "label" },
|
|
1833
|
+
count: { description: "Times the action fired.", unit: "count", measure: true },
|
|
1834
|
+
},
|
|
1835
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
1836
|
+
interpretation: "Only actions the developer explicitly bound are captured, so the leaderboard is a view of " +
|
|
1837
|
+
"the *designed* shortcut surface — an absent action means it was never bound, not never used.",
|
|
1838
|
+
caveats: [
|
|
1839
|
+
"Keyboard capture is off unless `keyBindings` are supplied (privacy, ADR 0003/0023): arbitrary typing is never recorded.",
|
|
1840
|
+
SMALL_SAMPLE,
|
|
1841
|
+
"`bins` is accepted by the shared schema but ignored.",
|
|
1842
|
+
],
|
|
1843
|
+
sourceChannels: ["input_action"],
|
|
1844
|
+
related: ["interaction_sources", "camera_gestures"],
|
|
1845
|
+
comparable: { primary: "count", direction: "neutral", minSample: 30 },
|
|
1846
|
+
category: "interaction",
|
|
1847
|
+
},
|
|
1848
|
+
camera_gestures: {
|
|
1849
|
+
id: "camera_gestures",
|
|
1850
|
+
title: "Camera navigation gestures",
|
|
1851
|
+
description: "How often visitors moved the viewpoint and for how long, per gesture kind — orbit, pan, " +
|
|
1852
|
+
"dolly, zoom, roll, fly, navigate (ADR 0025). One row per kind. Separates deliberate " +
|
|
1853
|
+
"navigation intent from object selection.",
|
|
1854
|
+
builder: "buildCameraGestures",
|
|
1855
|
+
endpoint: { method: "GET", path: "/api/v1/camera-gestures" },
|
|
1856
|
+
grain: "row",
|
|
1857
|
+
dimensions: ["name", "scene", "session", "source", "cameraMode"],
|
|
1858
|
+
filters: [
|
|
1859
|
+
"since",
|
|
1860
|
+
"until",
|
|
1861
|
+
"bins",
|
|
1862
|
+
"limit",
|
|
1863
|
+
"scene",
|
|
1864
|
+
"session",
|
|
1865
|
+
"source",
|
|
1866
|
+
"cameraMode",
|
|
1867
|
+
"format",
|
|
1868
|
+
],
|
|
1869
|
+
row: z.object({ kind: text, gestures: int, total_ms: num, avg_ms: num, max_ms: num }),
|
|
1870
|
+
columns: {
|
|
1871
|
+
kind: { description: "Gesture class.", unit: "label", label: true },
|
|
1872
|
+
gestures: { description: "Gestures of this kind.", unit: "count", measure: true },
|
|
1873
|
+
total_ms: { description: "Total time spent in this gesture kind.", unit: "ms" },
|
|
1874
|
+
avg_ms: { description: "Mean gesture duration.", unit: "ms" },
|
|
1875
|
+
max_ms: { description: "Longest single gesture.", unit: "ms" },
|
|
1876
|
+
},
|
|
1877
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
1878
|
+
interpretation: "A high orbit/pan share with little dolly means people are circling but not inspecting; heavy " +
|
|
1879
|
+
"`fly` in an XR session is the smooth-locomotion discomfort risk (see `xr_locomotion`).",
|
|
1880
|
+
caveats: [
|
|
1881
|
+
"Gesture capture is on by default, but classification sensitivity is tunable (`cameraGestureSensitivity`), so counts are not comparable across differently-tuned apps.",
|
|
1882
|
+
"A click that does not move the camera emits no gesture — absence is not inactivity.",
|
|
1883
|
+
"A teleport emits both a `fly` gesture and a `teleport` interaction (ADR 0025); do not double-count.",
|
|
1884
|
+
],
|
|
1885
|
+
sourceChannels: ["camera_gesture"],
|
|
1886
|
+
related: ["xr_locomotion", "navigation_stats", "camera_distance", "top_input_actions"],
|
|
1887
|
+
comparable: { primary: "gestures", direction: "neutral", minSample: 30 },
|
|
1888
|
+
category: "navigation",
|
|
1889
|
+
},
|
|
1890
|
+
navigation_stats: {
|
|
1891
|
+
id: "navigation_stats",
|
|
1892
|
+
title: "Navigation effort per session",
|
|
1893
|
+
description: "Per session, how far the camera travelled and how much of that travel was active rather " +
|
|
1894
|
+
"than idle dwell. One row per session. A high segment count with low active distance flags a " +
|
|
1895
|
+
"stuck or lost visitor.",
|
|
1896
|
+
builder: "buildNavigationStats",
|
|
1897
|
+
endpoint: { method: "GET", path: "/api/v1/navigation" },
|
|
1898
|
+
grain: "session",
|
|
1899
|
+
dimensions: ["session", "scene"],
|
|
1900
|
+
filters: ["since", "until", "moveThreshold", "limit", "scene", "session", "format"],
|
|
1901
|
+
row: z.object({
|
|
1902
|
+
session_id: text,
|
|
1903
|
+
segments: int,
|
|
1904
|
+
total_distance: num,
|
|
1905
|
+
active_segments: int,
|
|
1906
|
+
active_distance: num,
|
|
1907
|
+
}),
|
|
1908
|
+
columns: {
|
|
1909
|
+
session_id: { description: "Session identifier.", unit: "id", label: true },
|
|
1910
|
+
segments: { description: "Inter-sample segments in the session.", unit: "count" },
|
|
1911
|
+
total_distance: { description: "Total path length.", unit: "world-units", measure: true },
|
|
1912
|
+
active_segments: {
|
|
1913
|
+
description: "Segments whose distance cleared `moveThreshold`.",
|
|
1914
|
+
unit: "count",
|
|
1915
|
+
rateOf: "segments",
|
|
1916
|
+
},
|
|
1917
|
+
active_distance: {
|
|
1918
|
+
description: "Path length across active segments only.",
|
|
1919
|
+
unit: "world-units",
|
|
1920
|
+
},
|
|
1921
|
+
},
|
|
1922
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
1923
|
+
interpretation: "`active_segments / segments` is the share of the session spent moving. Distances are in " +
|
|
1924
|
+
"world units and only comparable within one scene's scale.",
|
|
1925
|
+
caveats: [
|
|
1926
|
+
CAMERA_SAMPLED,
|
|
1927
|
+
"Distance is the sum of straight lines between samples, so it under-reports curved paths and over-reports teleports.",
|
|
1928
|
+
"`moveThreshold` defines idle; change it and every number moves.",
|
|
1929
|
+
],
|
|
1930
|
+
sourceChannels: ["camera_sample"],
|
|
1931
|
+
related: ["backtrack_ratio", "aggregate_paths", "position_heatmap", "camera_gestures"],
|
|
1932
|
+
comparable: { primary: "total_distance", direction: "neutral", minSample: 10 },
|
|
1933
|
+
category: "navigation",
|
|
1934
|
+
},
|
|
1935
|
+
backtrack_ratio: {
|
|
1936
|
+
id: "backtrack_ratio",
|
|
1937
|
+
title: "Path retrace / backtracking",
|
|
1938
|
+
description: "Per scene, the share of coarse-grid cell entries that re-entered an already-visited cell " +
|
|
1939
|
+
"(#153). One row per scene. A high ratio flags a dead end, a missed cue, or a puzzle that is " +
|
|
1940
|
+
"not reading clearly.",
|
|
1941
|
+
builder: "buildBacktrackRatio",
|
|
1942
|
+
endpoint: { method: "GET", path: "/api/v1/backtrack" },
|
|
1943
|
+
grain: "scene",
|
|
1944
|
+
dimensions: ["scene", "session"],
|
|
1945
|
+
filters: ["since", "until", "cellSize", "limit", "scene", "session", "format"],
|
|
1946
|
+
row: z.object({
|
|
1947
|
+
scene: text,
|
|
1948
|
+
sessions: int,
|
|
1949
|
+
entries: int,
|
|
1950
|
+
revisits: int,
|
|
1951
|
+
backtrack_ratio: num,
|
|
1952
|
+
}),
|
|
1953
|
+
columns: {
|
|
1954
|
+
scene: {
|
|
1955
|
+
description: "Scene the counts are pooled over; may be empty.",
|
|
1956
|
+
unit: "id",
|
|
1957
|
+
label: true,
|
|
1958
|
+
},
|
|
1959
|
+
sessions: {
|
|
1960
|
+
description: "Distinct sessions that entered any cell in the scene.",
|
|
1961
|
+
unit: "sessions",
|
|
1962
|
+
},
|
|
1963
|
+
entries: { description: "Cell entries (consecutive dwell collapsed).", unit: "count" },
|
|
1964
|
+
revisits: {
|
|
1965
|
+
description: "Entries into an already-visited cell.",
|
|
1966
|
+
unit: "count",
|
|
1967
|
+
rateOf: "entries",
|
|
1968
|
+
},
|
|
1969
|
+
backtrack_ratio: {
|
|
1970
|
+
description: "`revisits / entries` in [0, 1); higher means more re-walking.",
|
|
1971
|
+
unit: "ratio",
|
|
1972
|
+
measure: true,
|
|
1973
|
+
},
|
|
1974
|
+
},
|
|
1975
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
1976
|
+
interpretation: "This is a coarse-grid revisit proxy, not true reverse-segment retracing: standing still " +
|
|
1977
|
+
"never counts, but circling a landmark does. Compare scenes at the same `cellSize`.",
|
|
1978
|
+
caveats: [
|
|
1979
|
+
CAMERA_SAMPLED,
|
|
1980
|
+
CELL_SIZE_SENSITIVE,
|
|
1981
|
+
"A small `cellSize` inflates the ratio (more cells to re-enter); a large one hides it.",
|
|
1982
|
+
"Meaningless for orbit/viewer scenes, where the camera revisits by construction.",
|
|
1983
|
+
],
|
|
1984
|
+
sourceChannels: ["camera_sample"],
|
|
1985
|
+
related: ["navigation_stats", "aggregate_paths", "position_heatmap"],
|
|
1986
|
+
comparable: { primary: "backtrack_ratio", direction: "down", minSample: 30 },
|
|
1987
|
+
category: "navigation",
|
|
1988
|
+
},
|
|
1989
|
+
// =========================================================================
|
|
1990
|
+
// Performance
|
|
1991
|
+
// =========================================================================
|
|
1992
|
+
perf_summary: {
|
|
1993
|
+
id: "perf_summary",
|
|
1994
|
+
title: "Rendering performance summary",
|
|
1995
|
+
description: "The pooled FPS headline over the range: how many `frame_perf` samples were seen and their " +
|
|
1996
|
+
"average, minimum and median FPS. Always a single row. The quickest 'is this scene smooth' " +
|
|
1997
|
+
"check.",
|
|
1998
|
+
builder: "buildPerfSummary",
|
|
1999
|
+
endpoint: { method: "GET", path: "/api/v1/perf" },
|
|
2000
|
+
grain: "project",
|
|
2001
|
+
dimensions: ["session"],
|
|
2002
|
+
filters: ["since", "until", "bins", "limit", "session", "format"],
|
|
2003
|
+
row: z.object({ samples: int, avg_fps: numOrNull, min_fps: numOrNull, p50_fps: numOrNull }),
|
|
2004
|
+
columns: {
|
|
2005
|
+
samples: { description: "`frame_perf` samples in scope.", unit: "count" },
|
|
2006
|
+
avg_fps: { description: "Mean FPS across samples.", unit: "fps", measure: true },
|
|
2007
|
+
min_fps: { description: "Worst single sample.", unit: "fps" },
|
|
2008
|
+
p50_fps: { description: "Median FPS across samples.", unit: "fps" },
|
|
2009
|
+
},
|
|
2010
|
+
limits: { maxRows: 1, maxSummaryRows: 1 },
|
|
2011
|
+
interpretation: "Samples are pooled, so a long or high-refresh session dominates. For a headline that treats " +
|
|
2012
|
+
"each visitor equally, use `perf_distribution`; for the resolution behind the number, " +
|
|
2013
|
+
"`render_scale_truth`.",
|
|
2014
|
+
caveats: [
|
|
2015
|
+
EMPTY_SET_NULLS,
|
|
2016
|
+
"`frame_perf` is a sampled channel (`samplePerfMs`, 2 s by default, ADR 0012); `min_fps` is the worst *sampled* frame, not the worst frame.",
|
|
2017
|
+
"No scene filter on this builder — scope by session or use `perf_by_scene`.",
|
|
2018
|
+
"A healthy average can hide adaptive down-scaling; check `render_scale_truth`.",
|
|
2019
|
+
"Fewer than ~30 samples makes every number here unreliable.",
|
|
2020
|
+
],
|
|
2021
|
+
sourceChannels: ["frame_perf"],
|
|
2022
|
+
related: ["perf_distribution", "render_scale_truth", "jank_rate", "perf_by_scene"],
|
|
2023
|
+
comparable: { primary: "p50_fps", direction: "up", minSample: 30 },
|
|
2024
|
+
category: "performance",
|
|
2025
|
+
},
|
|
2026
|
+
render_scale_truth: {
|
|
2027
|
+
id: "render_scale_truth",
|
|
2028
|
+
title: "Render-scale truth",
|
|
2029
|
+
description: "The FPS headline paired with the resolution the engine actually rendered at (#71, ADR 0021). " +
|
|
2030
|
+
"Always a single row. A scene can report a healthy frame rate only because an adaptive " +
|
|
2031
|
+
"renderer quietly dropped the render scale below 1.",
|
|
2032
|
+
builder: "buildRenderScaleTruth",
|
|
2033
|
+
endpoint: { method: "GET", path: "/api/v1/perf/render-scale" },
|
|
2034
|
+
grain: "project",
|
|
2035
|
+
dimensions: ["session"],
|
|
2036
|
+
filters: ["since", "until", "bins", "limit", "session", "format"],
|
|
2037
|
+
row: z.object({
|
|
2038
|
+
samples: int,
|
|
2039
|
+
avg_fps: numOrNull,
|
|
2040
|
+
p50_fps: numOrNull,
|
|
2041
|
+
avg_render_scale: numOrNull,
|
|
2042
|
+
p50_render_scale: numOrNull,
|
|
2043
|
+
downscaled_samples: intOrNull,
|
|
2044
|
+
scale_samples: intOrNull,
|
|
2045
|
+
}),
|
|
2046
|
+
columns: {
|
|
2047
|
+
samples: { description: "`frame_perf` samples in scope.", unit: "count" },
|
|
2048
|
+
avg_fps: { description: "Mean FPS across samples.", unit: "fps" },
|
|
2049
|
+
p50_fps: { description: "Median FPS across samples.", unit: "fps", measure: true },
|
|
2050
|
+
avg_render_scale: {
|
|
2051
|
+
description: "Mean reported render scale; `null` when nothing reported one.",
|
|
2052
|
+
unit: "ratio",
|
|
2053
|
+
},
|
|
2054
|
+
p50_render_scale: {
|
|
2055
|
+
description: "Median reported render scale; `null` when nothing reported one.",
|
|
2056
|
+
unit: "ratio",
|
|
2057
|
+
},
|
|
2058
|
+
downscaled_samples: {
|
|
2059
|
+
description: "Reported samples that rendered below native resolution.",
|
|
2060
|
+
unit: "count",
|
|
2061
|
+
rateOf: "scale_samples",
|
|
2062
|
+
},
|
|
2063
|
+
scale_samples: { description: "Samples that reported a render scale at all.", unit: "count" },
|
|
2064
|
+
},
|
|
2065
|
+
limits: { maxRows: 1, maxSummaryRows: 1 },
|
|
2066
|
+
interpretation: "The downscaled share is `downscaled_samples / scale_samples` — it is deliberately left to " +
|
|
2067
|
+
"the caller so it stays integer-exact across engines. A high share next to a good FPS means " +
|
|
2068
|
+
"the frame rate was bought with pixels.",
|
|
2069
|
+
caveats: [
|
|
2070
|
+
EMPTY_SET_NULLS,
|
|
2071
|
+
"`scale_samples` is 0 when no connector reported a render scale; both scale columns are then `null` and the share is undefined.",
|
|
2072
|
+
"`frame_perf` is a sampled channel (ADR 0012).",
|
|
2073
|
+
"No scene filter on this builder.",
|
|
2074
|
+
],
|
|
2075
|
+
sourceChannels: ["frame_perf"],
|
|
2076
|
+
related: ["perf_summary", "perf_distribution", "capability_changes"],
|
|
2077
|
+
comparable: { primary: "p50_render_scale", direction: "up", minSample: 30 },
|
|
2078
|
+
category: "performance",
|
|
2079
|
+
},
|
|
2080
|
+
perf_distribution: {
|
|
2081
|
+
id: "perf_distribution",
|
|
2082
|
+
title: "FPS distribution (per-session)",
|
|
2083
|
+
description: "FPS percentiles computed per session and then aggregated (ADR 0028 §1): the median across " +
|
|
2084
|
+
"sessions of each session's p05 / p50 / p95. Always a single row. The honest smoothness " +
|
|
2085
|
+
"headline — one visitor, one vote.",
|
|
2086
|
+
builder: "buildPerfDistribution",
|
|
2087
|
+
endpoint: { method: "GET", path: "/api/v1/perf/distribution" },
|
|
2088
|
+
grain: "project",
|
|
2089
|
+
dimensions: ["scene", "session"],
|
|
2090
|
+
filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
|
|
2091
|
+
row: z.object({
|
|
2092
|
+
sessions: int,
|
|
2093
|
+
samples: intOrNull,
|
|
2094
|
+
p05_fps: numOrNull,
|
|
2095
|
+
p50_fps: numOrNull,
|
|
2096
|
+
p95_fps: numOrNull,
|
|
2097
|
+
}),
|
|
2098
|
+
columns: {
|
|
2099
|
+
sessions: { description: "Sessions contributing a percentile.", unit: "sessions" },
|
|
2100
|
+
samples: { description: "Total `frame_perf` samples behind them.", unit: "count" },
|
|
2101
|
+
p05_fps: { description: "Median across sessions of each session's p05 FPS.", unit: "fps" },
|
|
2102
|
+
p50_fps: {
|
|
2103
|
+
description: "Median across sessions of each session's median FPS.",
|
|
2104
|
+
unit: "fps",
|
|
2105
|
+
measure: true,
|
|
2106
|
+
},
|
|
2107
|
+
p95_fps: { description: "Median across sessions of each session's p95 FPS.", unit: "fps" },
|
|
2108
|
+
},
|
|
2109
|
+
limits: { maxRows: 1, maxSummaryRows: 1 },
|
|
2110
|
+
interpretation: "`p05_fps` is the 'bad moments' number and the one users feel; `p50_fps` is the typical " +
|
|
2111
|
+
"experience. A wide p05→p95 spread means inconsistent frame pacing, which reads worse than a " +
|
|
2112
|
+
"steadily lower frame rate.",
|
|
2113
|
+
caveats: [
|
|
2114
|
+
EMPTY_SET_NULLS,
|
|
2115
|
+
PER_SESSION_THEN_AGGREGATE,
|
|
2116
|
+
"These are medians of per-session percentiles, not global percentiles — they do not describe the worst sessions. Use `jank_rate`'s worst-decile for that.",
|
|
2117
|
+
"Fewer than ~20 sessions makes the medians unstable.",
|
|
2118
|
+
"`frame_perf` is a sampled channel (ADR 0012).",
|
|
2119
|
+
],
|
|
2120
|
+
sourceChannels: ["frame_perf"],
|
|
2121
|
+
related: ["perf_summary", "fps_histogram", "frame_time_percentiles", "jank_rate"],
|
|
2122
|
+
comparable: { primary: "p50_fps", direction: "up", minSample: 20 },
|
|
2123
|
+
category: "performance",
|
|
2124
|
+
},
|
|
2125
|
+
fps_histogram: {
|
|
2126
|
+
id: "fps_histogram",
|
|
2127
|
+
title: "Per-session median-FPS histogram",
|
|
2128
|
+
description: "How many sessions fell into each FPS band, where a session contributes a single data point " +
|
|
2129
|
+
"— its median FPS (ADR 0028 §1). One row per `bucket`-wide band. Answers 'how many " +
|
|
2130
|
+
"*experiences* were smooth', not how many frames.",
|
|
2131
|
+
builder: "buildFpsHistogram",
|
|
2132
|
+
endpoint: { method: "GET", path: "/api/v1/perf/fps-histogram" },
|
|
2133
|
+
grain: "bucket",
|
|
2134
|
+
dimensions: ["scene", "session"],
|
|
2135
|
+
filters: ["since", "until", "bins", "limit", "scene", "session", "bucket", "format"],
|
|
2136
|
+
row: z.object({ bucket: int, sessions: int }),
|
|
2137
|
+
columns: {
|
|
2138
|
+
bucket: {
|
|
2139
|
+
description: "Inclusive lower bound of the FPS bin.",
|
|
2140
|
+
unit: "fps",
|
|
2141
|
+
label: true,
|
|
2142
|
+
axis: true,
|
|
2143
|
+
},
|
|
2144
|
+
sessions: {
|
|
2145
|
+
description: "Sessions whose median FPS fell in the bin.",
|
|
2146
|
+
unit: "sessions",
|
|
2147
|
+
measure: true,
|
|
2148
|
+
},
|
|
2149
|
+
},
|
|
2150
|
+
limits: { maxRows: 100, maxSummaryRows: 8 },
|
|
2151
|
+
interpretation: "A bimodal shape is the classic desktop-vs-mobile split — confirm it with `perf_by_device` " +
|
|
2152
|
+
"rather than treating the average as representative of either group.",
|
|
2153
|
+
caveats: [
|
|
2154
|
+
"Bins are `bucket` FPS wide (default 10) and empty bins are absent, not zero-filled.",
|
|
2155
|
+
PER_SESSION_THEN_AGGREGATE,
|
|
2156
|
+
"Fewer than ~20 sessions makes the shape meaningless.",
|
|
2157
|
+
],
|
|
2158
|
+
sourceChannels: ["frame_perf"],
|
|
2159
|
+
related: ["perf_distribution", "perf_by_device", "perf_by_scene"],
|
|
2160
|
+
comparable: { primary: "sessions", direction: "neutral", minSample: 20 },
|
|
2161
|
+
category: "performance",
|
|
2162
|
+
},
|
|
2163
|
+
frame_time_percentiles: {
|
|
2164
|
+
id: "frame_time_percentiles",
|
|
2165
|
+
title: "Frame-time percentiles",
|
|
2166
|
+
description: "Frame cost in milliseconds, computed per session then aggregated (ADR 0028 §1): the typical " +
|
|
2167
|
+
"frame and the tail. Always a single row. Milliseconds are the budget developers actually " +
|
|
2168
|
+
"work in — FPS is the reciprocal.",
|
|
2169
|
+
builder: "buildFrameTimePercentiles",
|
|
2170
|
+
endpoint: { method: "GET", path: "/api/v1/perf/frame-time" },
|
|
2171
|
+
grain: "project",
|
|
2172
|
+
dimensions: ["scene", "session"],
|
|
2173
|
+
filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
|
|
2174
|
+
row: z.object({ sessions: int, samples: intOrNull, p50_ms: numOrNull, p95_ms: numOrNull }),
|
|
2175
|
+
columns: {
|
|
2176
|
+
sessions: { description: "Sessions contributing a percentile.", unit: "sessions" },
|
|
2177
|
+
samples: { description: "Total `frame_perf` samples behind them.", unit: "count" },
|
|
2178
|
+
p50_ms: {
|
|
2179
|
+
description: "Median across sessions of each session's median frame time.",
|
|
2180
|
+
unit: "ms",
|
|
2181
|
+
measure: true,
|
|
2182
|
+
},
|
|
2183
|
+
p95_ms: {
|
|
2184
|
+
description: "Median across sessions of each session's worst-window p95 frame time.",
|
|
2185
|
+
unit: "ms",
|
|
2186
|
+
},
|
|
2187
|
+
},
|
|
2188
|
+
limits: { maxRows: 1, maxSummaryRows: 1 },
|
|
2189
|
+
interpretation: "16.7 ms is the 60 Hz budget and 11.1 ms the 90 Hz XR budget. `p95_ms` is what makes a scene " +
|
|
2190
|
+
"feel janky even when `p50_ms` looks fine.",
|
|
2191
|
+
caveats: [
|
|
2192
|
+
EMPTY_SET_NULLS,
|
|
2193
|
+
"`p95_ms` is read from the SDK's per-window p95, not re-derived from window means, so it is a tail of tails.",
|
|
2194
|
+
"Samples that never reported frame-time detail are excluded rather than counted as zero.",
|
|
2195
|
+
PER_SESSION_THEN_AGGREGATE,
|
|
2196
|
+
],
|
|
2197
|
+
sourceChannels: ["frame_perf"],
|
|
2198
|
+
related: ["perf_distribution", "jank_rate", "compile_stalls"],
|
|
2199
|
+
comparable: { primary: "p95_ms", direction: "down", minSample: 20 },
|
|
2200
|
+
category: "performance",
|
|
2201
|
+
},
|
|
2202
|
+
jank_rate: {
|
|
2203
|
+
id: "jank_rate",
|
|
2204
|
+
title: "Jank rate",
|
|
2205
|
+
description: "How often frames ran long, per session then aggregated (ADR 0028 §1): the median session's " +
|
|
2206
|
+
"long-frames-per-window rate and the worst decile's. Always a single row. Surfaces the janky " +
|
|
2207
|
+
"minority instead of averaging it away.",
|
|
2208
|
+
builder: "buildJankRate",
|
|
2209
|
+
endpoint: { method: "GET", path: "/api/v1/perf/jank" },
|
|
2210
|
+
grain: "project",
|
|
2211
|
+
dimensions: ["scene", "session"],
|
|
2212
|
+
filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
|
|
2213
|
+
row: z.object({
|
|
2214
|
+
sessions: int,
|
|
2215
|
+
total_long_frames: numOrNull,
|
|
2216
|
+
median_rate: numOrNull,
|
|
2217
|
+
worst_decile_rate: numOrNull,
|
|
2218
|
+
}),
|
|
2219
|
+
columns: {
|
|
2220
|
+
sessions: { description: "Sessions contributing a rate.", unit: "sessions" },
|
|
2221
|
+
total_long_frames: {
|
|
2222
|
+
description: "Raw long-frame count across all sessions.",
|
|
2223
|
+
unit: "count",
|
|
2224
|
+
},
|
|
2225
|
+
median_rate: {
|
|
2226
|
+
description: "Median per-session long-frames-per-sample-window rate.",
|
|
2227
|
+
unit: "ratio",
|
|
2228
|
+
measure: true,
|
|
2229
|
+
},
|
|
2230
|
+
worst_decile_rate: {
|
|
2231
|
+
description: "p90 per-session rate — the unlucky visitors.",
|
|
2232
|
+
unit: "ratio",
|
|
2233
|
+
},
|
|
2234
|
+
},
|
|
2235
|
+
limits: { maxRows: 1, maxSummaryRows: 1 },
|
|
2236
|
+
interpretation: "A `median_rate` near zero with a high `worst_decile_rate` is the signature of a device-class " +
|
|
2237
|
+
"problem: most visitors are fine, a tenth are not. Follow it into `perf_by_device`.",
|
|
2238
|
+
caveats: [
|
|
2239
|
+
EMPTY_SET_NULLS,
|
|
2240
|
+
"A 'long frame' is defined by the SDK's `jankFrameMs` threshold (50 ms by default); apps that retune it are not comparable.",
|
|
2241
|
+
"Rates are per sample *window*, not per frame, so they scale with `samplePerfMs`.",
|
|
2242
|
+
PER_SESSION_THEN_AGGREGATE,
|
|
2243
|
+
],
|
|
2244
|
+
sourceChannels: ["frame_perf"],
|
|
2245
|
+
related: ["frame_time_percentiles", "perf_distribution", "perf_by_device", "perf_churn"],
|
|
2246
|
+
comparable: { primary: "median_rate", direction: "down", minSample: 20 },
|
|
2247
|
+
category: "performance",
|
|
2248
|
+
},
|
|
2249
|
+
perf_churn: {
|
|
2250
|
+
id: "perf_churn",
|
|
2251
|
+
title: "Perf-correlated churn",
|
|
2252
|
+
description: "Does a stutter actually cost sessions (#144)? Of the sessions that ended in range, how many " +
|
|
2253
|
+
"ended shortly after an FPS dip or a compile stall, with the cause attributed. Always a " +
|
|
2254
|
+
"single row of aggregate counts.",
|
|
2255
|
+
builder: "buildPerfChurn",
|
|
2256
|
+
endpoint: { method: "GET", path: "/api/v1/perf/churn" },
|
|
2257
|
+
grain: "project",
|
|
2258
|
+
dimensions: ["scene", "session"],
|
|
2259
|
+
filters: [
|
|
2260
|
+
"since",
|
|
2261
|
+
"until",
|
|
2262
|
+
"bins",
|
|
2263
|
+
"limit",
|
|
2264
|
+
"scene",
|
|
2265
|
+
"session",
|
|
2266
|
+
"windowMs",
|
|
2267
|
+
"fpsThreshold",
|
|
2268
|
+
"stallMs",
|
|
2269
|
+
"format",
|
|
2270
|
+
],
|
|
2271
|
+
row: z.object({
|
|
2272
|
+
sessions: int,
|
|
2273
|
+
churn_sessions: int,
|
|
2274
|
+
fps_churn_sessions: intOrNull,
|
|
2275
|
+
stall_churn_sessions: intOrNull,
|
|
2276
|
+
}),
|
|
2277
|
+
columns: {
|
|
2278
|
+
sessions: {
|
|
2279
|
+
description: "Sessions with a `session_end` in scope — the denominator.",
|
|
2280
|
+
unit: "sessions",
|
|
2281
|
+
},
|
|
2282
|
+
churn_sessions: {
|
|
2283
|
+
description: "Sessions that ended within `windowMs` of a qualifying dip.",
|
|
2284
|
+
unit: "sessions",
|
|
2285
|
+
measure: true,
|
|
2286
|
+
rateOf: "sessions",
|
|
2287
|
+
},
|
|
2288
|
+
fps_churn_sessions: {
|
|
2289
|
+
description: "Churned sessions whose window held a low-FPS sample.",
|
|
2290
|
+
unit: "sessions",
|
|
2291
|
+
},
|
|
2292
|
+
stall_churn_sessions: {
|
|
2293
|
+
description: "Churned sessions whose window held a compile stall.",
|
|
2294
|
+
unit: "sessions",
|
|
2295
|
+
},
|
|
2296
|
+
},
|
|
2297
|
+
limits: { maxRows: 1, maxSummaryRows: 1 },
|
|
2298
|
+
interpretation: "This is correlation, not causation — a session that ends after a dip may have ended anyway. " +
|
|
2299
|
+
"Compare the churn rate against a period with fewer dips before acting.",
|
|
2300
|
+
caveats: [
|
|
2301
|
+
EMPTY_SET_NULLS,
|
|
2302
|
+
"A session counted in both cause columns is counted once in `churn_sessions`, so the cause columns can sum to more than the total.",
|
|
2303
|
+
"Sessions with no `session_end` (a hard tab close that never flushed) are outside the denominator entirely.",
|
|
2304
|
+
"Every number moves with `windowMs` / `fpsThreshold` / `stallMs`; report the thresholds with the result.",
|
|
2305
|
+
"Fewer than ~50 ended sessions makes the rate noise.",
|
|
2306
|
+
],
|
|
2307
|
+
sourceChannels: ["session_end", "frame_perf", "compile_stall"],
|
|
2308
|
+
related: ["jank_rate", "compile_stalls", "load_bounce_funnel", "perf_distribution"],
|
|
2309
|
+
comparable: { primary: "churn_sessions", direction: "down", minSample: 50 },
|
|
2310
|
+
category: "performance",
|
|
2311
|
+
},
|
|
2312
|
+
perf_by_device: {
|
|
2313
|
+
id: "perf_by_device",
|
|
2314
|
+
title: "FPS by device class",
|
|
2315
|
+
description: "Median FPS attributed to the graphics backend, mobile flag, GPU renderer and the coarse " +
|
|
2316
|
+
"browser/OS families derived at ingestion (ADR 0028 §2, ADR 0042). One row per device " +
|
|
2317
|
+
"combination. Where a bimodal FPS histogram gets explained.",
|
|
2318
|
+
builder: "buildPerfByDevice",
|
|
2319
|
+
endpoint: { method: "GET", path: "/api/v1/perf/by-device" },
|
|
2320
|
+
grain: "row",
|
|
2321
|
+
dimensions: [
|
|
2322
|
+
"device.engine",
|
|
2323
|
+
"device.isMobile",
|
|
2324
|
+
"device.renderer",
|
|
2325
|
+
"device.browser",
|
|
2326
|
+
"device.os",
|
|
2327
|
+
"scene",
|
|
2328
|
+
"session",
|
|
2329
|
+
],
|
|
2330
|
+
filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
|
|
2331
|
+
row: z.object({
|
|
2332
|
+
engine: text,
|
|
2333
|
+
is_mobile: text,
|
|
2334
|
+
renderer: text,
|
|
2335
|
+
browser: text,
|
|
2336
|
+
os: text,
|
|
2337
|
+
sessions: int,
|
|
2338
|
+
samples: int,
|
|
2339
|
+
p50_fps: num,
|
|
2340
|
+
}),
|
|
2341
|
+
columns: {
|
|
2342
|
+
engine: {
|
|
2343
|
+
description: "Graphics backend reported at `session_start`; `''` if unreported.",
|
|
2344
|
+
unit: "label",
|
|
2345
|
+
},
|
|
2346
|
+
is_mobile: { description: "Mobile flag as reported; `''` if unreported.", unit: "label" },
|
|
2347
|
+
renderer: {
|
|
2348
|
+
description: "GPU renderer string; `''` if unreported.",
|
|
2349
|
+
unit: "label",
|
|
2350
|
+
label: true,
|
|
2351
|
+
},
|
|
2352
|
+
browser: { description: "Coarse browser family derived from the User-Agent.", unit: "label" },
|
|
2353
|
+
os: { description: "Coarse OS family derived from the User-Agent.", unit: "label" },
|
|
2354
|
+
sessions: { description: "Sessions in the group.", unit: "sessions" },
|
|
2355
|
+
samples: { description: "`frame_perf` samples behind them.", unit: "count" },
|
|
2356
|
+
p50_fps: {
|
|
2357
|
+
description: "Median across the group's sessions of each session's median FPS.",
|
|
2358
|
+
unit: "fps",
|
|
2359
|
+
measure: true,
|
|
2360
|
+
},
|
|
2361
|
+
},
|
|
2362
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
2363
|
+
interpretation: "The group is the full cross-product, so the rows are narrow. Sum `sessions` over the values " +
|
|
2364
|
+
"of one column to get a by-browser or by-OS view without a second query.",
|
|
2365
|
+
caveats: [
|
|
2366
|
+
"Device fields are read from the `session_start` payload and are `''` when a connector never reported them — `''` is a real group, not a null.",
|
|
2367
|
+
"GPU `renderer` strings are high-cardinality and vendor-formatted; expect many near-duplicate rows.",
|
|
2368
|
+
"Groups with one or two sessions carry no signal — check `sessions` before comparing medians.",
|
|
2369
|
+
PER_SESSION_THEN_AGGREGATE,
|
|
2370
|
+
],
|
|
2371
|
+
sourceChannels: ["session_start", "frame_perf"],
|
|
2372
|
+
related: ["fps_histogram", "perf_distribution", "rendering_technology", "session_meta"],
|
|
2373
|
+
comparable: { primary: "p50_fps", direction: "up", minSample: 10 },
|
|
2374
|
+
category: "performance",
|
|
2375
|
+
},
|
|
2376
|
+
perf_by_scene: {
|
|
2377
|
+
id: "perf_by_scene",
|
|
2378
|
+
title: "FPS by scene",
|
|
2379
|
+
description: "Median FPS attributed to each scene, per session then aggregated (ADR 0028 §1). One row per " +
|
|
2380
|
+
"scene. The comparison that tells you which level is expensive.",
|
|
2381
|
+
builder: "buildPerfByScene",
|
|
2382
|
+
endpoint: { method: "GET", path: "/api/v1/perf/by-scene" },
|
|
2383
|
+
grain: "scene",
|
|
2384
|
+
dimensions: ["scene", "session"],
|
|
2385
|
+
filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
|
|
2386
|
+
row: z.object({ scene_id: text, sessions: int, samples: int, p50_fps: num }),
|
|
2387
|
+
columns: {
|
|
2388
|
+
scene_id: { description: "Developer-assigned scene id.", unit: "id", label: true },
|
|
2389
|
+
sessions: { description: "Sessions in the scene.", unit: "sessions" },
|
|
2390
|
+
samples: { description: "`frame_perf` samples behind them.", unit: "count" },
|
|
2391
|
+
p50_fps: {
|
|
2392
|
+
description: "Median across the scene's sessions of each session's median FPS.",
|
|
2393
|
+
unit: "fps",
|
|
2394
|
+
measure: true,
|
|
2395
|
+
},
|
|
2396
|
+
},
|
|
2397
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
2398
|
+
interpretation: "Because each session is one vote, a scene visited mostly on phones will look slower than one " +
|
|
2399
|
+
"visited mostly on desktops even with identical content — cross-check `perf_by_device`.",
|
|
2400
|
+
caveats: [
|
|
2401
|
+
"Scenes with a handful of sessions are not comparable; check `sessions` first.",
|
|
2402
|
+
PER_SESSION_THEN_AGGREGATE,
|
|
2403
|
+
"An app that never calls `setScene(...)` reports everything under `default`.",
|
|
2404
|
+
],
|
|
2405
|
+
sourceChannels: ["frame_perf"],
|
|
2406
|
+
related: ["perf_by_device", "perf_heatmap", "list_scenes", "perf_distribution"],
|
|
2407
|
+
comparable: { primary: "p50_fps", direction: "up", minSample: 10 },
|
|
2408
|
+
category: "performance",
|
|
2409
|
+
},
|
|
2410
|
+
perf_heatmap: {
|
|
2411
|
+
id: "perf_heatmap",
|
|
2412
|
+
title: "Spatial FPS heatmap",
|
|
2413
|
+
description: "`frame_perf` samples voxel-binned by the camera position they were captured at (#145), with " +
|
|
2414
|
+
"each cell's sample count, mean FPS and worst sample. One row per occupied voxel, " +
|
|
2415
|
+
"worst-FPS-first. Answers *where* performance degrades.",
|
|
2416
|
+
builder: "buildPerfHeatmap",
|
|
2417
|
+
endpoint: { method: "GET", path: "/api/v1/heatmaps/perf" },
|
|
2418
|
+
grain: "voxel",
|
|
2419
|
+
dimensions: ["scene", "session"],
|
|
2420
|
+
filters: ["since", "until", "cellSize", "limit", "scene", "session", "format"],
|
|
2421
|
+
row: z.object({ vx: int, vy: int, vz: int, samples: int, avg_fps: num, min_fps: num }),
|
|
2422
|
+
columns: {
|
|
2423
|
+
vx: { description: "Voxel X index.", unit: "index", label: true },
|
|
2424
|
+
vy: { description: "Voxel Y index.", unit: "index" },
|
|
2425
|
+
vz: { description: "Voxel Z index.", unit: "index" },
|
|
2426
|
+
samples: { description: "`frame_perf` samples in the voxel.", unit: "count" },
|
|
2427
|
+
avg_fps: { description: "Mean FPS in the voxel.", unit: "fps", measure: true },
|
|
2428
|
+
min_fps: { description: "Worst single sample in the voxel.", unit: "fps" },
|
|
2429
|
+
},
|
|
2430
|
+
limits: { maxRows: 10000, maxSummaryRows: 8 },
|
|
2431
|
+
interpretation: "Rows come back worst-FPS-first, so the capped slice is the jankiest cells rather than an " +
|
|
2432
|
+
"arbitrary corner. A low-`samples` cell with terrible FPS is one unlucky moment, not a hot " +
|
|
2433
|
+
"spot — weight by `samples`.",
|
|
2434
|
+
caveats: [
|
|
2435
|
+
"Needs `frame_perf` samples that carry a camera position; connectors that omit it contribute nothing.",
|
|
2436
|
+
CELL_SIZE_SENSITIVE,
|
|
2437
|
+
"`frame_perf` is a sampled channel (ADR 0012), so a fast traverse through a bad region may leave few samples behind.",
|
|
2438
|
+
],
|
|
2439
|
+
sourceChannels: ["frame_perf"],
|
|
2440
|
+
related: ["perf_by_scene", "scene_coverage", "position_heatmap", "error_heatmap"],
|
|
2441
|
+
comparable: { primary: "avg_fps", direction: "up", minSample: 30 },
|
|
2442
|
+
category: "performance",
|
|
2443
|
+
},
|
|
2444
|
+
perf_daily: {
|
|
2445
|
+
id: "perf_daily",
|
|
2446
|
+
title: "Daily performance trend",
|
|
2447
|
+
description: "Per-day FPS aggregates read from the `perf_daily` rollup: sample count and average / " +
|
|
2448
|
+
"minimum / median FPS. One row per day. The long-horizon performance trend.",
|
|
2449
|
+
builder: "buildPerfDaily",
|
|
2450
|
+
grain: "bucket",
|
|
2451
|
+
dimensions: [],
|
|
2452
|
+
filters: [],
|
|
2453
|
+
row: z.object({ day: day, samples: int, avg_fps: num, min_fps: num, p50_fps: num }),
|
|
2454
|
+
columns: {
|
|
2455
|
+
day: {
|
|
2456
|
+
description: "Calendar day (UTC) as `YYYY-MM-DD`.",
|
|
2457
|
+
unit: "label",
|
|
2458
|
+
label: true,
|
|
2459
|
+
axis: true,
|
|
2460
|
+
},
|
|
2461
|
+
samples: { description: "`frame_perf` samples on that day.", unit: "count" },
|
|
2462
|
+
avg_fps: { description: "Mean FPS across the day's samples.", unit: "fps", measure: true },
|
|
2463
|
+
min_fps: { description: "Worst single sample of the day.", unit: "fps" },
|
|
2464
|
+
p50_fps: { description: "Median FPS across the day's samples.", unit: "fps" },
|
|
2465
|
+
},
|
|
2466
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
2467
|
+
interpretation: "Pooled per day, so a single busy session can move the average. Use it for direction of " +
|
|
2468
|
+
"travel, and `perf_distribution` over a narrower range for a decision.",
|
|
2469
|
+
caveats: [
|
|
2470
|
+
"No collector endpoint serves this builder today — it is reachable through the store / `QuerySpec` API only.",
|
|
2471
|
+
"On the OSS DuckDB store this is a query-time aggregation; on the ClickHouse scale tier it merges materialized aggregate states.",
|
|
2472
|
+
"Day boundaries are UTC and the upper bound is exclusive at date granularity.",
|
|
2473
|
+
],
|
|
2474
|
+
sourceChannels: ["frame_perf"],
|
|
2475
|
+
related: ["perf_summary", "events_daily", "perf_distribution"],
|
|
2476
|
+
comparable: { primary: "p50_fps", direction: "up", minSample: 30 },
|
|
2477
|
+
category: "performance",
|
|
2478
|
+
},
|
|
2479
|
+
compile_stalls: {
|
|
2480
|
+
id: "compile_stalls",
|
|
2481
|
+
title: "Shader / pipeline compile stalls",
|
|
2482
|
+
description: "Per compile phase, how many main-thread compile hitches happened and their total, average " +
|
|
2483
|
+
"and worst duration (#42). One row per phase. Compilation is the biggest single source of " +
|
|
2484
|
+
"first-interaction jank, and frame-rate averages hide it.",
|
|
2485
|
+
builder: "buildCompileStalls",
|
|
2486
|
+
endpoint: { method: "GET", path: "/api/v1/perf/compile-stalls" },
|
|
2487
|
+
grain: "row",
|
|
2488
|
+
dimensions: ["name", "scene", "session"],
|
|
2489
|
+
filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
|
|
2490
|
+
row: z.object({ phase: text, stalls: int, total_ms: num, avg_ms: num, max_ms: num }),
|
|
2491
|
+
columns: {
|
|
2492
|
+
phase: {
|
|
2493
|
+
description: "Coarse compile phase (shader / pipeline / material / other); `''` if unattributed.",
|
|
2494
|
+
unit: "label",
|
|
2495
|
+
label: true,
|
|
2496
|
+
},
|
|
2497
|
+
stalls: { description: "Compile stalls in the phase.", unit: "count" },
|
|
2498
|
+
total_ms: {
|
|
2499
|
+
description: "Total main-thread time spent compiling.",
|
|
2500
|
+
unit: "ms",
|
|
2501
|
+
measure: true,
|
|
2502
|
+
},
|
|
2503
|
+
avg_ms: { description: "Mean compile-stall duration.", unit: "ms" },
|
|
2504
|
+
max_ms: { description: "Worst single compile stall.", unit: "ms" },
|
|
2505
|
+
},
|
|
2506
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
2507
|
+
interpretation: "`max_ms` is what a visitor felt as a freeze; `total_ms` is what a warm-up pass could remove. " +
|
|
2508
|
+
"Most stalls cluster at first load, so a per-session view is more actionable than a range total.",
|
|
2509
|
+
caveats: [
|
|
2510
|
+
"Compile-stall capture is on by default, but only engines that expose compilation hooks report it.",
|
|
2511
|
+
"Mostly a first-load cost, so totals scale with new visitors rather than with usage.",
|
|
2512
|
+
"Durations ride in the shared `visible_ms` column; do not mix them with dwell metrics.",
|
|
2513
|
+
],
|
|
2514
|
+
sourceChannels: ["compile_stall"],
|
|
2515
|
+
related: ["stability_counts", "perf_churn", "frame_time_percentiles", "load_bounce_funnel"],
|
|
2516
|
+
comparable: { primary: "total_ms", direction: "down", minSample: 10 },
|
|
2517
|
+
category: "performance",
|
|
2518
|
+
},
|
|
2519
|
+
resource_summary: {
|
|
2520
|
+
id: "resource_summary",
|
|
2521
|
+
title: "GPU / memory footprint summary",
|
|
2522
|
+
description: "The average and peak of each footprint metric over the range (#44): JS heap, submitted " +
|
|
2523
|
+
"triangles and vertices, resident texture and geometry bytes. Always a single row — the " +
|
|
2524
|
+
"actual cost the scene asked of the device.",
|
|
2525
|
+
builder: "buildResourceSummary",
|
|
2526
|
+
endpoint: { method: "GET", path: "/api/v1/perf/resources" },
|
|
2527
|
+
grain: "project",
|
|
2528
|
+
dimensions: ["session"],
|
|
2529
|
+
filters: ["since", "until", "bins", "limit", "session", "format"],
|
|
2530
|
+
row: z.object({
|
|
2531
|
+
samples: int,
|
|
2532
|
+
avg_js_heap_bytes: numOrNull,
|
|
2533
|
+
max_js_heap_bytes: numOrNull,
|
|
2534
|
+
avg_triangles: numOrNull,
|
|
2535
|
+
max_triangles: numOrNull,
|
|
2536
|
+
avg_vertices: numOrNull,
|
|
2537
|
+
max_vertices: numOrNull,
|
|
2538
|
+
avg_texture_bytes: numOrNull,
|
|
2539
|
+
max_texture_bytes: numOrNull,
|
|
2540
|
+
avg_geometry_bytes: numOrNull,
|
|
2541
|
+
max_geometry_bytes: numOrNull,
|
|
2542
|
+
}),
|
|
2543
|
+
columns: {
|
|
2544
|
+
samples: { description: "Footprint samples in the range.", unit: "count" },
|
|
2545
|
+
avg_js_heap_bytes: { description: "Mean used JS heap.", unit: "bytes", measure: true },
|
|
2546
|
+
max_js_heap_bytes: { description: "Peak used JS heap.", unit: "bytes" },
|
|
2547
|
+
avg_triangles: { description: "Mean triangles submitted per sampled frame.", unit: "count" },
|
|
2548
|
+
max_triangles: { description: "Peak triangles submitted in a sampled frame.", unit: "count" },
|
|
2549
|
+
avg_vertices: { description: "Mean vertices submitted per sampled frame.", unit: "count" },
|
|
2550
|
+
max_vertices: { description: "Peak vertices submitted in a sampled frame.", unit: "count" },
|
|
2551
|
+
avg_texture_bytes: { description: "Mean resident texture memory.", unit: "bytes" },
|
|
2552
|
+
max_texture_bytes: { description: "Peak resident texture memory.", unit: "bytes" },
|
|
2553
|
+
avg_geometry_bytes: { description: "Mean resident geometry memory.", unit: "bytes" },
|
|
2554
|
+
max_geometry_bytes: { description: "Peak resident geometry memory.", unit: "bytes" },
|
|
2555
|
+
},
|
|
2556
|
+
limits: { maxRows: 1, maxSummaryRows: 1 },
|
|
2557
|
+
interpretation: "Peaks matter more than averages here: a device runs out of memory at the peak. Read it " +
|
|
2558
|
+
"against the device caps in `session_meta`, not against an absolute budget.",
|
|
2559
|
+
caveats: [
|
|
2560
|
+
EMPTY_SET_NULLS,
|
|
2561
|
+
"Requires the footprint capture option (`capture.resourceSample`, **off by default**, ADR 0012); without it the result is empty.",
|
|
2562
|
+
"Unreported metrics are stored as `0` and excluded from the averages, so a metric one engine omits does not dilute another's.",
|
|
2563
|
+
"JS heap is only available where the browser exposes it (Chromium-family); elsewhere it reads as unreported.",
|
|
2564
|
+
"Averages are pooled over samples, so a long session dominates — use `resource_percentiles` for a per-session view.",
|
|
2565
|
+
],
|
|
2566
|
+
sourceChannels: ["resource_sample"],
|
|
2567
|
+
related: ["resource_percentiles", "perf_summary", "perf_by_device"],
|
|
2568
|
+
comparable: { primary: "max_js_heap_bytes", direction: "down", minSample: 10 },
|
|
2569
|
+
category: "performance",
|
|
2570
|
+
},
|
|
2571
|
+
resource_percentiles: {
|
|
2572
|
+
id: "resource_percentiles",
|
|
2573
|
+
title: "GPU / memory footprint percentiles",
|
|
2574
|
+
description: "Footprint percentiles computed per session then aggregated (ADR 0028 §1): a typical (p50) " +
|
|
2575
|
+
"and peak (p95) JS heap, texture bytes and triangle count per session, summarised as the " +
|
|
2576
|
+
"median across sessions. Always a single row.",
|
|
2577
|
+
builder: "buildResourcePercentiles",
|
|
2578
|
+
endpoint: { method: "GET", path: "/api/v1/perf/resource-percentiles" },
|
|
2579
|
+
grain: "project",
|
|
2580
|
+
dimensions: ["scene", "session"],
|
|
2581
|
+
filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
|
|
2582
|
+
row: z.object({
|
|
2583
|
+
sessions: int,
|
|
2584
|
+
samples: intOrNull,
|
|
2585
|
+
p50_js_heap_bytes: numOrNull,
|
|
2586
|
+
p95_js_heap_bytes: numOrNull,
|
|
2587
|
+
p50_texture_bytes: numOrNull,
|
|
2588
|
+
p95_texture_bytes: numOrNull,
|
|
2589
|
+
p50_triangles: numOrNull,
|
|
2590
|
+
p95_triangles: numOrNull,
|
|
2591
|
+
}),
|
|
2592
|
+
columns: {
|
|
2593
|
+
sessions: { description: "Sessions contributing a percentile.", unit: "sessions" },
|
|
2594
|
+
samples: { description: "Footprint samples behind them.", unit: "count" },
|
|
2595
|
+
p50_js_heap_bytes: {
|
|
2596
|
+
description: "Median session's typical JS heap.",
|
|
2597
|
+
unit: "bytes",
|
|
2598
|
+
measure: true,
|
|
2599
|
+
},
|
|
2600
|
+
p95_js_heap_bytes: { description: "Median session's peak JS heap.", unit: "bytes" },
|
|
2601
|
+
p50_texture_bytes: { description: "Median session's typical texture memory.", unit: "bytes" },
|
|
2602
|
+
p95_texture_bytes: { description: "Median session's peak texture memory.", unit: "bytes" },
|
|
2603
|
+
p50_triangles: {
|
|
2604
|
+
description: "Median session's typical submitted triangles.",
|
|
2605
|
+
unit: "count",
|
|
2606
|
+
},
|
|
2607
|
+
p95_triangles: { description: "Median session's peak submitted triangles.", unit: "count" },
|
|
2608
|
+
},
|
|
2609
|
+
limits: { maxRows: 1, maxSummaryRows: 1 },
|
|
2610
|
+
interpretation: "The distribution-honest companion to `resource_summary`: a single heavy session no longer " +
|
|
2611
|
+
"sets the headline footprint.",
|
|
2612
|
+
caveats: [
|
|
2613
|
+
EMPTY_SET_NULLS,
|
|
2614
|
+
"Requires the footprint capture option (`capture.resourceSample`, **off by default**, ADR 0012).",
|
|
2615
|
+
"Unreported metrics (stored `0`) are excluded rather than counted as zero.",
|
|
2616
|
+
PER_SESSION_THEN_AGGREGATE,
|
|
2617
|
+
"Fewer than ~20 sessions makes the medians unstable.",
|
|
2618
|
+
],
|
|
2619
|
+
sourceChannels: ["resource_sample"],
|
|
2620
|
+
related: ["resource_summary", "perf_distribution", "perf_by_device"],
|
|
2621
|
+
comparable: { primary: "p95_js_heap_bytes", direction: "down", minSample: 20 },
|
|
2622
|
+
category: "performance",
|
|
2623
|
+
},
|
|
2624
|
+
// =========================================================================
|
|
2625
|
+
// Errors, stability & capabilities
|
|
2626
|
+
// =========================================================================
|
|
2627
|
+
stability_counts: {
|
|
2628
|
+
id: "stability_counts",
|
|
2629
|
+
title: "Stability incidents",
|
|
2630
|
+
description: "GPU context losses and shader/pipeline compile stalls over the range, plus their total. " +
|
|
2631
|
+
"Always a single row. These are the hard failures a frame-rate average cannot show — a " +
|
|
2632
|
+
"context loss blanks the canvas, a compile stall freezes first interaction.",
|
|
2633
|
+
builder: "buildStabilityCounts",
|
|
2634
|
+
endpoint: { method: "GET", path: "/api/v1/perf/stability" },
|
|
2635
|
+
grain: "project",
|
|
2636
|
+
dimensions: ["scene", "session"],
|
|
2637
|
+
filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
|
|
2638
|
+
row: z.object({ context_losses: int, compile_stalls: int, incidents: int }),
|
|
2639
|
+
columns: {
|
|
2640
|
+
context_losses: { description: "`context_lost` events in scope.", unit: "count" },
|
|
2641
|
+
compile_stalls: { description: "`compile_stall` events in scope.", unit: "count" },
|
|
2642
|
+
incidents: { description: "Sum of both.", unit: "count", measure: true },
|
|
2643
|
+
},
|
|
2644
|
+
limits: { maxRows: 1, maxSummaryRows: 1 },
|
|
2645
|
+
interpretation: "An empty range reports `0`, never null. Normalise by session count (`list_sessions`) before " +
|
|
2646
|
+
"comparing periods — raw incident counts track traffic.",
|
|
2647
|
+
caveats: [
|
|
2648
|
+
"Context-loss capture is on by default but engine-dependent; a connector that does not hook it reports zero.",
|
|
2649
|
+
"Compile stalls cluster at first load, so this total is dominated by new visitors.",
|
|
2650
|
+
"It counts incidents, not affected sessions — one bad session can produce many.",
|
|
2651
|
+
],
|
|
2652
|
+
sourceChannels: ["context_lost", "compile_stall"],
|
|
2653
|
+
related: ["compile_stalls", "graphics_diagnostics", "capability_changes", "event_counts"],
|
|
2654
|
+
comparable: { primary: "incidents", direction: "down", minSample: 10 },
|
|
2655
|
+
category: "errors",
|
|
2656
|
+
},
|
|
2657
|
+
graphics_diagnostics: {
|
|
2658
|
+
id: "graphics_diagnostics",
|
|
2659
|
+
title: "Engine diagnostic counts",
|
|
2660
|
+
description: "Opt-in engine diagnostics crossed by (severity, category, backend) with a rollup-aware " +
|
|
2661
|
+
"incident total (ADR 0021 part 2). One row per combination. Surfaces validation errors, " +
|
|
2662
|
+
"shader-compile failures and context-loss detail the engine reports.",
|
|
2663
|
+
builder: "buildGraphicsDiagnosticCounts",
|
|
2664
|
+
endpoint: { method: "GET", path: "/api/v1/graphics-diagnostics" },
|
|
2665
|
+
grain: "row",
|
|
2666
|
+
dimensions: ["scene", "session"],
|
|
2667
|
+
filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
|
|
2668
|
+
row: z.object({ severity: text, category: text, backend: text, incidents: int }),
|
|
2669
|
+
columns: {
|
|
2670
|
+
severity: {
|
|
2671
|
+
description: "Diagnostic severity (info / warning / error / fatal).",
|
|
2672
|
+
unit: "label",
|
|
2673
|
+
},
|
|
2674
|
+
category: {
|
|
2675
|
+
description: "Diagnostic category (context-loss / validation / shader-compile / …).",
|
|
2676
|
+
unit: "label",
|
|
2677
|
+
label: true,
|
|
2678
|
+
},
|
|
2679
|
+
backend: {
|
|
2680
|
+
description: "Graphics backend; `''` when the connector omitted it.",
|
|
2681
|
+
unit: "label",
|
|
2682
|
+
},
|
|
2683
|
+
incidents: {
|
|
2684
|
+
description: "Incidents in the cell, markers and per-session rollups folded together.",
|
|
2685
|
+
unit: "count",
|
|
2686
|
+
measure: true,
|
|
2687
|
+
},
|
|
2688
|
+
},
|
|
2689
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
2690
|
+
interpretation: "Sum over one column to get the by-severity, by-category or by-backend breakdown from this " +
|
|
2691
|
+
"single query. Each event is either one discrete incident or a per-session rollup of N, and " +
|
|
2692
|
+
"the total already accounts for both.",
|
|
2693
|
+
caveats: [
|
|
2694
|
+
"Diagnostic capture is **off by default** (`captureGraphicsDiagnostics`, ADR 0021); an empty result is the common case and does not mean the engine is clean.",
|
|
2695
|
+
"Severity/category/backend ride in the event payload rather than promoted columns.",
|
|
2696
|
+
"Counts incidents, not affected sessions.",
|
|
2697
|
+
],
|
|
2698
|
+
sourceChannels: ["graphics_diagnostic"],
|
|
2699
|
+
related: ["stability_counts", "error_heatmap", "rendering_technology", "capability_changes"],
|
|
2700
|
+
comparable: { primary: "incidents", direction: "down", minSample: 10 },
|
|
2701
|
+
category: "errors",
|
|
2702
|
+
},
|
|
2703
|
+
error_heatmap: {
|
|
2704
|
+
id: "error_heatmap",
|
|
2705
|
+
title: "Spatial error heatmap",
|
|
2706
|
+
description: "Positioned runtime errors and engine diagnostics voxel-binned into a uniform grid (#154). " +
|
|
2707
|
+
"One row per occupied voxel, busiest first. Reveals *where* in the scene things break, not " +
|
|
2708
|
+
"only when.",
|
|
2709
|
+
builder: "buildErrorHeatmap",
|
|
2710
|
+
endpoint: { method: "GET", path: "/api/v1/heatmaps/errors" },
|
|
2711
|
+
grain: "voxel",
|
|
2712
|
+
dimensions: ["scene", "session"],
|
|
2713
|
+
filters: [
|
|
2714
|
+
"since",
|
|
2715
|
+
"until",
|
|
2716
|
+
"cellSize",
|
|
2717
|
+
"limit",
|
|
2718
|
+
"scene",
|
|
2719
|
+
"session",
|
|
2720
|
+
"region",
|
|
2721
|
+
"severity",
|
|
2722
|
+
"category",
|
|
2723
|
+
"errorKind",
|
|
2724
|
+
"format",
|
|
2725
|
+
],
|
|
2726
|
+
row: voxelCountRow,
|
|
2727
|
+
columns: {
|
|
2728
|
+
vx: { description: "Voxel X index.", unit: "index", label: true },
|
|
2729
|
+
vy: { description: "Voxel Y index.", unit: "index" },
|
|
2730
|
+
vz: { description: "Voxel Z index.", unit: "index" },
|
|
2731
|
+
count: { description: "Errors and diagnostics in the voxel.", unit: "count", measure: true },
|
|
2732
|
+
},
|
|
2733
|
+
limits: { maxRows: 10000, maxSummaryRows: 8 },
|
|
2734
|
+
interpretation: "Setting `severity` or `category` narrows to engine diagnostics; setting `errorKind` narrows " +
|
|
2735
|
+
"to JS runtime errors. Leave all three unset to bin both streams together.",
|
|
2736
|
+
caveats: [
|
|
2737
|
+
"Only events carrying a full 3-vector position participate — errors from pages with no 3D connector are excluded by construction.",
|
|
2738
|
+
"The position is the camera pose at the moment the error fired, which is best-effort and not necessarily where the fault is.",
|
|
2739
|
+
"Engine diagnostics need their opt-in capture option (ADR 0021); with it off only JS runtime errors appear.",
|
|
2740
|
+
CELL_SIZE_SENSITIVE,
|
|
2741
|
+
],
|
|
2742
|
+
sourceChannels: ["runtime_error", "graphics_diagnostic"],
|
|
2743
|
+
related: ["graphics_diagnostics", "stability_counts", "perf_heatmap"],
|
|
2744
|
+
comparable: { primary: "count", direction: "down", minSample: 10 },
|
|
2745
|
+
category: "errors",
|
|
2746
|
+
},
|
|
2747
|
+
rendering_technology: {
|
|
2748
|
+
id: "rendering_technology",
|
|
2749
|
+
title: "Rendering-technology mix",
|
|
2750
|
+
description: "Session counts crossed by (api, backend, api version, shading language) from the " +
|
|
2751
|
+
"always-on `session_start` graphics block (ADR 0021 part 1, ADR 0046). One row per " +
|
|
2752
|
+
"combination — WebGPU vs WebGL2 adoption, and which shading language is in play.",
|
|
2753
|
+
builder: "buildRenderingTechnology",
|
|
2754
|
+
endpoint: { method: "GET", path: "/api/v1/rendering-technology" },
|
|
2755
|
+
grain: "row",
|
|
2756
|
+
dimensions: ["scene", "session"],
|
|
2757
|
+
filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
|
|
2758
|
+
row: z.object({
|
|
2759
|
+
api: text,
|
|
2760
|
+
backend: text,
|
|
2761
|
+
api_version: text,
|
|
2762
|
+
shading_language: text,
|
|
2763
|
+
sessions: int,
|
|
2764
|
+
}),
|
|
2765
|
+
columns: {
|
|
2766
|
+
api: {
|
|
2767
|
+
description: "Graphics API; `''` when the connector omitted it.",
|
|
2768
|
+
unit: "label",
|
|
2769
|
+
label: true,
|
|
2770
|
+
},
|
|
2771
|
+
backend: { description: "Engine backend; `''` if unreported.", unit: "label" },
|
|
2772
|
+
api_version: { description: "API version string; `''` if unreported.", unit: "label" },
|
|
2773
|
+
shading_language: { description: "Shading language; `''` if unreported.", unit: "label" },
|
|
2774
|
+
sessions: { description: "Sessions in the cell.", unit: "sessions", measure: true },
|
|
2775
|
+
},
|
|
2776
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
2777
|
+
interpretation: "Sum over one column to derive the by-api, by-backend, by-version or by-shading-language " +
|
|
2778
|
+
"breakdown from this single query. `session_start` is always on, so a populated result is the " +
|
|
2779
|
+
"normal case.",
|
|
2780
|
+
caveats: [
|
|
2781
|
+
"`''` means 'the connector did not report this field', which is a real and often large group.",
|
|
2782
|
+
"It counts sessions, not visitors — one person across two days counts twice.",
|
|
2783
|
+
SMALL_SAMPLE,
|
|
2784
|
+
],
|
|
2785
|
+
sourceChannels: ["session_start"],
|
|
2786
|
+
related: ["perf_by_device", "capability_changes", "graphics_diagnostics"],
|
|
2787
|
+
comparable: { primary: "sessions", direction: "neutral", minSample: 30 },
|
|
2788
|
+
category: "performance",
|
|
2789
|
+
},
|
|
2790
|
+
capability_changes: {
|
|
2791
|
+
id: "capability_changes",
|
|
2792
|
+
title: "Capability / fidelity transitions",
|
|
2793
|
+
description: "How often the app reported a capability fallback or recovery, per (kind, from, to) (#49). " +
|
|
2794
|
+
"One row per transition. Explains perf and visual-fidelity variance — e.g. how many sessions " +
|
|
2795
|
+
"fell back from WebGPU to WebGL2.",
|
|
2796
|
+
builder: "buildCapabilityChanges",
|
|
2797
|
+
endpoint: { method: "GET", path: "/api/v1/capabilities" },
|
|
2798
|
+
grain: "row",
|
|
2799
|
+
dimensions: ["name", "scene", "session"],
|
|
2800
|
+
filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
|
|
2801
|
+
row: z.object({ kind: text, from: text, to: text, changes: int }),
|
|
2802
|
+
columns: {
|
|
2803
|
+
kind: {
|
|
2804
|
+
description: "Capability class (graphics-backend / quality / device-recovery / tracking / feature / other).",
|
|
2805
|
+
unit: "label",
|
|
2806
|
+
label: true,
|
|
2807
|
+
},
|
|
2808
|
+
from: { description: "Previous capability token; `''` if unreported.", unit: "label" },
|
|
2809
|
+
to: { description: "New capability token; `''` if unreported.", unit: "label" },
|
|
2810
|
+
changes: { description: "Times the transition was reported.", unit: "count", measure: true },
|
|
2811
|
+
},
|
|
2812
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
2813
|
+
interpretation: "A fallback and its recovery are separate rows in opposite directions; a large imbalance " +
|
|
2814
|
+
'means sessions ended while degraded. `kind: "tracking"` rows are the XR signal behind ' +
|
|
2815
|
+
"`xr_tracking_quality`.",
|
|
2816
|
+
caveats: [
|
|
2817
|
+
"Only transitions the app or connector actually reports appear — silence is not stability.",
|
|
2818
|
+
"Reported through `reportCapabilityChange(...)` or the XR tracking option; neither is universal.",
|
|
2819
|
+
SMALL_SAMPLE,
|
|
2820
|
+
],
|
|
2821
|
+
sourceChannels: ["capability_change"],
|
|
2822
|
+
related: [
|
|
2823
|
+
"rendering_technology",
|
|
2824
|
+
"xr_tracking_quality",
|
|
2825
|
+
"render_scale_truth",
|
|
2826
|
+
"stability_counts",
|
|
2827
|
+
],
|
|
2828
|
+
comparable: { primary: "changes", direction: "down", minSample: 10 },
|
|
2829
|
+
category: "errors",
|
|
2830
|
+
},
|
|
2831
|
+
// =========================================================================
|
|
2832
|
+
// XR & AR
|
|
2833
|
+
// =========================================================================
|
|
2834
|
+
xr_rotation: {
|
|
2835
|
+
id: "xr_rotation",
|
|
2836
|
+
title: "XR head-rotation rate",
|
|
2837
|
+
description: "Per session, how fast the view turned over the camera pose stream — the angular path, the " +
|
|
2838
|
+
"worst single jerk, and how many steps cleared the rapid-turn threshold. One row per session. " +
|
|
2839
|
+
"A motion-sickness proxy.",
|
|
2840
|
+
builder: "buildXrRotationRate",
|
|
2841
|
+
endpoint: { method: "GET", path: "/api/v1/xr/rotation" },
|
|
2842
|
+
grain: "session",
|
|
2843
|
+
dimensions: ["session", "scene"],
|
|
2844
|
+
filters: ["since", "until", "rapidTurn", "limit", "scene", "session", "format"],
|
|
2845
|
+
row: z.object({
|
|
2846
|
+
session_id: text,
|
|
2847
|
+
samples: int,
|
|
2848
|
+
avg_turn_rad: num,
|
|
2849
|
+
max_turn_rad: num,
|
|
2850
|
+
total_turn_rad: num,
|
|
2851
|
+
rapid_segments: int,
|
|
2852
|
+
}),
|
|
2853
|
+
columns: {
|
|
2854
|
+
session_id: { description: "Session identifier.", unit: "id", label: true },
|
|
2855
|
+
samples: { description: "Pose samples contributing a turn.", unit: "count" },
|
|
2856
|
+
avg_turn_rad: {
|
|
2857
|
+
description: "Mean angle between consecutive view directions.",
|
|
2858
|
+
unit: "radians",
|
|
2859
|
+
},
|
|
2860
|
+
max_turn_rad: { description: "Worst single inter-sample turn.", unit: "radians" },
|
|
2861
|
+
total_turn_rad: {
|
|
2862
|
+
description: "Total angular path travelled.",
|
|
2863
|
+
unit: "radians",
|
|
2864
|
+
measure: true,
|
|
2865
|
+
},
|
|
2866
|
+
rapid_segments: {
|
|
2867
|
+
description: "Steps whose turn cleared `rapidTurn`.",
|
|
2868
|
+
unit: "count",
|
|
2869
|
+
rateOf: "samples",
|
|
2870
|
+
},
|
|
2871
|
+
},
|
|
2872
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
2873
|
+
interpretation: "`rapid_segments / samples` is the share of the session spent turning uncomfortably fast. " +
|
|
2874
|
+
"Rapid view rotation correlates with simulator sickness, most acutely in a headset.",
|
|
2875
|
+
caveats: [
|
|
2876
|
+
"Turn *rate* depends on the pose cadence: at ~1 Hz a 'step' spans a second, so the angles are coarse. Raise the camera sampling rate before trusting them in a headset.",
|
|
2877
|
+
CAMERA_SAMPLED,
|
|
2878
|
+
"Not XR-gated — it runs over any session's camera samples, including flat-screen ones.",
|
|
2879
|
+
"`rapid_segments` moves with `rapidTurn`; report the threshold with the result.",
|
|
2880
|
+
],
|
|
2881
|
+
sourceChannels: ["camera_sample"],
|
|
2882
|
+
related: ["xr_locomotion", "xr_abandonment", "camera_gestures", "xr_tracking_quality"],
|
|
2883
|
+
comparable: { primary: "rapid_segments", direction: "down", minSample: 10 },
|
|
2884
|
+
category: "xr",
|
|
2885
|
+
},
|
|
2886
|
+
xr_sources: {
|
|
2887
|
+
id: "xr_sources",
|
|
2888
|
+
title: "XR input-source usage",
|
|
2889
|
+
description: "The immersive input mix: one row per XR input source (hand, controller, gaze, transient) " +
|
|
2890
|
+
"with its interaction count and how many sessions used it. Flat-screen sources are excluded " +
|
|
2891
|
+
"so the split is purely XR.",
|
|
2892
|
+
builder: "buildXrSourceUsage",
|
|
2893
|
+
endpoint: { method: "GET", path: "/api/v1/xr/sources" },
|
|
2894
|
+
grain: "row",
|
|
2895
|
+
dimensions: ["source", "scene", "session"],
|
|
2896
|
+
filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
|
|
2897
|
+
row: z.object({ source: text, interactions: int, sessions: int }),
|
|
2898
|
+
columns: {
|
|
2899
|
+
source: { description: "XR input source.", unit: "label", label: true },
|
|
2900
|
+
interactions: { description: "Interactions from the source.", unit: "count", measure: true },
|
|
2901
|
+
sessions: { description: "Distinct sessions that used it.", unit: "sessions" },
|
|
2902
|
+
},
|
|
2903
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
2904
|
+
interpretation: "Weigh `sessions` alongside `interactions`: hand tracking often shows few sessions with many " +
|
|
2905
|
+
"interactions (it is chatty), controllers the reverse.",
|
|
2906
|
+
caveats: [
|
|
2907
|
+
"An empty result means no XR input was seen, which is the normal case for a flat-screen project.",
|
|
2908
|
+
"Requires XR interaction capture (`xr.capture.clicks` / `meshPicks`); pose-only XR sessions contribute nothing.",
|
|
2909
|
+
SMALL_SAMPLE,
|
|
2910
|
+
],
|
|
2911
|
+
sourceChannels: ["pointer_click", "pointer_move", "mesh_interaction"],
|
|
2912
|
+
related: ["interaction_sources", "xr_locomotion", "xr_tracking_quality", "mesh_reachability"],
|
|
2913
|
+
comparable: { primary: "interactions", direction: "neutral", minSample: 30 },
|
|
2914
|
+
category: "xr",
|
|
2915
|
+
},
|
|
2916
|
+
xr_abandonment: {
|
|
2917
|
+
id: "xr_abandonment",
|
|
2918
|
+
title: "XR session abandonment",
|
|
2919
|
+
description: "For every session that used an XR input source, its wall-clock bounds and event / " +
|
|
2920
|
+
"interaction counts. One row per XR session. A short span with few interactions is headset " +
|
|
2921
|
+
"drop-off.",
|
|
2922
|
+
builder: "buildXrAbandonment",
|
|
2923
|
+
endpoint: { method: "GET", path: "/api/v1/xr/abandonment" },
|
|
2924
|
+
grain: "session",
|
|
2925
|
+
dimensions: ["session", "scene"],
|
|
2926
|
+
filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
|
|
2927
|
+
row: z.object({
|
|
2928
|
+
session_id: text,
|
|
2929
|
+
events: int,
|
|
2930
|
+
xr_interactions: int,
|
|
2931
|
+
started_at: ts,
|
|
2932
|
+
ended_at: ts,
|
|
2933
|
+
}),
|
|
2934
|
+
columns: {
|
|
2935
|
+
session_id: { description: "Session identifier.", unit: "id", label: true },
|
|
2936
|
+
events: { description: "All events in the session.", unit: "count" },
|
|
2937
|
+
xr_interactions: {
|
|
2938
|
+
description: "Interactions from an XR input source.",
|
|
2939
|
+
unit: "count",
|
|
2940
|
+
measure: true,
|
|
2941
|
+
},
|
|
2942
|
+
started_at: { description: "First event timestamp.", unit: "timestamp" },
|
|
2943
|
+
ended_at: { description: "Last event timestamp.", unit: "timestamp" },
|
|
2944
|
+
},
|
|
2945
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
2946
|
+
interpretation: "Abandonment is read from the span: `ended_at - started_at` short relative to the rest of the " +
|
|
2947
|
+
"cohort. Pair with `xr_rotation` and `xr_locomotion` to test whether discomfort explains it.",
|
|
2948
|
+
caveats: [
|
|
2949
|
+
"Sessions with no XR input are omitted entirely, so this is never a full session list.",
|
|
2950
|
+
ENGINE_TIMESTAMPS,
|
|
2951
|
+
"A short span can equally mean a quick successful task; compare against the cohort, not an absolute.",
|
|
2952
|
+
],
|
|
2953
|
+
sourceChannels: ["pointer_click", "pointer_move", "mesh_interaction"],
|
|
2954
|
+
related: ["xr_locomotion", "xr_rotation", "xr_tracking_quality", "list_sessions"],
|
|
2955
|
+
comparable: { primary: "xr_interactions", direction: "up", minSample: 10 },
|
|
2956
|
+
category: "xr",
|
|
2957
|
+
},
|
|
2958
|
+
xr_locomotion: {
|
|
2959
|
+
id: "xr_locomotion",
|
|
2960
|
+
title: "XR locomotion & comfort",
|
|
2961
|
+
description: "Per XR session, its locomotion-style mix — fly and navigate gestures, discrete teleports, " +
|
|
2962
|
+
"and total time in locomotion — plus the session's wall-clock span (#148). One row per XR " +
|
|
2963
|
+
"session. Constant smooth locomotion is a motion-sickness risk; teleport-dominant sessions " +
|
|
2964
|
+
"are not.",
|
|
2965
|
+
builder: "buildXrLocomotionComfort",
|
|
2966
|
+
endpoint: { method: "GET", path: "/api/v1/xr/locomotion" },
|
|
2967
|
+
grain: "session",
|
|
2968
|
+
dimensions: ["session", "scene"],
|
|
2969
|
+
filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
|
|
2970
|
+
row: z.object({
|
|
2971
|
+
session_id: text,
|
|
2972
|
+
fly_gestures: int,
|
|
2973
|
+
navigate_gestures: int,
|
|
2974
|
+
teleports: int,
|
|
2975
|
+
locomotion_ms: num,
|
|
2976
|
+
started_at: ts,
|
|
2977
|
+
ended_at: ts,
|
|
2978
|
+
}),
|
|
2979
|
+
columns: {
|
|
2980
|
+
session_id: { description: "Session identifier.", unit: "id", label: true },
|
|
2981
|
+
fly_gestures: {
|
|
2982
|
+
description: "`fly` gestures — smooth thumbstick moves *and* teleport flies.",
|
|
2983
|
+
unit: "count",
|
|
2984
|
+
},
|
|
2985
|
+
navigate_gestures: {
|
|
2986
|
+
description: "`navigate` gestures — untyped user-bracketed moves.",
|
|
2987
|
+
unit: "count",
|
|
2988
|
+
},
|
|
2989
|
+
teleports: { description: "Discrete viewpoint jumps.", unit: "count" },
|
|
2990
|
+
locomotion_ms: {
|
|
2991
|
+
description: "Total time in fly + navigate gestures.",
|
|
2992
|
+
unit: "ms",
|
|
2993
|
+
measure: true,
|
|
2994
|
+
},
|
|
2995
|
+
started_at: { description: "First event timestamp.", unit: "timestamp" },
|
|
2996
|
+
ended_at: { description: "Last event timestamp.", unit: "timestamp" },
|
|
2997
|
+
},
|
|
2998
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
2999
|
+
interpretation: "Smooth locomotion is `fly_gestures - teleports`: a teleport emits both a fly gesture and a " +
|
|
3000
|
+
"teleport interaction (ADR 0025), so the raw `fly_gestures` over-counts smooth movement. A " +
|
|
3001
|
+
"high smooth count with a short span is the discomfort / rage-quit signature.",
|
|
3002
|
+
caveats: [
|
|
3003
|
+
"Sessions with no XR input are omitted entirely.",
|
|
3004
|
+
"Requires camera-gesture capture (on by default) and XR mesh-pick capture for the teleport half.",
|
|
3005
|
+
ENGINE_TIMESTAMPS,
|
|
3006
|
+
SMALL_SAMPLE,
|
|
3007
|
+
],
|
|
3008
|
+
sourceChannels: ["camera_gesture", "mesh_interaction"],
|
|
3009
|
+
related: ["xr_rotation", "xr_abandonment", "camera_gestures", "xr_boundary_contacts"],
|
|
3010
|
+
comparable: { primary: "locomotion_ms", direction: "down", minSample: 10 },
|
|
3011
|
+
category: "xr",
|
|
3012
|
+
},
|
|
3013
|
+
xr_tracking_quality: {
|
|
3014
|
+
id: "xr_tracking_quality",
|
|
3015
|
+
title: "XR tracking quality",
|
|
3016
|
+
description: "Per session that reported a tracking transition, how much of it ran with degraded or lost " +
|
|
3017
|
+
"spatial tracking, split by hand vs controller (#155, ADR 0048). One row per session. A " +
|
|
3018
|
+
"session that looked fine on FPS can still have been unusable because the hands kept " +
|
|
3019
|
+
"disappearing.",
|
|
3020
|
+
builder: "buildTrackingQuality",
|
|
3021
|
+
endpoint: { method: "GET", path: "/api/v1/xr/tracking" },
|
|
3022
|
+
grain: "session",
|
|
3023
|
+
dimensions: ["session", "scene", "source"],
|
|
3024
|
+
filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
|
|
3025
|
+
row: z.object({
|
|
3026
|
+
session_id: text,
|
|
3027
|
+
degraded_ms: num,
|
|
3028
|
+
hand_degraded_ms: num,
|
|
3029
|
+
controller_degraded_ms: num,
|
|
3030
|
+
degraded_episodes: int,
|
|
3031
|
+
started_at: ts,
|
|
3032
|
+
ended_at: ts,
|
|
3033
|
+
}),
|
|
3034
|
+
columns: {
|
|
3035
|
+
session_id: { description: "Session identifier.", unit: "id", label: true },
|
|
3036
|
+
degraded_ms: {
|
|
3037
|
+
description: "Total degraded / lost tracking time.",
|
|
3038
|
+
unit: "ms",
|
|
3039
|
+
measure: true,
|
|
3040
|
+
},
|
|
3041
|
+
hand_degraded_ms: { description: "Degraded time attributed to hand tracking.", unit: "ms" },
|
|
3042
|
+
controller_degraded_ms: {
|
|
3043
|
+
description: "Degraded time attributed to controller tracking.",
|
|
3044
|
+
unit: "ms",
|
|
3045
|
+
},
|
|
3046
|
+
degraded_episodes: { description: "Completed degraded episodes.", unit: "count" },
|
|
3047
|
+
started_at: { description: "First event timestamp of the whole session.", unit: "timestamp" },
|
|
3048
|
+
ended_at: { description: "Last event timestamp of the whole session.", unit: "timestamp" },
|
|
3049
|
+
},
|
|
3050
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
3051
|
+
interpretation: "The degraded share is `degraded_ms / (ended_at - started_at)` — the timestamps deliberately " +
|
|
3052
|
+
"bound the whole session, not just the tracking events, so that division is meaningful.",
|
|
3053
|
+
caveats: [
|
|
3054
|
+
"Requires the XR tracking capture option (`xr.capture.tracking`, ADR 0048); sessions with no tracking transition are omitted entirely.",
|
|
3055
|
+
"Detection is coarse: a source deliberately switched off looks the same as one whose tracking was lost. Apps with a real confidence hook should report transitions explicitly.",
|
|
3056
|
+
"Only *completed* episodes carry a duration, so tracking still degraded at session end is under-counted.",
|
|
3057
|
+
ENGINE_TIMESTAMPS,
|
|
3058
|
+
],
|
|
3059
|
+
sourceChannels: ["capability_change"],
|
|
3060
|
+
related: ["capability_changes", "xr_sources", "xr_abandonment", "xr_locomotion"],
|
|
3061
|
+
comparable: { primary: "degraded_ms", direction: "down", minSample: 10 },
|
|
3062
|
+
category: "xr",
|
|
3063
|
+
},
|
|
3064
|
+
boundary_heatmap: {
|
|
3065
|
+
id: "boundary_heatmap",
|
|
3066
|
+
title: "Guardian / boundary-touch heatmap",
|
|
3067
|
+
description: "Where room-scale VR visitors approached their play-space boundary, voxel-binned into a " +
|
|
3068
|
+
"uniform grid (#157, ADR 0048). One row per occupied voxel, busiest first. The 'where did " +
|
|
3069
|
+
"people keep bumping into their guardian' map.",
|
|
3070
|
+
builder: "buildBoundaryHeatmap",
|
|
3071
|
+
endpoint: { method: "GET", path: "/api/v1/heatmaps/boundary" },
|
|
3072
|
+
grain: "voxel",
|
|
3073
|
+
dimensions: ["scene", "session"],
|
|
3074
|
+
filters: ["since", "until", "cellSize", "limit", "scene", "session", "region", "format"],
|
|
3075
|
+
row: voxelCountRow,
|
|
3076
|
+
columns: {
|
|
3077
|
+
vx: { description: "Voxel X index.", unit: "index", label: true },
|
|
3078
|
+
vy: { description: "Voxel Y index.", unit: "index" },
|
|
3079
|
+
vz: { description: "Voxel Z index.", unit: "index" },
|
|
3080
|
+
count: { description: "Boundary approaches in the voxel.", unit: "count", measure: true },
|
|
3081
|
+
},
|
|
3082
|
+
limits: { maxRows: 1000, maxSummaryRows: 8 },
|
|
3083
|
+
interpretation: "A cluster means the experience asked visitors to move somewhere their physical room does not " +
|
|
3084
|
+
"allow — content to reposition, not a bug in the headset.",
|
|
3085
|
+
caveats: [
|
|
3086
|
+
"Requires boundary-proximity capture (`trackBoundaryProximity`, opt-in, ADR 0048); empty otherwise.",
|
|
3087
|
+
"The boundary polygon and room geometry are **never** captured (ADR 0003) — only the coarse HMD position at closest approach.",
|
|
3088
|
+
"Room layouts differ per visitor, so voxels only pool meaningfully within one scene's content frame.",
|
|
3089
|
+
CELL_SIZE_SENSITIVE,
|
|
3090
|
+
TRUNCATED_TOP_N,
|
|
3091
|
+
],
|
|
3092
|
+
sourceChannels: ["xr_boundary_proximity"],
|
|
3093
|
+
related: [
|
|
3094
|
+
"boundary_heatmap_stats",
|
|
3095
|
+
"xr_boundary_contacts",
|
|
3096
|
+
"position_heatmap",
|
|
3097
|
+
"xr_locomotion",
|
|
3098
|
+
],
|
|
3099
|
+
comparable: { primary: "count", direction: "down", minSample: 30 },
|
|
3100
|
+
category: "xr",
|
|
3101
|
+
},
|
|
3102
|
+
boundary_heatmap_stats: {
|
|
3103
|
+
id: "boundary_heatmap_stats",
|
|
3104
|
+
title: "Boundary heatmap totals",
|
|
3105
|
+
description: "The un-truncated totals behind `boundary_heatmap` (ADR 0040 §3): occupied voxels and total " +
|
|
3106
|
+
"boundary contacts, with no row cap. Always a single row.",
|
|
3107
|
+
builder: "buildBoundaryHeatmapStats",
|
|
3108
|
+
endpoint: { method: "GET", path: "/api/v1/heatmaps/boundary/stats" },
|
|
3109
|
+
grain: "project",
|
|
3110
|
+
dimensions: ["scene", "session"],
|
|
3111
|
+
filters: ["since", "until", "cellSize", "scene", "session", "region", "format"],
|
|
3112
|
+
row: spatialStatsRow,
|
|
3113
|
+
columns: {
|
|
3114
|
+
cells: { description: "Occupied boundary voxels across the scene or region.", unit: "count" },
|
|
3115
|
+
hits: { description: "Total boundary approaches.", unit: "count", measure: true },
|
|
3116
|
+
},
|
|
3117
|
+
limits: { maxRows: 1, maxSummaryRows: 1 },
|
|
3118
|
+
interpretation: "Use `hits` as the denominator for shares over a truncated `boundary_heatmap`, and `cells` for " +
|
|
3119
|
+
"'showing the top N of M cells'.",
|
|
3120
|
+
caveats: [
|
|
3121
|
+
"Requires boundary-proximity capture (opt-in, ADR 0048).",
|
|
3122
|
+
CELL_SIZE_SENSITIVE,
|
|
3123
|
+
"Must be called with exactly the same filters and `cellSize` as the `boundary_heatmap` it describes.",
|
|
3124
|
+
],
|
|
3125
|
+
sourceChannels: ["xr_boundary_proximity"],
|
|
3126
|
+
related: ["boundary_heatmap"],
|
|
3127
|
+
category: "xr",
|
|
3128
|
+
},
|
|
3129
|
+
xr_boundary_contacts: {
|
|
3130
|
+
id: "xr_boundary_contacts",
|
|
3131
|
+
title: "Boundary contacts per session",
|
|
3132
|
+
description: "For every session that touched its play-space boundary, how many approaches it made and how " +
|
|
3133
|
+
"long it spent in the near-boundary zone (#157, ADR 0048). One row per session. Frequent " +
|
|
3134
|
+
"contact means the physical space did not fit the experience.",
|
|
3135
|
+
builder: "buildBoundaryContacts",
|
|
3136
|
+
endpoint: { method: "GET", path: "/api/v1/xr/boundary-contacts" },
|
|
3137
|
+
grain: "session",
|
|
3138
|
+
dimensions: ["session", "scene"],
|
|
3139
|
+
filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
|
|
3140
|
+
row: z.object({ session_id: text, contacts: int, near_ms: num }),
|
|
3141
|
+
columns: {
|
|
3142
|
+
session_id: { description: "Session identifier.", unit: "id", label: true },
|
|
3143
|
+
contacts: {
|
|
3144
|
+
description: "Boundary approaches in the session.",
|
|
3145
|
+
unit: "count",
|
|
3146
|
+
measure: true,
|
|
3147
|
+
},
|
|
3148
|
+
near_ms: { description: "Total time spent inside the near-boundary zone.", unit: "ms" },
|
|
3149
|
+
},
|
|
3150
|
+
limits: { maxRows: 1000, maxSummaryRows: 10 },
|
|
3151
|
+
interpretation: "`near_ms / contacts` is how long an average approach lasted: many short contacts is a " +
|
|
3152
|
+
"cramped room, few long ones is content placed against the edge.",
|
|
3153
|
+
caveats: [
|
|
3154
|
+
"Requires boundary-proximity capture (opt-in, ADR 0048); sessions that never approached are absent.",
|
|
3155
|
+
"Room size is a property of the visitor, not the content — normalise before comparing scenes.",
|
|
3156
|
+
"No boundary geometry is ever read; only the promoted position and duration each event carries.",
|
|
3157
|
+
],
|
|
3158
|
+
sourceChannels: ["xr_boundary_proximity"],
|
|
3159
|
+
related: ["boundary_heatmap", "xr_locomotion", "xr_abandonment"],
|
|
3160
|
+
comparable: { primary: "contacts", direction: "down", minSample: 10 },
|
|
3161
|
+
category: "xr",
|
|
3162
|
+
},
|
|
3163
|
+
ar_placement_time_to_place: {
|
|
3164
|
+
id: "ar_placement_time_to_place",
|
|
3165
|
+
title: "AR time-to-place distribution",
|
|
3166
|
+
description: "How long visitors took to place a model on a surface, histogrammed into `bucketMs`-wide " +
|
|
3167
|
+
"bins (#156, ADR 0048 §1). One row per bin, one settle per data point. The felt cost of " +
|
|
3168
|
+
"getting a 'view in your room' model down — the AR analogue of a slow add-to-cart.",
|
|
3169
|
+
builder: "buildArPlacementTimeToPlace",
|
|
3170
|
+
endpoint: { method: "GET", path: "/api/v1/ar/placement/time-to-place" },
|
|
3171
|
+
grain: "bucket",
|
|
3172
|
+
dimensions: ["scene", "session"],
|
|
3173
|
+
filters: ["since", "until", "bins", "limit", "scene", "session", "bucketMs", "format"],
|
|
3174
|
+
row: z.object({ bucket: int, placements: int }),
|
|
3175
|
+
columns: {
|
|
3176
|
+
bucket: {
|
|
3177
|
+
description: "Inclusive lower bound of the time-to-place bin.",
|
|
3178
|
+
unit: "ms",
|
|
3179
|
+
label: true,
|
|
3180
|
+
axis: true,
|
|
3181
|
+
},
|
|
3182
|
+
placements: { description: "Placement settles in the bin.", unit: "count", measure: true },
|
|
3183
|
+
},
|
|
3184
|
+
limits: { maxRows: 1000, maxSummaryRows: 8 },
|
|
3185
|
+
interpretation: "A right shift over time means visitors are struggling more, not that the model got bigger. " +
|
|
3186
|
+
"Read it beside `ar_placement_attempts`, which separates 'slow to decide' from 'fought the UI'.",
|
|
3187
|
+
caveats: [
|
|
3188
|
+
"Requires AR placement capture (`trackArPlacement`, opt-in, ADR 0048).",
|
|
3189
|
+
"`timeToPlaceMs` rides in the event payload; settles without it bin at `0`.",
|
|
3190
|
+
"Only *settles* are counted — abandoned placements never emit an event, so the histogram survivor-biases toward success.",
|
|
3191
|
+
],
|
|
3192
|
+
sourceChannels: ["ar_placement"],
|
|
3193
|
+
related: ["ar_placement_attempts", "ar_placement_surfaces"],
|
|
3194
|
+
comparable: { primary: "placements", direction: "neutral", minSample: 20 },
|
|
3195
|
+
category: "ar",
|
|
3196
|
+
},
|
|
3197
|
+
ar_placement_attempts: {
|
|
3198
|
+
id: "ar_placement_attempts",
|
|
3199
|
+
title: "AR re-placement distribution",
|
|
3200
|
+
description: "How many place / re-place actions visitors made before committing (#156, ADR 0048 §1). One " +
|
|
3201
|
+
"row per attempt count. `attempts = 1` is a clean first try; a long right tail is placement " +
|
|
3202
|
+
"friction.",
|
|
3203
|
+
builder: "buildArPlacementAttempts",
|
|
3204
|
+
endpoint: { method: "GET", path: "/api/v1/ar/placement/attempts" },
|
|
3205
|
+
grain: "bucket",
|
|
3206
|
+
dimensions: ["scene", "session"],
|
|
3207
|
+
filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
|
|
3208
|
+
row: z.object({ attempts: int, placements: int }),
|
|
3209
|
+
columns: {
|
|
3210
|
+
attempts: {
|
|
3211
|
+
description: "Place / re-place actions before the settle.",
|
|
3212
|
+
unit: "count",
|
|
3213
|
+
label: true,
|
|
3214
|
+
axis: true,
|
|
3215
|
+
},
|
|
3216
|
+
placements: {
|
|
3217
|
+
description: "Settles that took exactly that many attempts.",
|
|
3218
|
+
unit: "count",
|
|
3219
|
+
measure: true,
|
|
3220
|
+
},
|
|
3221
|
+
},
|
|
3222
|
+
limits: { maxRows: 1000, maxSummaryRows: 8 },
|
|
3223
|
+
interpretation: "The share at `attempts = 1` is the clean-placement rate — the single number worth tracking " +
|
|
3224
|
+
"release over release.",
|
|
3225
|
+
caveats: [
|
|
3226
|
+
"Requires AR placement capture (`trackArPlacement`, opt-in, ADR 0048).",
|
|
3227
|
+
"`attempts` rides in the event payload and defaults to 1 when absent, so older data skews clean.",
|
|
3228
|
+
"Only settles are counted; abandoned placements are invisible here.",
|
|
3229
|
+
],
|
|
3230
|
+
sourceChannels: ["ar_placement"],
|
|
3231
|
+
related: ["ar_placement_time_to_place", "ar_placement_surfaces"],
|
|
3232
|
+
comparable: { primary: "placements", direction: "neutral", minSample: 20 },
|
|
3233
|
+
category: "ar",
|
|
3234
|
+
},
|
|
3235
|
+
ar_placement_surfaces: {
|
|
3236
|
+
id: "ar_placement_surfaces",
|
|
3237
|
+
title: "AR placement surfaces",
|
|
3238
|
+
description: "Per coarse surface bucket — floor, wall, table, ceiling, unknown — how many settles landed " +
|
|
3239
|
+
"there and their average committed scale (#156, ADR 0048 §1). One row per surface. Shows " +
|
|
3240
|
+
"where visitors place models and how far off the authored size they settle.",
|
|
3241
|
+
builder: "buildArPlacementSurfaces",
|
|
3242
|
+
endpoint: { method: "GET", path: "/api/v1/ar/placement/surfaces" },
|
|
3243
|
+
grain: "row",
|
|
3244
|
+
dimensions: ["scene", "session"],
|
|
3245
|
+
filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
|
|
3246
|
+
row: z.object({ surface: text, placements: int, avg_scale: num }),
|
|
3247
|
+
columns: {
|
|
3248
|
+
surface: {
|
|
3249
|
+
description: "Coarse surface bucket; `unknown` when unclassified.",
|
|
3250
|
+
unit: "label",
|
|
3251
|
+
label: true,
|
|
3252
|
+
},
|
|
3253
|
+
placements: { description: "Settles on the surface.", unit: "count", measure: true },
|
|
3254
|
+
avg_scale: {
|
|
3255
|
+
description: "Mean final scale; `1` means the authored real-world size.",
|
|
3256
|
+
unit: "ratio",
|
|
3257
|
+
},
|
|
3258
|
+
},
|
|
3259
|
+
limits: { maxRows: 1000, maxSummaryRows: 8 },
|
|
3260
|
+
interpretation: "An `avg_scale` far from 1 means the model's default size does not match how people actually " +
|
|
3261
|
+
"use it — a retail-configuration signal, not a tracking problem.",
|
|
3262
|
+
caveats: [
|
|
3263
|
+
"Requires AR placement capture (`trackArPlacement`, opt-in, ADR 0048).",
|
|
3264
|
+
"`surface` and `scale` ride in the event payload; unclassified settles group under `unknown`, which is often the largest bucket.",
|
|
3265
|
+
SMALL_SAMPLE,
|
|
3266
|
+
],
|
|
3267
|
+
sourceChannels: ["ar_placement"],
|
|
3268
|
+
related: ["ar_placement_attempts", "ar_placement_time_to_place"],
|
|
3269
|
+
comparable: { primary: "avg_scale", direction: "neutral", minSample: 20 },
|
|
3270
|
+
category: "ar",
|
|
3271
|
+
},
|
|
3272
|
+
// =========================================================================
|
|
3273
|
+
// Conversion
|
|
3274
|
+
// =========================================================================
|
|
3275
|
+
funnel: {
|
|
3276
|
+
id: "funnel",
|
|
3277
|
+
title: "Conversion funnel",
|
|
3278
|
+
description: "An ordered, per-session conversion funnel over caller-supplied step predicates (ADR 0038): " +
|
|
3279
|
+
"how many sessions reached each step in order. One row per step, 0-based. The OSS collector " +
|
|
3280
|
+
"has no authoring surface, so the steps come from the caller.",
|
|
3281
|
+
builder: "buildFunnel",
|
|
3282
|
+
endpoint: { method: "GET", path: "/api/v1/funnel" },
|
|
3283
|
+
grain: "bucket",
|
|
3284
|
+
dimensions: ["scene", "cameraMode"],
|
|
3285
|
+
filters: ["since", "until", "scene", "cameraMode", "steps", "format"],
|
|
3286
|
+
row: z.object({ step: int, sessions: int }),
|
|
3287
|
+
columns: {
|
|
3288
|
+
step: {
|
|
3289
|
+
description: "0-based step index, in the order the caller supplied.",
|
|
3290
|
+
unit: "index",
|
|
3291
|
+
label: true,
|
|
3292
|
+
axis: true,
|
|
3293
|
+
},
|
|
3294
|
+
sessions: {
|
|
3295
|
+
description: "Sessions that reached the step in order.",
|
|
3296
|
+
unit: "sessions",
|
|
3297
|
+
measure: true,
|
|
3298
|
+
},
|
|
3299
|
+
},
|
|
3300
|
+
limits: { maxRows: 32, maxSummaryRows: 32 },
|
|
3301
|
+
interpretation: "Conversion into step k is `sessions[k] / sessions[k-1]`. Labels are the caller's concern — " +
|
|
3302
|
+
"the query returns indices only.",
|
|
3303
|
+
caveats: [
|
|
3304
|
+
"`steps` is required: a JSON array of at least two predicates matching on promoted columns only (`type`, `name`, `mesh`).",
|
|
3305
|
+
"Steps must happen in order within one session; a session that did them out of order does not convert.",
|
|
3306
|
+
"Predicates are pure equality on promoted columns — payload props are not queryable (ADR 0038).",
|
|
3307
|
+
"Fewer than ~50 sessions in the first step makes every downstream rate noise.",
|
|
3308
|
+
],
|
|
3309
|
+
sourceChannels: [],
|
|
3310
|
+
related: ["scene_retention", "load_bounce_funnel", "variant_leaderboard"],
|
|
3311
|
+
comparable: { primary: "sessions", direction: "up", minSample: 50 },
|
|
3312
|
+
category: "conversion",
|
|
3313
|
+
},
|
|
3314
|
+
scene_retention: {
|
|
3315
|
+
id: "scene_retention",
|
|
3316
|
+
title: "Scene-to-scene retention",
|
|
3317
|
+
description: "Directed scene→scene links weighted by how many distinct sessions made each consecutive " +
|
|
3318
|
+
"transition (#147), derived purely from the observed order of `scene_change` markers. One " +
|
|
3319
|
+
"row per link, busiest first. The zero-config level funnel.",
|
|
3320
|
+
builder: "buildSceneRetention",
|
|
3321
|
+
endpoint: { method: "GET", path: "/api/v1/scene-retention" },
|
|
3322
|
+
grain: "row",
|
|
3323
|
+
dimensions: ["scene"],
|
|
3324
|
+
filters: ["since", "until", "limit", "format"],
|
|
3325
|
+
row: z.object({ from_scene: text, to_scene: text, sessions: int }),
|
|
3326
|
+
columns: {
|
|
3327
|
+
from_scene: { description: "Scene the session moved from.", unit: "id", label: true },
|
|
3328
|
+
to_scene: { description: "Scene it moved to next.", unit: "id" },
|
|
3329
|
+
sessions: {
|
|
3330
|
+
description: "Distinct sessions that made the transition.",
|
|
3331
|
+
unit: "sessions",
|
|
3332
|
+
measure: true,
|
|
3333
|
+
},
|
|
3334
|
+
},
|
|
3335
|
+
limits: { maxRows: 10000, maxSummaryRows: 10 },
|
|
3336
|
+
interpretation: "Weights are distinct sessions, so a link reads as level-to-level retention. Compare a " +
|
|
3337
|
+
"scene's outgoing total against its incoming total to find where players stop.",
|
|
3338
|
+
caveats: [
|
|
3339
|
+
"No scene filter by design — the whole point is the cross-scene flow.",
|
|
3340
|
+
"Sessions with a single `scene_change` contribute no link (there is no 'from'), and every session's last scene has no outgoing link.",
|
|
3341
|
+
"Apps that never call `setScene(...)` emit no `scene_change` and produce an empty result.",
|
|
3342
|
+
"Fewer than ~30 sessions per link makes ordering unreliable.",
|
|
3343
|
+
],
|
|
3344
|
+
sourceChannels: ["scene_change"],
|
|
3345
|
+
related: ["funnel", "list_scenes", "load_bounce_funnel"],
|
|
3346
|
+
comparable: { primary: "sessions", direction: "up", minSample: 30 },
|
|
3347
|
+
category: "conversion",
|
|
3348
|
+
},
|
|
3349
|
+
load_bounce_funnel: {
|
|
3350
|
+
id: "load_bounce_funnel",
|
|
3351
|
+
title: "Load → bounce funnel",
|
|
3352
|
+
description: "Sessions bucketed by their initial load time, with how many bounced in each band (#152) — a " +
|
|
3353
|
+
"bounce being a session that produced no interaction at or after its first asset load. One " +
|
|
3354
|
+
"row per band. Turns 'slow loads cost you customers' into a number.",
|
|
3355
|
+
builder: "buildLoadBounceFunnel",
|
|
3356
|
+
endpoint: { method: "GET", path: "/api/v1/load-bounce" },
|
|
3357
|
+
grain: "bucket",
|
|
3358
|
+
dimensions: ["scene"],
|
|
3359
|
+
filters: ["since", "until", "scene", "bands", "format"],
|
|
3360
|
+
row: z.object({ band: int, sessions: int, bounced: int }),
|
|
3361
|
+
columns: {
|
|
3362
|
+
band: {
|
|
3363
|
+
description: "0-based band index, ordered by ascending load time.",
|
|
3364
|
+
unit: "index",
|
|
3365
|
+
label: true,
|
|
3366
|
+
axis: true,
|
|
3367
|
+
},
|
|
3368
|
+
sessions: { description: "Sessions whose initial load fell in the band.", unit: "sessions" },
|
|
3369
|
+
bounced: {
|
|
3370
|
+
description: "Of those, how many produced no interaction.",
|
|
3371
|
+
unit: "sessions",
|
|
3372
|
+
measure: true,
|
|
3373
|
+
rateOf: "sessions",
|
|
3374
|
+
},
|
|
3375
|
+
},
|
|
3376
|
+
limits: { maxRows: 17, maxSummaryRows: 17 },
|
|
3377
|
+
interpretation: "The bounce rate is `bounced / sessions`. The story is the *slope* across bands, not any one " +
|
|
3378
|
+
"band's rate — a rising slope is the load-time cost.",
|
|
3379
|
+
caveats: [
|
|
3380
|
+
"Band boundaries come from `bands` (default `1000,3000,5000`); labels are the caller's concern, and a different set makes results incomparable.",
|
|
3381
|
+
"A session's load time is the `loadMs` of its earliest `asset_load`; sessions with no `asset_load` in scope are excluded entirely.",
|
|
3382
|
+
"The engagement check is not re-bounded by `until`, so a load near the end of the window is not mis-counted as a bounce.",
|
|
3383
|
+
"Fewer than ~50 sessions in a band makes its rate noise.",
|
|
3384
|
+
],
|
|
3385
|
+
sourceChannels: ["asset_load", "pointer_click", "mesh_interaction", "camera_gesture"],
|
|
3386
|
+
related: ["funnel", "perf_churn", "compile_stalls", "scene_retention"],
|
|
3387
|
+
comparable: { primary: "bounced", direction: "down", minSample: 50 },
|
|
3388
|
+
category: "conversion",
|
|
3389
|
+
},
|
|
3390
|
+
variant_leaderboard: {
|
|
3391
|
+
id: "variant_leaderboard",
|
|
3392
|
+
title: "Variant → conversion leaderboard",
|
|
3393
|
+
description: "For a product configurator (#150): per variant — a custom event grouped by its name — how " +
|
|
3394
|
+
"often it was viewed, over how many sessions, how many of those converted, and the mean " +
|
|
3395
|
+
"dwell before the visitor switched or converted. One row per variant, ranked by views.",
|
|
3396
|
+
builder: "buildVariantLeaderboard",
|
|
3397
|
+
endpoint: { method: "GET", path: "/api/v1/variant-leaderboard" },
|
|
3398
|
+
grain: "row",
|
|
3399
|
+
dimensions: ["name", "scene", "cameraMode"],
|
|
3400
|
+
filters: ["since", "until", "scene", "cameraMode", "variant", "conversion", "limit", "format"],
|
|
3401
|
+
row: z.object({
|
|
3402
|
+
variant: text,
|
|
3403
|
+
views: int,
|
|
3404
|
+
sessions: int,
|
|
3405
|
+
conversions: int,
|
|
3406
|
+
avg_dwell_ms: num,
|
|
3407
|
+
}),
|
|
3408
|
+
columns: {
|
|
3409
|
+
variant: {
|
|
3410
|
+
description: "Variant name (the custom event's `name`).",
|
|
3411
|
+
unit: "label",
|
|
3412
|
+
label: true,
|
|
3413
|
+
},
|
|
3414
|
+
views: { description: "Matching events fired.", unit: "count", measure: true },
|
|
3415
|
+
sessions: { description: "Distinct sessions that viewed the variant.", unit: "sessions" },
|
|
3416
|
+
conversions: {
|
|
3417
|
+
description: "Sessions that fired the conversion event at or after their first view; `0` with no `conversion` predicate.",
|
|
3418
|
+
unit: "sessions",
|
|
3419
|
+
rateOf: "sessions",
|
|
3420
|
+
},
|
|
3421
|
+
avg_dwell_ms: {
|
|
3422
|
+
description: "Mean time from a view to the next boundary (a switch to a different variant, or the conversion).",
|
|
3423
|
+
unit: "ms",
|
|
3424
|
+
},
|
|
3425
|
+
},
|
|
3426
|
+
limits: { maxRows: 500, maxSummaryRows: 10 },
|
|
3427
|
+
interpretation: "The conversion rate is `conversions / sessions`. `avg_dwell_ms` is considered time: a variant " +
|
|
3428
|
+
"with high dwell and low conversion is attractive but not convincing.",
|
|
3429
|
+
caveats: [
|
|
3430
|
+
"Variants are discriminated by the custom event's promoted `name` — payload `props` are not portably queryable (ADR 0038), so configurators must encode the variant in the name.",
|
|
3431
|
+
"`conversions` is 0 unless a `conversion` predicate is supplied; do not read that as a zero conversion rate.",
|
|
3432
|
+
"A re-view of the same variant is not a dwell boundary, and views with no later boundary are excluded from the average.",
|
|
3433
|
+
"Fewer than ~50 sessions per variant makes the ranking unreliable.",
|
|
3434
|
+
],
|
|
3435
|
+
sourceChannels: ["custom"],
|
|
3436
|
+
related: ["funnel", "top_meshes", "scene_retention"],
|
|
3437
|
+
comparable: { primary: "conversions", direction: "up", minSample: 50 },
|
|
3438
|
+
category: "conversion",
|
|
3439
|
+
},
|
|
3440
|
+
};
|
|
3441
|
+
/** Every registry id, in declaration order. */
|
|
3442
|
+
export const METRIC_IDS = Object.keys(METRIC_REGISTRY);
|
|
3443
|
+
/** Narrow an arbitrary string to a {@link MetricId}. */
|
|
3444
|
+
export function isMetricId(value) {
|
|
3445
|
+
return Object.prototype.hasOwnProperty.call(METRIC_REGISTRY, value);
|
|
3446
|
+
}
|
|
3447
|
+
/** Look a metric up by id, or `undefined` when the id is unknown. */
|
|
3448
|
+
export function getMetric(id) {
|
|
3449
|
+
return isMetricId(id) ? METRIC_REGISTRY[id] : undefined;
|
|
3450
|
+
}
|
|
3451
|
+
/** Every registry entry as a plain array, typed as the shared interface. */
|
|
3452
|
+
export function allMetrics() {
|
|
3453
|
+
return METRIC_IDS.map((id) => METRIC_REGISTRY[id]);
|
|
3454
|
+
}
|
|
3455
|
+
/**
|
|
3456
|
+
* Reverse lookup: aggregation builder name → the metric that claims it.
|
|
3457
|
+
*
|
|
3458
|
+
* `MetricDefinition.builder` is the forward direction (a metric names its
|
|
3459
|
+
* builder); this is the direction the *store edge* needs — a `QuerySpec` knows
|
|
3460
|
+
* which aggregation produced it, and numeric coercion has to find that
|
|
3461
|
+
* aggregation's row schema (ADR 0051 §2). Built once, at module load: the
|
|
3462
|
+
* compile-time coverage guard below makes the mapping total over every exported
|
|
3463
|
+
* `build*`, and every builder is claimed by exactly one metric (asserted in
|
|
3464
|
+
* `src/__tests__/registry.test.ts`).
|
|
3465
|
+
*/
|
|
3466
|
+
export const METRIC_BY_BUILDER = new Map(METRIC_IDS.map((id) => METRIC_REGISTRY[id])
|
|
3467
|
+
.filter((metric) => Boolean(metric.builder))
|
|
3468
|
+
.map((metric) => [metric.builder, metric]));
|
|
3469
|
+
/**
|
|
3470
|
+
* The registry entry that claims a given aggregation builder, or `undefined` for
|
|
3471
|
+
* the (currently empty) set of unclaimed builders.
|
|
3472
|
+
*/
|
|
3473
|
+
export function metricForBuilder(builder) {
|
|
3474
|
+
return METRIC_BY_BUILDER.get(builder);
|
|
3475
|
+
}
|
|
3476
|
+
/**
|
|
3477
|
+
* A **resource** entry is a store read rather than an aggregation: it has no
|
|
3478
|
+
* `build*` builder, no time range, and is included so the generated agent
|
|
3479
|
+
* catalog stays a superset of the hand-written one (design sketch §A.4).
|
|
3480
|
+
*/
|
|
3481
|
+
export function isResourceMetric(metric) {
|
|
3482
|
+
return metric.builder === undefined;
|
|
3483
|
+
}
|
|
3484
|
+
//# sourceMappingURL=registry.js.map
|