@uptimizr/collector-server 1.1.1 → 2.0.1
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 +193 -0
- package/README.md +112 -8
- package/dist/__tests__/support/registryRequests.d.ts +47 -0
- package/dist/__tests__/support/registryRequests.d.ts.map +1 -0
- package/dist/__tests__/support/registryRequests.js +93 -0
- package/dist/__tests__/support/registryRequests.js.map +1 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +34 -3
- package/dist/app.js.map +1 -1
- package/dist/audit.d.ts +22 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +96 -0
- package/dist/audit.js.map +1 -0
- package/dist/auth.d.ts +64 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +71 -0
- package/dist/auth.js.map +1 -0
- package/dist/cli.js +279 -0
- package/dist/cli.js.map +1 -1
- package/dist/cliStore.d.ts +20 -2
- package/dist/cliStore.d.ts.map +1 -1
- package/dist/cliStore.js +20 -8
- package/dist/cliStore.js.map +1 -1
- package/dist/clickhouseStore.d.ts.map +1 -1
- package/dist/clickhouseStore.js +7 -1
- package/dist/clickhouseStore.js.map +1 -1
- package/dist/config.d.ts +13 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/duckdbStore.d.ts.map +1 -1
- package/dist/duckdbStore.js +7 -1
- package/dist/duckdbStore.js.map +1 -1
- package/dist/liveToken.d.ts +14 -5
- package/dist/liveToken.d.ts.map +1 -1
- package/dist/liveToken.js +32 -6
- package/dist/liveToken.js.map +1 -1
- package/dist/memoryStore.d.ts +8 -3
- package/dist/memoryStore.d.ts.map +1 -1
- package/dist/memoryStore.js +57 -2
- package/dist/memoryStore.js.map +1 -1
- package/dist/mssqlStore.d.ts.map +1 -1
- package/dist/mssqlStore.js +7 -1
- package/dist/mssqlStore.js.map +1 -1
- package/dist/postgresStore.d.ts.map +1 -1
- package/dist/postgresStore.js +7 -1
- package/dist/postgresStore.js.map +1 -1
- package/dist/routes/live.d.ts.map +1 -1
- package/dist/routes/live.js +31 -26
- package/dist/routes/live.js.map +1 -1
- package/dist/routes/meta.d.ts +67 -0
- package/dist/routes/meta.d.ts.map +1 -0
- package/dist/routes/meta.js +436 -0
- package/dist/routes/meta.js.map +1 -0
- package/dist/routes/query.d.ts.map +1 -1
- package/dist/routes/query.js +805 -108
- package/dist/routes/query.js.map +1 -1
- package/dist/store.d.ts +32 -5
- package/dist/store.d.ts.map +1 -1
- package/llms.txt +38 -0
- package/package.json +11 -7
package/dist/routes/query.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { Readable } from "node:stream";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { sceneProxySchema, funnelStepsSchema, funnelStepSchema } from "@uptimizr/schema";
|
|
4
|
-
import { defaultCellSizeForBounds } from "@uptimizr/db";
|
|
3
|
+
import { sceneProxySchema, sceneRegionsSchema, regionIdSchema, funnelStepsSchema, funnelStepSchema, } from "@uptimizr/schema";
|
|
4
|
+
import { defaultCellSizeForBounds, resultEnvelopeSchema, resultFormatSchema, summarizeRows, tableResult, } from "@uptimizr/db";
|
|
5
|
+
import { allMetrics } from "@uptimizr/metrics";
|
|
6
|
+
import { requireCapability } from "../auth.js";
|
|
5
7
|
/** Developer-assigned scene/area filter (ADR 0010). */
|
|
6
8
|
const sceneFilter = z
|
|
7
9
|
.string()
|
|
@@ -20,6 +22,19 @@ const sessionFilter = z.string().min(1).max(128).optional();
|
|
|
20
22
|
* {@link cameraTypeForMode} before it reaches the aggregation layer.
|
|
21
23
|
*/
|
|
22
24
|
const cameraModeFilter = z.enum(["viewer", "first-person"]).optional();
|
|
25
|
+
/**
|
|
26
|
+
* Result envelope (ADR 0051 §2, design sketch §B.1). Accepted by every
|
|
27
|
+
* registry-served aggregate endpoint, and declared as the `format` filter on
|
|
28
|
+
* each of their registry entries — the registry-route contract test asserts the
|
|
29
|
+
* two stay in step.
|
|
30
|
+
*
|
|
31
|
+
* Omitted or `full` means the response is the bare rows, byte-for-byte what the
|
|
32
|
+
* endpoint has always returned: the dashboard never sends `format`, so it cannot
|
|
33
|
+
* be affected by this. `table` and `summary` are shaped by a single
|
|
34
|
+
* `preSerialization` hook at the bottom of this plugin, so no handler has to
|
|
35
|
+
* know the envelope exists.
|
|
36
|
+
*/
|
|
37
|
+
const formatFilter = resultFormatSchema.optional();
|
|
23
38
|
/** Map the dashboard camera-mode toggle to the stored `cameraType` value. */
|
|
24
39
|
function cameraTypeForMode(mode) {
|
|
25
40
|
if (mode === "first-person")
|
|
@@ -29,10 +44,19 @@ function cameraTypeForMode(mode) {
|
|
|
29
44
|
return undefined;
|
|
30
45
|
}
|
|
31
46
|
/**
|
|
32
|
-
* World-space region filter (ADR 0040 §4)
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
47
|
+
* World-space region filter (ADR 0040 §4, extended by ADR 0051 §2). Accepts
|
|
48
|
+
* either form:
|
|
49
|
+
*
|
|
50
|
+
* - a `minX,minY,minZ,maxX,maxY,maxZ` comma list naming an ad-hoc axis-aligned
|
|
51
|
+
* box to drill into (validated to six finite numbers with `max >= min` on
|
|
52
|
+
* every axis) — the original drill-down the dashboard sends; or
|
|
53
|
+
* - the **id of a registered scene region** (`region=checkout-counter`), the
|
|
54
|
+
* shared vocabulary humans and agents use to talk about places. It is
|
|
55
|
+
* resolved server-side to the stored bounds by {@link resolveRegionFilter},
|
|
56
|
+
* so the aggregation layer only ever sees a {@link WorldAabb}.
|
|
57
|
+
*
|
|
58
|
+
* The two are unambiguous: a box always contains commas, a region id never can.
|
|
59
|
+
* Omit for the whole scene.
|
|
36
60
|
*/
|
|
37
61
|
const regionFilter = z
|
|
38
62
|
.string()
|
|
@@ -40,6 +64,18 @@ const regionFilter = z
|
|
|
40
64
|
.transform((val, ctx) => {
|
|
41
65
|
if (val == null)
|
|
42
66
|
return undefined;
|
|
67
|
+
if (!val.includes(",")) {
|
|
68
|
+
const id = regionIdSchema.safeParse(val);
|
|
69
|
+
if (!id.success) {
|
|
70
|
+
ctx.addIssue({
|
|
71
|
+
code: z.ZodIssueCode.custom,
|
|
72
|
+
message: "region must be a registered region id (1-64 chars of [A-Za-z0-9._:-]) " +
|
|
73
|
+
"or 6 comma-separated finite numbers: minX,minY,minZ,maxX,maxY,maxZ",
|
|
74
|
+
});
|
|
75
|
+
return z.NEVER;
|
|
76
|
+
}
|
|
77
|
+
return { kind: "id", id: id.data };
|
|
78
|
+
}
|
|
43
79
|
const parts = val.split(",").map((s) => Number(s.trim()));
|
|
44
80
|
if (parts.length !== 6 || parts.some((n) => !Number.isFinite(n))) {
|
|
45
81
|
ctx.addIssue({
|
|
@@ -56,8 +92,38 @@ const regionFilter = z
|
|
|
56
92
|
});
|
|
57
93
|
return z.NEVER;
|
|
58
94
|
}
|
|
59
|
-
return [minX, minY, minZ, maxX, maxY, maxZ];
|
|
95
|
+
return { kind: "box", bounds: [minX, minY, minZ, maxX, maxY, maxZ] };
|
|
60
96
|
});
|
|
97
|
+
function isRegionError(value) {
|
|
98
|
+
return typeof value === "object" && value !== null && "error" in value;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Resolve a parsed `region` filter to the world box the aggregations take.
|
|
102
|
+
*
|
|
103
|
+
* A box passes straight through. A **region id** is looked up in the scene
|
|
104
|
+
* registry: regions are keyed by `(project, scene, region)`, so the request must
|
|
105
|
+
* also name the `scene` the region belongs to, and an id the project has never
|
|
106
|
+
* registered is a client error (`400`) rather than a silently unfiltered query —
|
|
107
|
+
* an agent must never be told "no hits in the entrance" when it merely misspelt
|
|
108
|
+
* the region. Returns the box, `undefined` when no filter was given, or an error
|
|
109
|
+
* object the caller turns into a `400`.
|
|
110
|
+
*/
|
|
111
|
+
async function resolveRegionFilter(store, projectId, scene, filter) {
|
|
112
|
+
if (filter == null)
|
|
113
|
+
return undefined;
|
|
114
|
+
if (filter.kind === "box")
|
|
115
|
+
return filter.bounds;
|
|
116
|
+
if (scene == null) {
|
|
117
|
+
return {
|
|
118
|
+
error: "region names a registered region, so the request must also pass a scene",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const regions = await store.getSceneRegions(projectId, scene);
|
|
122
|
+
const match = regions.find((r) => r.regionId === filter.id);
|
|
123
|
+
if (!match)
|
|
124
|
+
return { error: `unknown region "${filter.id}" in scene "${scene}"` };
|
|
125
|
+
return match.bounds;
|
|
126
|
+
}
|
|
61
127
|
/**
|
|
62
128
|
* Resolve the effective voxel `cellSize` for a spatial heatmap (ADR 0040 §1).
|
|
63
129
|
* When the caller pins a `cellSize` it wins. Otherwise the resolution is driven
|
|
@@ -66,7 +132,26 @@ const regionFilter = z
|
|
|
66
132
|
* legible instead of dissolving into a few coarse blocks. Returns `undefined`
|
|
67
133
|
* when no extent is known, letting the aggregation fall back to its fixed default.
|
|
68
134
|
*/
|
|
69
|
-
async function resolveSpatialCellSize(store, projectId, opts) {
|
|
135
|
+
async function resolveSpatialCellSize(store, projectId, opts, request) {
|
|
136
|
+
const resolved = await computeSpatialCellSize(store, projectId, opts);
|
|
137
|
+
if (request != null && resolved != null)
|
|
138
|
+
resolvedCellSizes.set(request, resolved);
|
|
139
|
+
return resolved;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Cell sizes a spatial route resolved for the request in flight.
|
|
143
|
+
*
|
|
144
|
+
* The `format=summary` hook runs after the handler and needs the **effective**
|
|
145
|
+
* cell size to turn a cluster's grid extent into a world-space `region` drill
|
|
146
|
+
* hint (ADR 0040 §1/§4). The handler is the only place that knows it — it may
|
|
147
|
+
* have been derived from the scene's registered bounds — so it is recorded here
|
|
148
|
+
* on the way past. A `WeakMap` rather than a request decorator keeps this an
|
|
149
|
+
* implementation detail of this plugin and cannot leak: the entry dies with the
|
|
150
|
+
* request object.
|
|
151
|
+
*/
|
|
152
|
+
const resolvedCellSizes = new WeakMap();
|
|
153
|
+
/** The resolution rule itself; see {@link resolveSpatialCellSize}. */
|
|
154
|
+
async function computeSpatialCellSize(store, projectId, opts) {
|
|
70
155
|
if (opts.cellSize != null)
|
|
71
156
|
return opts.cellSize;
|
|
72
157
|
if (opts.region != null)
|
|
@@ -84,6 +169,7 @@ const rangeQuery = z.object({
|
|
|
84
169
|
until: z.coerce.number().int().optional(),
|
|
85
170
|
bins: z.coerce.number().int().positive().max(500).optional(),
|
|
86
171
|
limit: z.coerce.number().int().positive().max(1000).optional(),
|
|
172
|
+
format: formatFilter,
|
|
87
173
|
});
|
|
88
174
|
/** Sessions-list params: a time range, result cap, and camera-mode filter. */
|
|
89
175
|
const sessionsQueryParams = rangeQuery.extend({ cameraMode: cameraModeFilter });
|
|
@@ -153,6 +239,7 @@ const worldHeatmapQueryParams = z.object({
|
|
|
153
239
|
source: sourceFilter,
|
|
154
240
|
cameraMode: cameraModeFilter,
|
|
155
241
|
region: regionFilter,
|
|
242
|
+
format: formatFilter,
|
|
156
243
|
});
|
|
157
244
|
/** World heatmap totals params (ADR 0040 §3): same filters as the world heatmap, no `limit`. */
|
|
158
245
|
const worldStatsQueryParams = z.object({
|
|
@@ -163,6 +250,7 @@ const worldStatsQueryParams = z.object({
|
|
|
163
250
|
source: sourceFilter,
|
|
164
251
|
cameraMode: cameraModeFilter,
|
|
165
252
|
region: regionFilter,
|
|
253
|
+
format: formatFilter,
|
|
166
254
|
});
|
|
167
255
|
/**
|
|
168
256
|
* Boundary-touch heatmap params (#157, ADR 0048): a positive voxel `cellSize`
|
|
@@ -178,6 +266,7 @@ const boundaryHeatmapQueryParams = z.object({
|
|
|
178
266
|
scene: sceneFilter,
|
|
179
267
|
session: sessionFilter,
|
|
180
268
|
region: regionFilter,
|
|
269
|
+
format: formatFilter,
|
|
181
270
|
});
|
|
182
271
|
/** Boundary-touch heatmap totals params (ADR 0040 §3): boundary filters, no `limit`. */
|
|
183
272
|
const boundaryStatsQueryParams = z.object({
|
|
@@ -187,6 +276,7 @@ const boundaryStatsQueryParams = z.object({
|
|
|
187
276
|
scene: sceneFilter,
|
|
188
277
|
session: sessionFilter,
|
|
189
278
|
region: regionFilter,
|
|
279
|
+
format: formatFilter,
|
|
190
280
|
});
|
|
191
281
|
/**
|
|
192
282
|
* Gaze heatmap params (ADR 0030): a positive voxel `cellSize` plus scene/session
|
|
@@ -202,6 +292,7 @@ const gazeHeatmapQueryParams = z.object({
|
|
|
202
292
|
session: sessionFilter,
|
|
203
293
|
cameraMode: cameraModeFilter,
|
|
204
294
|
region: regionFilter,
|
|
295
|
+
format: formatFilter,
|
|
205
296
|
});
|
|
206
297
|
/** Gaze heatmap totals params (ADR 0040 §3): same filters as the gaze heatmap, no `limit`. */
|
|
207
298
|
const gazeStatsQueryParams = z.object({
|
|
@@ -212,6 +303,7 @@ const gazeStatsQueryParams = z.object({
|
|
|
212
303
|
session: sessionFilter,
|
|
213
304
|
cameraMode: cameraModeFilter,
|
|
214
305
|
region: regionFilter,
|
|
306
|
+
format: formatFilter,
|
|
215
307
|
});
|
|
216
308
|
/**
|
|
217
309
|
* Floor-plan camera-position heatmap params (ADR 0026): a ground-plane bin
|
|
@@ -226,6 +318,7 @@ const cameraPositionQueryParams = z.object({
|
|
|
226
318
|
session: sessionFilter,
|
|
227
319
|
cameraMode: cameraModeFilter,
|
|
228
320
|
region: regionFilter,
|
|
321
|
+
format: formatFilter,
|
|
229
322
|
});
|
|
230
323
|
/**
|
|
231
324
|
* Aggregate desire-line params (#73, ADR 0037): a ground-bin `cellSize` plus
|
|
@@ -239,6 +332,7 @@ const aggregatePathQueryParams = z.object({
|
|
|
239
332
|
limit: z.coerce.number().int().positive().max(50000).optional(),
|
|
240
333
|
scene: sceneFilter,
|
|
241
334
|
cameraMode: cameraModeFilter,
|
|
335
|
+
format: formatFilter,
|
|
242
336
|
});
|
|
243
337
|
/** Session-trajectory params: a time range, scene filter, and point cap. */
|
|
244
338
|
const trajectoryQueryParams = z.object({
|
|
@@ -246,6 +340,7 @@ const trajectoryQueryParams = z.object({
|
|
|
246
340
|
until: z.coerce.number().int().optional(),
|
|
247
341
|
limit: z.coerce.number().int().positive().max(10000).optional(),
|
|
248
342
|
scene: sceneFilter,
|
|
343
|
+
format: formatFilter,
|
|
249
344
|
});
|
|
250
345
|
/** Path param for a single session's trajectory. */
|
|
251
346
|
const sessionPathParams = z.object({
|
|
@@ -260,6 +355,7 @@ const clickRayQueryParams = z.object({
|
|
|
260
355
|
scene: sceneFilter,
|
|
261
356
|
source: sourceFilter,
|
|
262
357
|
session: sessionFilter,
|
|
358
|
+
format: formatFilter,
|
|
263
359
|
});
|
|
264
360
|
/**
|
|
265
361
|
* Flow params: camera-direction bins joined to clicked meshes (§7.5), plus the
|
|
@@ -281,6 +377,7 @@ const scenesQueryParams = z.object({
|
|
|
281
377
|
since: z.coerce.number().int().optional(),
|
|
282
378
|
until: z.coerce.number().int().optional(),
|
|
283
379
|
limit: z.coerce.number().int().positive().max(1000).optional(),
|
|
380
|
+
format: formatFilter,
|
|
284
381
|
});
|
|
285
382
|
/** Time-series params: range + scene + optional event-type filter + bucket interval (seconds). */
|
|
286
383
|
const timeseriesQueryParams = z.object({
|
|
@@ -292,12 +389,14 @@ const timeseriesQueryParams = z.object({
|
|
|
292
389
|
.string()
|
|
293
390
|
.regex(/^[a-z_]{1,40}$/)
|
|
294
391
|
.optional(),
|
|
392
|
+
format: formatFilter,
|
|
295
393
|
});
|
|
296
394
|
/** Event-type-counts params: range + optional scene filter. */
|
|
297
395
|
const eventCountsQueryParams = z.object({
|
|
298
396
|
since: z.coerce.number().int().optional(),
|
|
299
397
|
until: z.coerce.number().int().optional(),
|
|
300
398
|
scene: sceneFilter,
|
|
399
|
+
format: formatFilter,
|
|
301
400
|
});
|
|
302
401
|
/**
|
|
303
402
|
* Funnel params (#78, ADR 0038): a time range, optional scene/camera-mode scope,
|
|
@@ -312,6 +411,7 @@ const funnelQueryParams = z.object({
|
|
|
312
411
|
scene: sceneFilter,
|
|
313
412
|
cameraMode: cameraModeFilter,
|
|
314
413
|
steps: z.string().min(1).max(8192),
|
|
414
|
+
format: formatFilter,
|
|
315
415
|
});
|
|
316
416
|
/**
|
|
317
417
|
* Scene-retention params (#147): a time range plus an optional result cap. No
|
|
@@ -322,6 +422,7 @@ const sceneRetentionQueryParams = z.object({
|
|
|
322
422
|
since: z.coerce.number().int().optional(),
|
|
323
423
|
until: z.coerce.number().int().optional(),
|
|
324
424
|
limit: z.coerce.number().int().positive().max(10000).optional(),
|
|
425
|
+
format: formatFilter,
|
|
325
426
|
});
|
|
326
427
|
/**
|
|
327
428
|
* Load → bounce funnel params (#152): a time range, optional scene scope, and
|
|
@@ -353,6 +454,7 @@ const loadBounceQueryParams = z.object({
|
|
|
353
454
|
}
|
|
354
455
|
return parts;
|
|
355
456
|
}),
|
|
457
|
+
format: formatFilter,
|
|
356
458
|
});
|
|
357
459
|
/**
|
|
358
460
|
* Variant-leaderboard params (#150): a time range, optional scene/camera-mode
|
|
@@ -370,6 +472,7 @@ const variantLeaderboardQueryParams = z.object({
|
|
|
370
472
|
variant: z.string().min(1).max(2048).optional(),
|
|
371
473
|
conversion: z.string().min(1).max(2048).optional(),
|
|
372
474
|
limit: z.coerce.number().int().positive().max(500).optional(),
|
|
475
|
+
format: formatFilter,
|
|
373
476
|
});
|
|
374
477
|
/** Scene-coverage params: voxel `cellSize` + scene/session filters + result cap. */
|
|
375
478
|
const coverageQueryParams = z.object({
|
|
@@ -379,6 +482,7 @@ const coverageQueryParams = z.object({
|
|
|
379
482
|
limit: z.coerce.number().int().positive().max(10000).optional(),
|
|
380
483
|
scene: sceneFilter,
|
|
381
484
|
session: sessionFilter,
|
|
485
|
+
format: formatFilter,
|
|
382
486
|
});
|
|
383
487
|
/**
|
|
384
488
|
* Spatial FPS heatmap params (#145): voxel `cellSize` + scene/session filters + a
|
|
@@ -402,6 +506,7 @@ const errorHeatmapQueryParams = z.object({
|
|
|
402
506
|
severity: z.string().min(1).max(64).optional(),
|
|
403
507
|
category: z.string().min(1).max(64).optional(),
|
|
404
508
|
errorKind: z.string().min(1).max(64).optional(),
|
|
509
|
+
format: formatFilter,
|
|
405
510
|
});
|
|
406
511
|
/** Camera-distance params: reference `center` (3 coords) + `bucketSize` + filters. */
|
|
407
512
|
const cameraDistanceQueryParams = z.object({
|
|
@@ -414,6 +519,7 @@ const cameraDistanceQueryParams = z.object({
|
|
|
414
519
|
limit: z.coerce.number().int().positive().max(1000).optional(),
|
|
415
520
|
scene: sceneFilter,
|
|
416
521
|
session: sessionFilter,
|
|
522
|
+
format: formatFilter,
|
|
417
523
|
});
|
|
418
524
|
/** Navigation-stats params: idle/active `moveThreshold` + scene/session filters. */
|
|
419
525
|
const navigationQueryParams = z.object({
|
|
@@ -423,6 +529,7 @@ const navigationQueryParams = z.object({
|
|
|
423
529
|
limit: z.coerce.number().int().positive().max(1000).optional(),
|
|
424
530
|
scene: sceneFilter,
|
|
425
531
|
session: sessionFilter,
|
|
532
|
+
format: formatFilter,
|
|
426
533
|
});
|
|
427
534
|
/** Backtrack-ratio params: coarse-grid `cellSize` + scene/session filters. */
|
|
428
535
|
const backtrackQueryParams = z.object({
|
|
@@ -432,6 +539,7 @@ const backtrackQueryParams = z.object({
|
|
|
432
539
|
limit: z.coerce.number().int().positive().max(1000).optional(),
|
|
433
540
|
scene: sceneFilter,
|
|
434
541
|
session: sessionFilter,
|
|
542
|
+
format: formatFilter,
|
|
435
543
|
});
|
|
436
544
|
/** XR rotation-rate params: `rapidTurn` (rad) threshold + scene/session filters. */
|
|
437
545
|
const xrRotationQueryParams = z.object({
|
|
@@ -441,6 +549,7 @@ const xrRotationQueryParams = z.object({
|
|
|
441
549
|
limit: z.coerce.number().int().positive().max(1000).optional(),
|
|
442
550
|
scene: sceneFilter,
|
|
443
551
|
session: sessionFilter,
|
|
552
|
+
format: formatFilter,
|
|
444
553
|
});
|
|
445
554
|
/** Path param for a single scene's representation. */
|
|
446
555
|
const sceneParams = z.object({
|
|
@@ -451,43 +560,251 @@ const putRepresentationBody = z.object({
|
|
|
451
560
|
proxy: sceneProxySchema,
|
|
452
561
|
label: z.string().max(200).optional(),
|
|
453
562
|
});
|
|
563
|
+
// --- Response schemas, from the metric registry ---------------------------
|
|
564
|
+
//
|
|
565
|
+
// Every canned read endpoint is a registry metric (ADR 0051 §1), and the registry
|
|
566
|
+
// already declares its output `row` — the same schema the agent tool catalog and
|
|
567
|
+
// the generated OpenAPI document are derived from. Attaching it here as the 200
|
|
568
|
+
// response schema makes the Zod type provider serialise through it, so the shape
|
|
569
|
+
// the docs promise and the shape the wire carries cannot drift apart.
|
|
570
|
+
//
|
|
571
|
+
// Two consequences worth knowing before adding a route:
|
|
572
|
+
//
|
|
573
|
+
// - `z.object()` **strips** keys it does not declare, so a column a handler
|
|
574
|
+
// returns but the registry does not describe would vanish. The test
|
|
575
|
+
// `__tests__/queryResponseSchemas.test.ts` calls every one of these endpoints
|
|
576
|
+
// against a seeded DuckDB store and asserts the serialised body is deep-equal
|
|
577
|
+
// to what the store produced, so a missing declaration fails the build rather
|
|
578
|
+
// than a dashboard panel.
|
|
579
|
+
// - Serialisation is strict, so a `null` in a column the registry declares
|
|
580
|
+
// non-nullable is a 500. The same test also runs every endpoint against an
|
|
581
|
+
// **empty** project, which is where SQL's "aggregate over no rows is NULL"
|
|
582
|
+
// shows up.
|
|
583
|
+
//
|
|
584
|
+
// Since ADR 0051 §2 each schema is the **union** of the three `format` envelopes
|
|
585
|
+
// (`resultEnvelopeSchema`), with the untouched `full` shape first — so a request
|
|
586
|
+
// that does not ask for an envelope is parsed by exactly the schema it was
|
|
587
|
+
// parsed by before, and its bytes cannot drift.
|
|
588
|
+
/** Registry metrics indexed by the collector path that serves them. */
|
|
589
|
+
const METRIC_BY_PATH = new Map(allMetrics()
|
|
590
|
+
.filter((metric) => Boolean(metric.endpoint))
|
|
591
|
+
.map((metric) => [metric.endpoint.path, metric]));
|
|
592
|
+
/** The registry row schema for the metric served on `path`. */
|
|
593
|
+
function rowFor(path) {
|
|
594
|
+
const metric = METRIC_BY_PATH.get(path);
|
|
595
|
+
if (!metric) {
|
|
596
|
+
// Startup-time failure: a route claiming to serve a metric must have one.
|
|
597
|
+
throw new Error(`no registry metric is registered for ${path}`);
|
|
598
|
+
}
|
|
599
|
+
return metric.row;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* 200 response schema for a metric that returns a list of rows.
|
|
603
|
+
*
|
|
604
|
+
* Typed as `z.ZodType<unknown[]>` rather than `z.ZodArray<z.ZodObject>`: a bare
|
|
605
|
+
* `ZodObject` infers `Record<string, unknown>`, and the hand-written row
|
|
606
|
+
* *interfaces* in `@uptimizr/db` (`MeshCountRow`, …) have no index signature, so
|
|
607
|
+
* the provider would reject every handler that returns one. Widening keeps the
|
|
608
|
+
* runtime schema — which is what serialises — exactly as declared; the
|
|
609
|
+
* handler-shape check is done for real, against real rows, by
|
|
610
|
+
* `__tests__/queryResponseSchemas.test.ts`.
|
|
611
|
+
*/
|
|
612
|
+
function rowsFor(path) {
|
|
613
|
+
const row = rowFor(path);
|
|
614
|
+
return resultEnvelopeSchema(z.array(row), row);
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* 200 response schema for the three spatial `stats` routes, which return the metric's
|
|
618
|
+
* single row plus the **effective** `cellSize` the collector resolved for the
|
|
619
|
+
* request (ADR 0040 §1) so the caller can label its own grid. The echo is a
|
|
620
|
+
* property of the route, not of the aggregation, so it is added here rather than
|
|
621
|
+
* put in the registry row.
|
|
622
|
+
*/
|
|
623
|
+
function statsRowFor(path) {
|
|
624
|
+
const row = rowFor(path).extend({ cellSize: z.number() });
|
|
625
|
+
return resultEnvelopeSchema(row, row);
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* 200 response schema for the two **resource** metrics, which return a single
|
|
629
|
+
* record rather than a list of aggregate rows. Widened for the same reason as
|
|
630
|
+
* {@link rowsFor}: `SessionMeta` / `SceneRepresentation` are declared interfaces
|
|
631
|
+
* with no index signature.
|
|
632
|
+
*/
|
|
633
|
+
function singleRowFor(path) {
|
|
634
|
+
return rowFor(path);
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Filters carried in the path rather than the querystring, keyed by the filter
|
|
638
|
+
* ids the registry declares for them (`endpoint.pathParams`, in `:` order) — so
|
|
639
|
+
* a session trajectory's `:sessionId` is echoed back as `session`, the name the
|
|
640
|
+
* rest of the query surface uses.
|
|
641
|
+
*/
|
|
642
|
+
function pathFilters(metric, request) {
|
|
643
|
+
const params = (request.params ?? {});
|
|
644
|
+
const segments = (metric.endpoint?.path ?? "")
|
|
645
|
+
.split("/")
|
|
646
|
+
.filter((segment) => segment.startsWith(":"))
|
|
647
|
+
.map((segment) => segment.slice(1));
|
|
648
|
+
const ids = metric.endpoint?.pathParams ?? [];
|
|
649
|
+
const filters = {};
|
|
650
|
+
segments.forEach((segment, index) => {
|
|
651
|
+
const id = ids[index];
|
|
652
|
+
const value = params[segment];
|
|
653
|
+
if (id != null && value != null)
|
|
654
|
+
filters[id] = value;
|
|
655
|
+
});
|
|
656
|
+
return filters;
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Everything the summariser needs to know about the request that produced the
|
|
660
|
+
* rows: the window and filters to echo, the row cap that was in force (so
|
|
661
|
+
* `truncated` is honest), and the effective spatial `cellSize` — the resolved
|
|
662
|
+
* one where a handler derived it from the scene bounds, else whatever the caller
|
|
663
|
+
* pinned.
|
|
664
|
+
*/
|
|
665
|
+
function summaryContextFor(metric, request) {
|
|
666
|
+
const query = (request.query ?? {});
|
|
667
|
+
const cellSize = resolvedCellSizes.get(request) ?? query.cellSize;
|
|
668
|
+
return {
|
|
669
|
+
range: {
|
|
670
|
+
since: typeof query.since === "number" ? query.since : null,
|
|
671
|
+
until: typeof query.until === "number" ? query.until : null,
|
|
672
|
+
},
|
|
673
|
+
filters: { ...query, ...pathFilters(metric, request) },
|
|
674
|
+
limit: typeof query.limit === "number" ? query.limit : undefined,
|
|
675
|
+
cellSize: typeof cellSize === "number" ? cellSize : undefined,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
/** `{ error }` body the read routes send for a miss. Declared so `reply.code(404)` stays typed. */
|
|
679
|
+
const notFoundResponse = z.object({ error: z.string() });
|
|
680
|
+
/**
|
|
681
|
+
* `{ error, details? }` body sent when a JSON-encoded query parameter (a funnel
|
|
682
|
+
* or variant predicate) fails its schema. Declared alongside the 200 so
|
|
683
|
+
* `reply.code(400)` stays typed once a route has a response schema.
|
|
684
|
+
*/
|
|
685
|
+
const badRequestResponse = z.object({ error: z.string(), details: z.unknown().optional() });
|
|
686
|
+
/**
|
|
687
|
+
* Body for declaring a scene's regions (ADR 0051 §2): the scene's whole set.
|
|
688
|
+
* The write replaces what is stored, so an empty array clears the scene — the
|
|
689
|
+
* caller declares what the regions *are* rather than patching them one by one.
|
|
690
|
+
* `sceneRegionsSchema` bounds the count, the label/description lengths, the box
|
|
691
|
+
* shape, and rejects duplicate ids at the edge.
|
|
692
|
+
*/
|
|
693
|
+
const putRegionsBody = z.object({
|
|
694
|
+
regions: sceneRegionsSchema,
|
|
695
|
+
});
|
|
454
696
|
/**
|
|
455
697
|
* Authenticate a read request with a project API key (`x-api-key`). Returns the
|
|
456
|
-
* resolved project id, or sends a 401 and returns `null`. Reads are always
|
|
457
|
-
* to the authenticated project — any client-supplied project id is
|
|
698
|
+
* resolved project id, or sends a 401/403 and returns `null`. Reads are always
|
|
699
|
+
* scoped to the authenticated project — any client-supplied project id is
|
|
700
|
+
* ignored — and require a `query`-capable key.
|
|
458
701
|
*/
|
|
459
702
|
async function authProject(request, reply, store) {
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
await reply.code(401).send({ error: "missing api key" });
|
|
463
|
-
return null;
|
|
464
|
-
}
|
|
465
|
-
const projectId = await store.resolveApiKey(key);
|
|
466
|
-
if (!projectId) {
|
|
467
|
-
await reply.code(401).send({ error: "invalid api key" });
|
|
468
|
-
return null;
|
|
469
|
-
}
|
|
470
|
-
// Read endpoints require a `query`-capable key (ingest-only keys cannot read).
|
|
471
|
-
if (projectId.capability !== "query") {
|
|
472
|
-
await reply.code(403).send({ error: "api key not permitted to read" });
|
|
473
|
-
return null;
|
|
474
|
-
}
|
|
475
|
-
return projectId.projectId;
|
|
703
|
+
const resolved = await requireCapability(request, reply, store, "query");
|
|
704
|
+
return resolved?.projectId ?? null;
|
|
476
705
|
}
|
|
706
|
+
/**
|
|
707
|
+
* Audit-log query params: an optional epoch-ms range and a bounded row cap.
|
|
708
|
+
*/
|
|
709
|
+
const auditQueryParams = z.object({
|
|
710
|
+
since: z.coerce.number().int().optional(),
|
|
711
|
+
until: z.coerce.number().int().optional(),
|
|
712
|
+
limit: z.coerce.number().int().positive().max(1000).optional(),
|
|
713
|
+
});
|
|
477
714
|
/**
|
|
478
715
|
* Query API. All aggregations are computed at query time (v1). Every route is
|
|
479
716
|
* scoped to the authenticated project.
|
|
480
717
|
*/
|
|
481
718
|
export const queryRoutes = async (app, { store, config }) => {
|
|
482
719
|
const r = app.withTypeProvider();
|
|
483
|
-
|
|
720
|
+
// --- format=table | summary (ADR 0051 §2, design sketch §B.1) ------------
|
|
721
|
+
//
|
|
722
|
+
// The whole envelope feature lives in this one hook. It runs after a handler
|
|
723
|
+
// has produced its rows and before the response schema serialises them, which
|
|
724
|
+
// is the only place that can see *every* registry endpoint's result without
|
|
725
|
+
// any of the 67 handlers knowing the feature exists — exactly the "keep the
|
|
726
|
+
// Fastify layer thin" rule (ADR 0005). The shaping itself is
|
|
727
|
+
// `@uptimizr/db`'s: `tableResult` / `summarizeRows` are pure, registry-driven
|
|
728
|
+
// functions with no store or request in sight.
|
|
729
|
+
//
|
|
730
|
+
// Four conditions have to hold before anything is touched, and the first of
|
|
731
|
+
// them is the promise that this feature is invisible by default: no `format`,
|
|
732
|
+
// or `format=full`, returns the payload untouched, so the dashboard — which
|
|
733
|
+
// never sends the parameter — cannot be affected. The non-metric routes below
|
|
734
|
+
// (`/whoami`, `/audit`) describe the *calling key* rather than project
|
|
735
|
+
// telemetry; they are not in `METRIC_BY_PATH`, so the hook passes their
|
|
736
|
+
// payloads straight through (asserted in `resultFormat.test.ts`).
|
|
737
|
+
app.addHook("preSerialization", async (request, reply, payload) => {
|
|
738
|
+
if (reply.statusCode !== 200)
|
|
739
|
+
return payload;
|
|
740
|
+
const format = request.query?.format;
|
|
741
|
+
if (format == null || format === "full")
|
|
742
|
+
return payload;
|
|
743
|
+
const metric = METRIC_BY_PATH.get(request.routeOptions.url ?? "");
|
|
744
|
+
// Not a registry metric (the raw event stream, which has carried its own
|
|
745
|
+
// `format=json|ndjson` since long before this; `/whoami`; `/audit`), or one
|
|
746
|
+
// of the two **resource** reads. A resource is a single stored record rather
|
|
747
|
+
// than an aggregation: it declares no filters, no querystring schema — so a
|
|
748
|
+
// stray `?format=` would arrive unvalidated — and has nothing to summarise.
|
|
749
|
+
if (metric == null || metric.builder == null)
|
|
750
|
+
return payload;
|
|
751
|
+
// A single-row endpoint (the spatial `stats` routes) still summarises as a
|
|
752
|
+
// one-row result; `null` never happens on a 200 but costs nothing to guard.
|
|
753
|
+
const rows = (Array.isArray(payload) ? payload : payload == null ? [] : [payload]);
|
|
754
|
+
const context = summaryContextFor(metric, request);
|
|
755
|
+
const shaped = format === "table"
|
|
756
|
+
? tableResult(metric, rows, context)
|
|
757
|
+
: summarizeRows(metric, rows, context);
|
|
758
|
+
// `null` only comes back for an unknown metric, which `METRIC_BY_PATH`
|
|
759
|
+
// already excluded — fall back to the raw rows rather than fail the request.
|
|
760
|
+
return shaped ?? payload;
|
|
761
|
+
});
|
|
762
|
+
// --- Key identity + audit (#309, ADR 0051 §7) ----------------------------
|
|
763
|
+
// What is this key allowed to do? Agent clients (and the MCP server) call it
|
|
764
|
+
// first so they can register only the tools their capabilities permit. It
|
|
765
|
+
// reports the key's *id*, never the key itself.
|
|
766
|
+
r.get("/api/v1/whoami", async (req, reply) => {
|
|
767
|
+
const resolved = await requireCapability(req, reply, store, "query");
|
|
768
|
+
if (!resolved)
|
|
769
|
+
return reply;
|
|
770
|
+
return {
|
|
771
|
+
projectId: resolved.projectId,
|
|
772
|
+
keyId: resolved.keyId,
|
|
773
|
+
capabilities: resolved.capabilities,
|
|
774
|
+
label: resolved.label,
|
|
775
|
+
rateLimit: resolved.rateLimit ?? {
|
|
776
|
+
max: config.rateLimitMax,
|
|
777
|
+
windowMs: config.rateLimitWindowMs,
|
|
778
|
+
},
|
|
779
|
+
/** Whether {@link rateLimit} is the key's own budget or the collector default. */
|
|
780
|
+
rateLimitSource: resolved.rateLimit ? "key" : "default",
|
|
781
|
+
};
|
|
782
|
+
});
|
|
783
|
+
// The project's agent-audit trail, newest first. Reading it needs the ordinary
|
|
784
|
+
// `query` capability — it is the project owner's window onto what every agent
|
|
785
|
+
// key has asked for. Rows carry no key material and bounded, redacted params.
|
|
786
|
+
r.get("/api/v1/audit", { schema: { querystring: auditQueryParams } }, async (req, reply) => {
|
|
787
|
+
const projectId = await authProject(req, reply, store);
|
|
788
|
+
if (!projectId)
|
|
789
|
+
return reply;
|
|
790
|
+
const rows = await store.listAudit(projectId, req.query);
|
|
791
|
+
return rows.map((row) => ({ ...row, at: row.at.toISOString() }));
|
|
792
|
+
});
|
|
793
|
+
r.get("/api/v1/sessions", {
|
|
794
|
+
schema: { querystring: sessionsQueryParams, response: { 200: rowsFor("/api/v1/sessions") } },
|
|
795
|
+
}, async (req, reply) => {
|
|
484
796
|
const projectId = await authProject(req, reply, store);
|
|
485
797
|
if (!projectId)
|
|
486
798
|
return reply;
|
|
487
799
|
const { cameraMode, ...rest } = req.query;
|
|
488
800
|
return store.listSessions(projectId, { ...rest, cameraType: cameraTypeForMode(cameraMode) });
|
|
489
801
|
});
|
|
490
|
-
r.get("/api/v1/heatmaps/pointer", {
|
|
802
|
+
r.get("/api/v1/heatmaps/pointer", {
|
|
803
|
+
schema: {
|
|
804
|
+
querystring: pointerHeatmapQueryParams,
|
|
805
|
+
response: { 200: rowsFor("/api/v1/heatmaps/pointer") },
|
|
806
|
+
},
|
|
807
|
+
}, async (req, reply) => {
|
|
491
808
|
const projectId = await authProject(req, reply, store);
|
|
492
809
|
if (!projectId)
|
|
493
810
|
return reply;
|
|
@@ -503,16 +820,24 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
503
820
|
return reply;
|
|
504
821
|
return store.meshUvHeatmap(projectId, req.query);
|
|
505
822
|
});
|
|
506
|
-
r.get("/api/v1/heatmaps/world", {
|
|
823
|
+
r.get("/api/v1/heatmaps/world", {
|
|
824
|
+
schema: {
|
|
825
|
+
querystring: worldHeatmapQueryParams,
|
|
826
|
+
response: { 200: rowsFor("/api/v1/heatmaps/world"), 400: badRequestResponse },
|
|
827
|
+
},
|
|
828
|
+
}, async (req, reply) => {
|
|
507
829
|
const projectId = await authProject(req, reply, store);
|
|
508
830
|
if (!projectId)
|
|
509
831
|
return reply;
|
|
510
|
-
const { cameraMode, region, cellSize, ...rest } = req.query;
|
|
832
|
+
const { cameraMode, region: regionParam, cellSize, ...rest } = req.query;
|
|
833
|
+
const region = await resolveRegionFilter(store, projectId, rest.scene, regionParam);
|
|
834
|
+
if (isRegionError(region))
|
|
835
|
+
return reply.code(400).send(region);
|
|
511
836
|
const resolved = await resolveSpatialCellSize(store, projectId, {
|
|
512
837
|
cellSize,
|
|
513
838
|
scene: rest.scene,
|
|
514
839
|
region,
|
|
515
|
-
});
|
|
840
|
+
}, req);
|
|
516
841
|
return store.worldHeatmap(projectId, {
|
|
517
842
|
...rest,
|
|
518
843
|
region,
|
|
@@ -524,16 +849,24 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
524
849
|
// the truncated top-N voxels, so the viewer can report coverage/cold-spots and
|
|
525
850
|
// "showing top N of M cells". Echoes the effective (possibly bounds-derived)
|
|
526
851
|
// cellSize so the caller can label its own grid.
|
|
527
|
-
r.get("/api/v1/heatmaps/world/stats", {
|
|
852
|
+
r.get("/api/v1/heatmaps/world/stats", {
|
|
853
|
+
schema: {
|
|
854
|
+
querystring: worldStatsQueryParams,
|
|
855
|
+
response: { 200: statsRowFor("/api/v1/heatmaps/world/stats"), 400: badRequestResponse },
|
|
856
|
+
},
|
|
857
|
+
}, async (req, reply) => {
|
|
528
858
|
const projectId = await authProject(req, reply, store);
|
|
529
859
|
if (!projectId)
|
|
530
860
|
return reply;
|
|
531
|
-
const { cameraMode, region, cellSize, ...rest } = req.query;
|
|
861
|
+
const { cameraMode, region: regionParam, cellSize, ...rest } = req.query;
|
|
862
|
+
const region = await resolveRegionFilter(store, projectId, rest.scene, regionParam);
|
|
863
|
+
if (isRegionError(region))
|
|
864
|
+
return reply.code(400).send(region);
|
|
532
865
|
const resolved = await resolveSpatialCellSize(store, projectId, {
|
|
533
866
|
cellSize,
|
|
534
867
|
scene: rest.scene,
|
|
535
868
|
region,
|
|
536
|
-
});
|
|
869
|
+
}, req);
|
|
537
870
|
const stats = await store.worldHeatmapStats(projectId, {
|
|
538
871
|
...rest,
|
|
539
872
|
region,
|
|
@@ -544,16 +877,24 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
544
877
|
});
|
|
545
878
|
// World-space gaze heatmap (ADR 0030) — the "what did people actually look at"
|
|
546
879
|
// sibling of the world (click) heatmap: voxel-binned `camera_sample` gaze hits.
|
|
547
|
-
r.get("/api/v1/heatmaps/gaze", {
|
|
880
|
+
r.get("/api/v1/heatmaps/gaze", {
|
|
881
|
+
schema: {
|
|
882
|
+
querystring: gazeHeatmapQueryParams,
|
|
883
|
+
response: { 200: rowsFor("/api/v1/heatmaps/gaze"), 400: badRequestResponse },
|
|
884
|
+
},
|
|
885
|
+
}, async (req, reply) => {
|
|
548
886
|
const projectId = await authProject(req, reply, store);
|
|
549
887
|
if (!projectId)
|
|
550
888
|
return reply;
|
|
551
|
-
const { cameraMode, region, cellSize, ...rest } = req.query;
|
|
889
|
+
const { cameraMode, region: regionParam, cellSize, ...rest } = req.query;
|
|
890
|
+
const region = await resolveRegionFilter(store, projectId, rest.scene, regionParam);
|
|
891
|
+
if (isRegionError(region))
|
|
892
|
+
return reply.code(400).send(region);
|
|
552
893
|
const resolved = await resolveSpatialCellSize(store, projectId, {
|
|
553
894
|
cellSize,
|
|
554
895
|
scene: rest.scene,
|
|
555
896
|
region,
|
|
556
|
-
});
|
|
897
|
+
}, req);
|
|
557
898
|
return store.gazeHeatmap(projectId, {
|
|
558
899
|
...rest,
|
|
559
900
|
region,
|
|
@@ -562,16 +903,24 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
562
903
|
});
|
|
563
904
|
});
|
|
564
905
|
// Gaze heatmap totals (ADR 0040 §3) — gaze sibling of the world stats route.
|
|
565
|
-
r.get("/api/v1/heatmaps/gaze/stats", {
|
|
906
|
+
r.get("/api/v1/heatmaps/gaze/stats", {
|
|
907
|
+
schema: {
|
|
908
|
+
querystring: gazeStatsQueryParams,
|
|
909
|
+
response: { 200: statsRowFor("/api/v1/heatmaps/gaze/stats"), 400: badRequestResponse },
|
|
910
|
+
},
|
|
911
|
+
}, async (req, reply) => {
|
|
566
912
|
const projectId = await authProject(req, reply, store);
|
|
567
913
|
if (!projectId)
|
|
568
914
|
return reply;
|
|
569
|
-
const { cameraMode, region, cellSize, ...rest } = req.query;
|
|
915
|
+
const { cameraMode, region: regionParam, cellSize, ...rest } = req.query;
|
|
916
|
+
const region = await resolveRegionFilter(store, projectId, rest.scene, regionParam);
|
|
917
|
+
if (isRegionError(region))
|
|
918
|
+
return reply.code(400).send(region);
|
|
570
919
|
const resolved = await resolveSpatialCellSize(store, projectId, {
|
|
571
920
|
cellSize,
|
|
572
921
|
scene: rest.scene,
|
|
573
922
|
region,
|
|
574
|
-
});
|
|
923
|
+
}, req);
|
|
575
924
|
const stats = await store.gazeHeatmapStats(projectId, {
|
|
576
925
|
...rest,
|
|
577
926
|
region,
|
|
@@ -580,7 +929,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
580
929
|
});
|
|
581
930
|
return { cellSize: resolved ?? 0.5, cells: stats.cells, hits: stats.hits };
|
|
582
931
|
});
|
|
583
|
-
r.get("/api/v1/heatmaps/camera", {
|
|
932
|
+
r.get("/api/v1/heatmaps/camera", {
|
|
933
|
+
schema: {
|
|
934
|
+
querystring: cameraHeatmapQueryParams,
|
|
935
|
+
response: { 200: rowsFor("/api/v1/heatmaps/camera") },
|
|
936
|
+
},
|
|
937
|
+
}, async (req, reply) => {
|
|
584
938
|
const projectId = await authProject(req, reply, store);
|
|
585
939
|
if (!projectId)
|
|
586
940
|
return reply;
|
|
@@ -591,7 +945,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
591
945
|
// saw, bucketed across sessions (0–25 / 25–50 / 50–75 / 75–100%). Derived from
|
|
592
946
|
// the same `camera_sample` direction grid as the view-direction dome; no schema
|
|
593
947
|
// change. Shares the camera-heatmap params (bins + scene/session + cameraMode).
|
|
594
|
-
r.get("/api/v1/coverage/view-histogram", {
|
|
948
|
+
r.get("/api/v1/coverage/view-histogram", {
|
|
949
|
+
schema: {
|
|
950
|
+
querystring: cameraHeatmapQueryParams,
|
|
951
|
+
response: { 200: rowsFor("/api/v1/coverage/view-histogram") },
|
|
952
|
+
},
|
|
953
|
+
}, async (req, reply) => {
|
|
595
954
|
const projectId = await authProject(req, reply, store);
|
|
596
955
|
if (!projectId)
|
|
597
956
|
return reply;
|
|
@@ -603,18 +962,33 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
603
962
|
});
|
|
604
963
|
// Floor-plan camera-position heatmap (ADR 0026) — the first-person analog of
|
|
605
964
|
// the 2D pointer heatmap: where visitors stand/dwell on the X/Z ground plane.
|
|
606
|
-
r.get("/api/v1/heatmaps/position", {
|
|
965
|
+
r.get("/api/v1/heatmaps/position", {
|
|
966
|
+
schema: {
|
|
967
|
+
querystring: cameraPositionQueryParams,
|
|
968
|
+
response: { 200: rowsFor("/api/v1/heatmaps/position"), 400: badRequestResponse },
|
|
969
|
+
},
|
|
970
|
+
}, async (req, reply) => {
|
|
607
971
|
const projectId = await authProject(req, reply, store);
|
|
608
972
|
if (!projectId)
|
|
609
973
|
return reply;
|
|
610
|
-
const { cameraMode, ...rest } = req.query;
|
|
974
|
+
const { cameraMode, region: regionParam, ...rest } = req.query;
|
|
975
|
+
const region = await resolveRegionFilter(store, projectId, rest.scene, regionParam);
|
|
976
|
+
if (isRegionError(region))
|
|
977
|
+
return reply.code(400).send(region);
|
|
611
978
|
return store.cameraPositionHeatmap(projectId, {
|
|
612
979
|
...rest,
|
|
980
|
+
region,
|
|
613
981
|
cameraType: cameraTypeForMode(cameraMode),
|
|
614
982
|
});
|
|
615
983
|
});
|
|
616
984
|
// One session's ordered walked path (ADR 0026) — camera positions, oldest first.
|
|
617
|
-
r.get("/api/v1/sessions/:sessionId/trajectory", {
|
|
985
|
+
r.get("/api/v1/sessions/:sessionId/trajectory", {
|
|
986
|
+
schema: {
|
|
987
|
+
params: sessionPathParams,
|
|
988
|
+
querystring: trajectoryQueryParams,
|
|
989
|
+
response: { 200: rowsFor("/api/v1/sessions/:sessionId/trajectory") },
|
|
990
|
+
},
|
|
991
|
+
}, async (req, reply) => {
|
|
618
992
|
const projectId = await authProject(req, reply, store);
|
|
619
993
|
if (!projectId)
|
|
620
994
|
return reply;
|
|
@@ -623,7 +997,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
623
997
|
// Aggregate desire lines (#73, ADR 0037) — every session's camera path binned
|
|
624
998
|
// onto the ground grid and returned as ordered, session-keyed points; the
|
|
625
999
|
// dashboard overlays many low-opacity poly-lines into a crowd-level route map.
|
|
626
|
-
r.get("/api/v1/paths", {
|
|
1000
|
+
r.get("/api/v1/paths", {
|
|
1001
|
+
schema: {
|
|
1002
|
+
querystring: aggregatePathQueryParams,
|
|
1003
|
+
response: { 200: rowsFor("/api/v1/paths") },
|
|
1004
|
+
},
|
|
1005
|
+
}, async (req, reply) => {
|
|
627
1006
|
const projectId = await authProject(req, reply, store);
|
|
628
1007
|
if (!projectId)
|
|
629
1008
|
return reply;
|
|
@@ -634,7 +1013,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
634
1013
|
});
|
|
635
1014
|
});
|
|
636
1015
|
// View-gated click rays (design §7.2/§7.3) — camera-origin → hit rays per voxel/mesh.
|
|
637
|
-
r.get("/api/v1/heatmaps/click-rays", {
|
|
1016
|
+
r.get("/api/v1/heatmaps/click-rays", {
|
|
1017
|
+
schema: {
|
|
1018
|
+
querystring: clickRayQueryParams,
|
|
1019
|
+
response: { 200: rowsFor("/api/v1/heatmaps/click-rays") },
|
|
1020
|
+
},
|
|
1021
|
+
}, async (req, reply) => {
|
|
638
1022
|
const projectId = await authProject(req, reply, store);
|
|
639
1023
|
if (!projectId)
|
|
640
1024
|
return reply;
|
|
@@ -642,7 +1026,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
642
1026
|
});
|
|
643
1027
|
// Aggregate gaze→mesh flow links (design §7.5): direction bins → clicked meshes.
|
|
644
1028
|
// Position-aware mode (§7.8): a standpoint voxel dimension for walkable scenes.
|
|
645
|
-
r.get("/api/v1/heatmaps/flow", {
|
|
1029
|
+
r.get("/api/v1/heatmaps/flow", {
|
|
1030
|
+
schema: {
|
|
1031
|
+
querystring: flowHeatmapQueryParams,
|
|
1032
|
+
response: { 200: rowsFor("/api/v1/heatmaps/flow") },
|
|
1033
|
+
},
|
|
1034
|
+
}, async (req, reply) => {
|
|
646
1035
|
const projectId = await authProject(req, reply, store);
|
|
647
1036
|
if (!projectId)
|
|
648
1037
|
return reply;
|
|
@@ -656,7 +1045,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
656
1045
|
: undefined,
|
|
657
1046
|
});
|
|
658
1047
|
});
|
|
659
|
-
r.get("/api/v1/meshes/top", {
|
|
1048
|
+
r.get("/api/v1/meshes/top", {
|
|
1049
|
+
schema: {
|
|
1050
|
+
querystring: sessionScopedRangeQuery,
|
|
1051
|
+
response: { 200: rowsFor("/api/v1/meshes/top") },
|
|
1052
|
+
},
|
|
1053
|
+
}, async (req, reply) => {
|
|
660
1054
|
const projectId = await authProject(req, reply, store);
|
|
661
1055
|
if (!projectId)
|
|
662
1056
|
return reply;
|
|
@@ -665,7 +1059,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
665
1059
|
// Per-mesh source split (#74) — the most-interacted-mesh tally broken out by
|
|
666
1060
|
// the input `source` that drove each interaction; the leaderboard reads both
|
|
667
1061
|
// rank (sum across sources) and the per-row breakdown from this one query.
|
|
668
|
-
r.get("/api/v1/meshes/sources", {
|
|
1062
|
+
r.get("/api/v1/meshes/sources", {
|
|
1063
|
+
schema: {
|
|
1064
|
+
querystring: pointerHeatmapQueryParams,
|
|
1065
|
+
response: { 200: rowsFor("/api/v1/meshes/sources") },
|
|
1066
|
+
},
|
|
1067
|
+
}, async (req, reply) => {
|
|
669
1068
|
const projectId = await authProject(req, reply, store);
|
|
670
1069
|
if (!projectId)
|
|
671
1070
|
return reply;
|
|
@@ -674,7 +1073,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
674
1073
|
// Per-mesh interaction trend (#74) — the most-interacted-mesh tally bucketed
|
|
675
1074
|
// into fixed `interval`-second windows so the leaderboard can draw a per-mesh
|
|
676
1075
|
// sparkline and a rising/falling delta over the active range.
|
|
677
|
-
r.get("/api/v1/meshes/trend", {
|
|
1076
|
+
r.get("/api/v1/meshes/trend", {
|
|
1077
|
+
schema: {
|
|
1078
|
+
querystring: meshTrendQueryParams,
|
|
1079
|
+
response: { 200: rowsFor("/api/v1/meshes/trend") },
|
|
1080
|
+
},
|
|
1081
|
+
}, async (req, reply) => {
|
|
678
1082
|
const projectId = await authProject(req, reply, store);
|
|
679
1083
|
if (!projectId)
|
|
680
1084
|
return reply;
|
|
@@ -682,7 +1086,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
682
1086
|
});
|
|
683
1087
|
// Object dwell ranking (#37) — per-mesh attention from `mesh_visibility`
|
|
684
1088
|
// summaries (total visible/centered time, peak screen fraction).
|
|
685
|
-
r.get("/api/v1/meshes/dwell", {
|
|
1089
|
+
r.get("/api/v1/meshes/dwell", {
|
|
1090
|
+
schema: {
|
|
1091
|
+
querystring: heatmapQueryParams,
|
|
1092
|
+
response: { 200: rowsFor("/api/v1/meshes/dwell") },
|
|
1093
|
+
},
|
|
1094
|
+
}, async (req, reply) => {
|
|
686
1095
|
const projectId = await authProject(req, reply, store);
|
|
687
1096
|
if (!projectId)
|
|
688
1097
|
return reply;
|
|
@@ -692,7 +1101,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
692
1101
|
// cross-referenced against `mesh_interaction` + `hover_dwell` engagement.
|
|
693
1102
|
// Surfaces objects that render but are never noticed (the inverse of the
|
|
694
1103
|
// most-interacted / part-popularity leaderboards).
|
|
695
|
-
r.get("/api/v1/meshes/blind-spots", {
|
|
1104
|
+
r.get("/api/v1/meshes/blind-spots", {
|
|
1105
|
+
schema: {
|
|
1106
|
+
querystring: heatmapQueryParams,
|
|
1107
|
+
response: { 200: rowsFor("/api/v1/meshes/blind-spots") },
|
|
1108
|
+
},
|
|
1109
|
+
}, async (req, reply) => {
|
|
696
1110
|
const projectId = await authProject(req, reply, store);
|
|
697
1111
|
if (!projectId)
|
|
698
1112
|
return reply;
|
|
@@ -701,7 +1115,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
701
1115
|
// Interaction-kind breakdown (#72, ADR 0023) — per-mesh counts of each
|
|
702
1116
|
// interaction kind (hover / pick / click / drag / …) from `mesh_interaction`
|
|
703
1117
|
// events; how an audience acts on objects, not just which ones draw attention.
|
|
704
|
-
r.get("/api/v1/meshes/kinds", {
|
|
1118
|
+
r.get("/api/v1/meshes/kinds", {
|
|
1119
|
+
schema: {
|
|
1120
|
+
querystring: pointerHeatmapQueryParams,
|
|
1121
|
+
response: { 200: rowsFor("/api/v1/meshes/kinds") },
|
|
1122
|
+
},
|
|
1123
|
+
}, async (req, reply) => {
|
|
705
1124
|
const projectId = await authProject(req, reply, store);
|
|
706
1125
|
if (!projectId)
|
|
707
1126
|
return reply;
|
|
@@ -710,7 +1129,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
710
1129
|
// Reachability report (#151) — per-mesh histogram of the standpoint→interaction
|
|
711
1130
|
// distance (ASOF-joins `mesh_interaction` world points to the nearest preceding
|
|
712
1131
|
// `camera_sample`). Far bands flag meshes/UI reached from an uncomfortable range.
|
|
713
|
-
r.get("/api/v1/meshes/reachability", {
|
|
1132
|
+
r.get("/api/v1/meshes/reachability", {
|
|
1133
|
+
schema: {
|
|
1134
|
+
querystring: reachabilityQueryParams,
|
|
1135
|
+
response: { 200: rowsFor("/api/v1/meshes/reachability") },
|
|
1136
|
+
},
|
|
1137
|
+
}, async (req, reply) => {
|
|
714
1138
|
const projectId = await authProject(req, reply, store);
|
|
715
1139
|
if (!projectId)
|
|
716
1140
|
return reply;
|
|
@@ -718,7 +1142,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
718
1142
|
});
|
|
719
1143
|
// Dead-click rate (#46) — total clicks vs. clicks that hit empty space; a 3D
|
|
720
1144
|
// discoverability signal. The consumer derives the rate from the two counts.
|
|
721
|
-
r.get("/api/v1/clicks/dead", {
|
|
1145
|
+
r.get("/api/v1/clicks/dead", {
|
|
1146
|
+
schema: {
|
|
1147
|
+
querystring: pointerHeatmapQueryParams,
|
|
1148
|
+
response: { 200: rowsFor("/api/v1/clicks/dead") },
|
|
1149
|
+
},
|
|
1150
|
+
}, async (req, reply) => {
|
|
722
1151
|
const projectId = await authProject(req, reply, store);
|
|
723
1152
|
if (!projectId)
|
|
724
1153
|
return reply;
|
|
@@ -726,7 +1155,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
726
1155
|
});
|
|
727
1156
|
// Rage clicks (#47) — rapid repeated clicks on the same mesh within a short
|
|
728
1157
|
// window; a frustration signal derived from the click stream.
|
|
729
|
-
r.get("/api/v1/clicks/rage", {
|
|
1158
|
+
r.get("/api/v1/clicks/rage", {
|
|
1159
|
+
schema: {
|
|
1160
|
+
querystring: rageClickQueryParams,
|
|
1161
|
+
response: { 200: rowsFor("/api/v1/clicks/rage") },
|
|
1162
|
+
},
|
|
1163
|
+
}, async (req, reply) => {
|
|
730
1164
|
const projectId = await authProject(req, reply, store);
|
|
731
1165
|
if (!projectId)
|
|
732
1166
|
return reply;
|
|
@@ -734,7 +1168,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
734
1168
|
});
|
|
735
1169
|
// Hover hesitation (#48) — per-mesh dwell spent hovering an object without
|
|
736
1170
|
// clicking it; flags things that look interactive but aren't.
|
|
737
|
-
r.get("/api/v1/hover/dwell", {
|
|
1171
|
+
r.get("/api/v1/hover/dwell", {
|
|
1172
|
+
schema: {
|
|
1173
|
+
querystring: pointerHeatmapQueryParams,
|
|
1174
|
+
response: { 200: rowsFor("/api/v1/hover/dwell") },
|
|
1175
|
+
},
|
|
1176
|
+
}, async (req, reply) => {
|
|
738
1177
|
const projectId = await authProject(req, reply, store);
|
|
739
1178
|
if (!projectId)
|
|
740
1179
|
return reply;
|
|
@@ -742,7 +1181,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
742
1181
|
});
|
|
743
1182
|
// Compile stalls (#42) — per-phase shader/pipeline compilation hitches; the
|
|
744
1183
|
// felt first-interaction jank that `frame_perf` averages away.
|
|
745
|
-
r.get("/api/v1/perf/compile-stalls", {
|
|
1184
|
+
r.get("/api/v1/perf/compile-stalls", {
|
|
1185
|
+
schema: {
|
|
1186
|
+
querystring: heatmapQueryParams,
|
|
1187
|
+
response: { 200: rowsFor("/api/v1/perf/compile-stalls") },
|
|
1188
|
+
},
|
|
1189
|
+
}, async (req, reply) => {
|
|
746
1190
|
const projectId = await authProject(req, reply, store);
|
|
747
1191
|
if (!projectId)
|
|
748
1192
|
return reply;
|
|
@@ -757,7 +1201,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
757
1201
|
return reply;
|
|
758
1202
|
return store.arPlacementTimeToPlace(projectId, req.query);
|
|
759
1203
|
});
|
|
760
|
-
r.get("/api/v1/ar/placement/attempts", {
|
|
1204
|
+
r.get("/api/v1/ar/placement/attempts", {
|
|
1205
|
+
schema: {
|
|
1206
|
+
querystring: heatmapQueryParams,
|
|
1207
|
+
response: { 200: rowsFor("/api/v1/ar/placement/attempts") },
|
|
1208
|
+
},
|
|
1209
|
+
}, async (req, reply) => {
|
|
761
1210
|
const projectId = await authProject(req, reply, store);
|
|
762
1211
|
if (!projectId)
|
|
763
1212
|
return reply;
|
|
@@ -769,7 +1218,9 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
769
1218
|
return reply;
|
|
770
1219
|
return store.arPlacementSurfaces(projectId, req.query);
|
|
771
1220
|
});
|
|
772
|
-
r.get("/api/v1/perf", {
|
|
1221
|
+
r.get("/api/v1/perf", {
|
|
1222
|
+
schema: { querystring: sessionScopedRangeQuery, response: { 200: rowsFor("/api/v1/perf") } },
|
|
1223
|
+
}, async (req, reply) => {
|
|
773
1224
|
const projectId = await authProject(req, reply, store);
|
|
774
1225
|
if (!projectId)
|
|
775
1226
|
return reply;
|
|
@@ -778,7 +1229,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
778
1229
|
// Render-scale truth (#71, ADR 0021) — the FPS headline paired with the
|
|
779
1230
|
// resolution the engine actually rendered at, so "good FPS" can be read against
|
|
780
1231
|
// the render scale an adaptive renderer bought it with.
|
|
781
|
-
r.get("/api/v1/perf/render-scale", {
|
|
1232
|
+
r.get("/api/v1/perf/render-scale", {
|
|
1233
|
+
schema: {
|
|
1234
|
+
querystring: sessionScopedRangeQuery,
|
|
1235
|
+
response: { 200: rowsFor("/api/v1/perf/render-scale") },
|
|
1236
|
+
},
|
|
1237
|
+
}, async (req, reply) => {
|
|
782
1238
|
const projectId = await authProject(req, reply, store);
|
|
783
1239
|
if (!projectId)
|
|
784
1240
|
return reply;
|
|
@@ -786,7 +1242,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
786
1242
|
});
|
|
787
1243
|
// Resource footprint (#44) — GPU / memory cost summary (avg + peak texture/
|
|
788
1244
|
// geometry bytes, triangles/vertices, JS heap) the scene asked of the device.
|
|
789
|
-
r.get("/api/v1/perf/resources", {
|
|
1245
|
+
r.get("/api/v1/perf/resources", {
|
|
1246
|
+
schema: {
|
|
1247
|
+
querystring: sessionScopedRangeQuery,
|
|
1248
|
+
response: { 200: rowsFor("/api/v1/perf/resources") },
|
|
1249
|
+
},
|
|
1250
|
+
}, async (req, reply) => {
|
|
790
1251
|
const projectId = await authProject(req, reply, store);
|
|
791
1252
|
if (!projectId)
|
|
792
1253
|
return reply;
|
|
@@ -795,7 +1256,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
795
1256
|
// FPS distribution (#81, ADR 0028 §1) — per-session p05/p50/p95 FPS summarized
|
|
796
1257
|
// across sessions (median-of-medians); the distribution-honest headline that
|
|
797
1258
|
// replaces the volume-chart mean.
|
|
798
|
-
r.get("/api/v1/perf/distribution", {
|
|
1259
|
+
r.get("/api/v1/perf/distribution", {
|
|
1260
|
+
schema: {
|
|
1261
|
+
querystring: heatmapQueryParams,
|
|
1262
|
+
response: { 200: rowsFor("/api/v1/perf/distribution") },
|
|
1263
|
+
},
|
|
1264
|
+
}, async (req, reply) => {
|
|
799
1265
|
const projectId = await authProject(req, reply, store);
|
|
800
1266
|
if (!projectId)
|
|
801
1267
|
return reply;
|
|
@@ -803,7 +1269,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
803
1269
|
});
|
|
804
1270
|
// FPS histogram (#81, ADR 0028 §1) — per-session median FPS bucketed into
|
|
805
1271
|
// `bucket`-wide bins; one session = one data point.
|
|
806
|
-
r.get("/api/v1/perf/fps-histogram", {
|
|
1272
|
+
r.get("/api/v1/perf/fps-histogram", {
|
|
1273
|
+
schema: {
|
|
1274
|
+
querystring: fpsHistogramQueryParams,
|
|
1275
|
+
response: { 200: rowsFor("/api/v1/perf/fps-histogram") },
|
|
1276
|
+
},
|
|
1277
|
+
}, async (req, reply) => {
|
|
807
1278
|
const projectId = await authProject(req, reply, store);
|
|
808
1279
|
if (!projectId)
|
|
809
1280
|
return reply;
|
|
@@ -811,7 +1282,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
811
1282
|
});
|
|
812
1283
|
// Frame-time percentiles (#81, ADR 0028 §1) — per-session median frame time and
|
|
813
1284
|
// worst-window p95 (ms), summarized across sessions.
|
|
814
|
-
r.get("/api/v1/perf/frame-time", {
|
|
1285
|
+
r.get("/api/v1/perf/frame-time", {
|
|
1286
|
+
schema: {
|
|
1287
|
+
querystring: heatmapQueryParams,
|
|
1288
|
+
response: { 200: rowsFor("/api/v1/perf/frame-time") },
|
|
1289
|
+
},
|
|
1290
|
+
}, async (req, reply) => {
|
|
815
1291
|
const projectId = await authProject(req, reply, store);
|
|
816
1292
|
if (!projectId)
|
|
817
1293
|
return reply;
|
|
@@ -819,7 +1295,9 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
819
1295
|
});
|
|
820
1296
|
// Jank rate (#81, ADR 0028 §1) — per-session long-frames-per-window rate,
|
|
821
1297
|
// reported as the median and worst-decile session.
|
|
822
|
-
r.get("/api/v1/perf/jank", {
|
|
1298
|
+
r.get("/api/v1/perf/jank", {
|
|
1299
|
+
schema: { querystring: heatmapQueryParams, response: { 200: rowsFor("/api/v1/perf/jank") } },
|
|
1300
|
+
}, async (req, reply) => {
|
|
823
1301
|
const projectId = await authProject(req, reply, store);
|
|
824
1302
|
if (!projectId)
|
|
825
1303
|
return reply;
|
|
@@ -828,7 +1306,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
828
1306
|
// Perf-correlated churn (#144) — of the sessions that ended in range, how many
|
|
829
1307
|
// ended within `windowMs` of an FPS dip or a compile_stall, with the cause
|
|
830
1308
|
// attributed. Does a stutter actually cost sessions?
|
|
831
|
-
r.get("/api/v1/perf/churn", {
|
|
1309
|
+
r.get("/api/v1/perf/churn", {
|
|
1310
|
+
schema: {
|
|
1311
|
+
querystring: perfChurnQueryParams,
|
|
1312
|
+
response: { 200: rowsFor("/api/v1/perf/churn") },
|
|
1313
|
+
},
|
|
1314
|
+
}, async (req, reply) => {
|
|
832
1315
|
const projectId = await authProject(req, reply, store);
|
|
833
1316
|
if (!projectId)
|
|
834
1317
|
return reply;
|
|
@@ -837,14 +1320,24 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
837
1320
|
// FPS by device class (#82, ADR 0028 §2; #11, ADR 0041) — per-session median FPS
|
|
838
1321
|
// attributed to the `session_start.device` block (backend / mobile / GPU
|
|
839
1322
|
// renderer) plus the coarse browser/OS derived from the User-Agent at ingestion.
|
|
840
|
-
r.get("/api/v1/perf/by-device", {
|
|
1323
|
+
r.get("/api/v1/perf/by-device", {
|
|
1324
|
+
schema: {
|
|
1325
|
+
querystring: heatmapQueryParams,
|
|
1326
|
+
response: { 200: rowsFor("/api/v1/perf/by-device") },
|
|
1327
|
+
},
|
|
1328
|
+
}, async (req, reply) => {
|
|
841
1329
|
const projectId = await authProject(req, reply, store);
|
|
842
1330
|
if (!projectId)
|
|
843
1331
|
return reply;
|
|
844
1332
|
return store.perfByDevice(projectId, req.query);
|
|
845
1333
|
});
|
|
846
1334
|
// FPS by scene (#82, ADR 0028 §1) — per-session median FPS grouped by scene.
|
|
847
|
-
r.get("/api/v1/perf/by-scene", {
|
|
1335
|
+
r.get("/api/v1/perf/by-scene", {
|
|
1336
|
+
schema: {
|
|
1337
|
+
querystring: heatmapQueryParams,
|
|
1338
|
+
response: { 200: rowsFor("/api/v1/perf/by-scene") },
|
|
1339
|
+
},
|
|
1340
|
+
}, async (req, reply) => {
|
|
848
1341
|
const projectId = await authProject(req, reply, store);
|
|
849
1342
|
if (!projectId)
|
|
850
1343
|
return reply;
|
|
@@ -852,7 +1345,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
852
1345
|
});
|
|
853
1346
|
// Resource-footprint percentiles (#83, ADR 0028 §1) — per-session p50/p95 of JS
|
|
854
1347
|
// heap, texture bytes, and triangle count, summarized across sessions.
|
|
855
|
-
r.get("/api/v1/perf/resource-percentiles", {
|
|
1348
|
+
r.get("/api/v1/perf/resource-percentiles", {
|
|
1349
|
+
schema: {
|
|
1350
|
+
querystring: heatmapQueryParams,
|
|
1351
|
+
response: { 200: rowsFor("/api/v1/perf/resource-percentiles") },
|
|
1352
|
+
},
|
|
1353
|
+
}, async (req, reply) => {
|
|
856
1354
|
const projectId = await authProject(req, reply, store);
|
|
857
1355
|
if (!projectId)
|
|
858
1356
|
return reply;
|
|
@@ -860,7 +1358,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
860
1358
|
});
|
|
861
1359
|
// Stability incidents (#83) — context-loss and compile-stall counts over the
|
|
862
1360
|
// range; the hard failures `frame_perf` cannot show.
|
|
863
|
-
r.get("/api/v1/perf/stability", {
|
|
1361
|
+
r.get("/api/v1/perf/stability", {
|
|
1362
|
+
schema: {
|
|
1363
|
+
querystring: heatmapQueryParams,
|
|
1364
|
+
response: { 200: rowsFor("/api/v1/perf/stability") },
|
|
1365
|
+
},
|
|
1366
|
+
}, async (req, reply) => {
|
|
864
1367
|
const projectId = await authProject(req, reply, store);
|
|
865
1368
|
if (!projectId)
|
|
866
1369
|
return reply;
|
|
@@ -870,7 +1373,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
870
1373
|
// incident counts crossed by (severity, category, backend) over the range,
|
|
871
1374
|
// folding discrete markers and per-session rollups into the same counters.
|
|
872
1375
|
// Capture is off by default, so an empty result is the common (clean) case.
|
|
873
|
-
r.get("/api/v1/graphics-diagnostics", {
|
|
1376
|
+
r.get("/api/v1/graphics-diagnostics", {
|
|
1377
|
+
schema: {
|
|
1378
|
+
querystring: heatmapQueryParams,
|
|
1379
|
+
response: { 200: rowsFor("/api/v1/graphics-diagnostics") },
|
|
1380
|
+
},
|
|
1381
|
+
}, async (req, reply) => {
|
|
874
1382
|
const projectId = await authProject(req, reply, store);
|
|
875
1383
|
if (!projectId)
|
|
876
1384
|
return reply;
|
|
@@ -881,11 +1389,20 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
881
1389
|
// things break (clustering around geometry, shader-heavy areas, a level
|
|
882
1390
|
// region). severity/category narrow to engine diagnostics, errorKind to JS
|
|
883
1391
|
// errors. Only events carrying a best-effort position participate.
|
|
884
|
-
r.get("/api/v1/heatmaps/errors", {
|
|
1392
|
+
r.get("/api/v1/heatmaps/errors", {
|
|
1393
|
+
schema: {
|
|
1394
|
+
querystring: errorHeatmapQueryParams,
|
|
1395
|
+
response: { 200: rowsFor("/api/v1/heatmaps/errors"), 400: badRequestResponse },
|
|
1396
|
+
},
|
|
1397
|
+
}, async (req, reply) => {
|
|
885
1398
|
const projectId = await authProject(req, reply, store);
|
|
886
1399
|
if (!projectId)
|
|
887
1400
|
return reply;
|
|
888
|
-
|
|
1401
|
+
const { region: regionParam, ...rest } = req.query;
|
|
1402
|
+
const region = await resolveRegionFilter(store, projectId, rest.scene, regionParam);
|
|
1403
|
+
if (isRegionError(region))
|
|
1404
|
+
return reply.code(400).send(region);
|
|
1405
|
+
return store.errorHeatmap(projectId, { ...rest, region });
|
|
889
1406
|
});
|
|
890
1407
|
// Guardian/boundary-touch heatmap (#157, ADR 0048) — voxel-binned world
|
|
891
1408
|
// `position` of `xr_boundary_proximity` events: *where* in the play space the
|
|
@@ -894,30 +1411,46 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
894
1411
|
// position + duration each approach carries participate (ADR 0003 / ADR 0048).
|
|
895
1412
|
// Reuses the world-heatmap voxel path; cellSize resolves from scene/region
|
|
896
1413
|
// bounds when unset so the stats endpoint can echo it.
|
|
897
|
-
r.get("/api/v1/heatmaps/boundary", {
|
|
1414
|
+
r.get("/api/v1/heatmaps/boundary", {
|
|
1415
|
+
schema: {
|
|
1416
|
+
querystring: boundaryHeatmapQueryParams,
|
|
1417
|
+
response: { 200: rowsFor("/api/v1/heatmaps/boundary"), 400: badRequestResponse },
|
|
1418
|
+
},
|
|
1419
|
+
}, async (req, reply) => {
|
|
898
1420
|
const projectId = await authProject(req, reply, store);
|
|
899
1421
|
if (!projectId)
|
|
900
1422
|
return reply;
|
|
901
|
-
const { region, cellSize, ...rest } = req.query;
|
|
1423
|
+
const { region: regionParam, cellSize, ...rest } = req.query;
|
|
1424
|
+
const region = await resolveRegionFilter(store, projectId, rest.scene, regionParam);
|
|
1425
|
+
if (isRegionError(region))
|
|
1426
|
+
return reply.code(400).send(region);
|
|
902
1427
|
const resolved = await resolveSpatialCellSize(store, projectId, {
|
|
903
1428
|
cellSize,
|
|
904
1429
|
scene: rest.scene,
|
|
905
1430
|
region,
|
|
906
|
-
});
|
|
1431
|
+
}, req);
|
|
907
1432
|
return store.boundaryHeatmap(projectId, { ...rest, region, cellSize: resolved });
|
|
908
1433
|
});
|
|
909
1434
|
// Boundary-touch heatmap totals (ADR 0040 §3) — true occupied-cell + contact
|
|
910
1435
|
// counts behind the truncated top-N voxels, echoing the effective cellSize.
|
|
911
|
-
r.get("/api/v1/heatmaps/boundary/stats", {
|
|
1436
|
+
r.get("/api/v1/heatmaps/boundary/stats", {
|
|
1437
|
+
schema: {
|
|
1438
|
+
querystring: boundaryStatsQueryParams,
|
|
1439
|
+
response: { 200: statsRowFor("/api/v1/heatmaps/boundary/stats"), 400: badRequestResponse },
|
|
1440
|
+
},
|
|
1441
|
+
}, async (req, reply) => {
|
|
912
1442
|
const projectId = await authProject(req, reply, store);
|
|
913
1443
|
if (!projectId)
|
|
914
1444
|
return reply;
|
|
915
|
-
const { region, cellSize, ...rest } = req.query;
|
|
1445
|
+
const { region: regionParam, cellSize, ...rest } = req.query;
|
|
1446
|
+
const region = await resolveRegionFilter(store, projectId, rest.scene, regionParam);
|
|
1447
|
+
if (isRegionError(region))
|
|
1448
|
+
return reply.code(400).send(region);
|
|
916
1449
|
const resolved = await resolveSpatialCellSize(store, projectId, {
|
|
917
1450
|
cellSize,
|
|
918
1451
|
scene: rest.scene,
|
|
919
1452
|
region,
|
|
920
|
-
});
|
|
1453
|
+
}, req);
|
|
921
1454
|
const stats = await store.boundaryHeatmapStats(projectId, {
|
|
922
1455
|
...rest,
|
|
923
1456
|
region,
|
|
@@ -929,7 +1462,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
929
1462
|
// crossed by (api, backend, api_version, shading_language) from
|
|
930
1463
|
// `session_start.graphics`. Always-on (unlike diagnostics), so a populated
|
|
931
1464
|
// result is the common case.
|
|
932
|
-
r.get("/api/v1/rendering-technology", {
|
|
1465
|
+
r.get("/api/v1/rendering-technology", {
|
|
1466
|
+
schema: {
|
|
1467
|
+
querystring: heatmapQueryParams,
|
|
1468
|
+
response: { 200: rowsFor("/api/v1/rendering-technology") },
|
|
1469
|
+
},
|
|
1470
|
+
}, async (req, reply) => {
|
|
933
1471
|
const projectId = await authProject(req, reply, store);
|
|
934
1472
|
if (!projectId)
|
|
935
1473
|
return reply;
|
|
@@ -938,7 +1476,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
938
1476
|
// Capability changes (#49) — per-transition fallback/recovery counts (e.g. how
|
|
939
1477
|
// many sessions fell back WebGPU→WebGL2); explains perf / visual-fidelity
|
|
940
1478
|
// variance across the user base. App-reported via reportCapabilityChange.
|
|
941
|
-
r.get("/api/v1/capabilities", {
|
|
1479
|
+
r.get("/api/v1/capabilities", {
|
|
1480
|
+
schema: {
|
|
1481
|
+
querystring: heatmapQueryParams,
|
|
1482
|
+
response: { 200: rowsFor("/api/v1/capabilities") },
|
|
1483
|
+
},
|
|
1484
|
+
}, async (req, reply) => {
|
|
942
1485
|
const projectId = await authProject(req, reply, store);
|
|
943
1486
|
if (!projectId)
|
|
944
1487
|
return reply;
|
|
@@ -947,7 +1490,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
947
1490
|
// Camera gestures (ADR 0025) — per-kind navigation breakdown (orbit / pan /
|
|
948
1491
|
// dolly / zoom / roll / fly) separating deliberate viewpoint movement from
|
|
949
1492
|
// object selection; reveals how an audience explores the scene.
|
|
950
|
-
r.get("/api/v1/camera-gestures", {
|
|
1493
|
+
r.get("/api/v1/camera-gestures", {
|
|
1494
|
+
schema: {
|
|
1495
|
+
querystring: pointerHeatmapQueryParams,
|
|
1496
|
+
response: { 200: rowsFor("/api/v1/camera-gestures") },
|
|
1497
|
+
},
|
|
1498
|
+
}, async (req, reply) => {
|
|
951
1499
|
const projectId = await authProject(req, reply, store);
|
|
952
1500
|
if (!projectId)
|
|
953
1501
|
return reply;
|
|
@@ -955,7 +1503,9 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
955
1503
|
});
|
|
956
1504
|
// Scene coverage / dead zones (derived, scene-metrics §B) — occupied
|
|
957
1505
|
// camera-position voxels; coverage % is layered in against the scene AABB.
|
|
958
|
-
r.get("/api/v1/coverage", {
|
|
1506
|
+
r.get("/api/v1/coverage", {
|
|
1507
|
+
schema: { querystring: coverageQueryParams, response: { 200: rowsFor("/api/v1/coverage") } },
|
|
1508
|
+
}, async (req, reply) => {
|
|
959
1509
|
const projectId = await authProject(req, reply, store);
|
|
960
1510
|
if (!projectId)
|
|
961
1511
|
return reply;
|
|
@@ -963,7 +1513,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
963
1513
|
});
|
|
964
1514
|
// Spatial FPS heatmap (#145) — where FPS is bad in world space: frame_perf
|
|
965
1515
|
// samples voxel-binned by their captured camera position (avg/min FPS per cell).
|
|
966
|
-
r.get("/api/v1/heatmaps/perf", {
|
|
1516
|
+
r.get("/api/v1/heatmaps/perf", {
|
|
1517
|
+
schema: {
|
|
1518
|
+
querystring: perfHeatmapQueryParams,
|
|
1519
|
+
response: { 200: rowsFor("/api/v1/heatmaps/perf") },
|
|
1520
|
+
},
|
|
1521
|
+
}, async (req, reply) => {
|
|
967
1522
|
const projectId = await authProject(req, reply, store);
|
|
968
1523
|
if (!projectId)
|
|
969
1524
|
return reply;
|
|
@@ -971,7 +1526,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
971
1526
|
});
|
|
972
1527
|
// Camera distance / zoom distribution (derived, scene-metrics §B) — histogram of
|
|
973
1528
|
// camera-to-center distance. The center defaults to the origin when omitted.
|
|
974
|
-
r.get("/api/v1/camera/distance", {
|
|
1529
|
+
r.get("/api/v1/camera/distance", {
|
|
1530
|
+
schema: {
|
|
1531
|
+
querystring: cameraDistanceQueryParams,
|
|
1532
|
+
response: { 200: rowsFor("/api/v1/camera/distance") },
|
|
1533
|
+
},
|
|
1534
|
+
}, async (req, reply) => {
|
|
975
1535
|
const projectId = await authProject(req, reply, store);
|
|
976
1536
|
if (!projectId)
|
|
977
1537
|
return reply;
|
|
@@ -983,7 +1543,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
983
1543
|
});
|
|
984
1544
|
// Navigation effort / friction (derived, scene-metrics §B) — per-session travel
|
|
985
1545
|
// distance with active-vs-idle segmentation.
|
|
986
|
-
r.get("/api/v1/navigation", {
|
|
1546
|
+
r.get("/api/v1/navigation", {
|
|
1547
|
+
schema: {
|
|
1548
|
+
querystring: navigationQueryParams,
|
|
1549
|
+
response: { 200: rowsFor("/api/v1/navigation") },
|
|
1550
|
+
},
|
|
1551
|
+
}, async (req, reply) => {
|
|
987
1552
|
const projectId = await authProject(req, reply, store);
|
|
988
1553
|
if (!projectId)
|
|
989
1554
|
return reply;
|
|
@@ -992,7 +1557,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
992
1557
|
// Path-retrace / backtracking ratio (#153) — per-scene leaderboard of how often
|
|
993
1558
|
// visitors re-walk a coarse grid cell (the confusion / unclear-signage signal),
|
|
994
1559
|
// derived from the same camera-position stream as desire lines.
|
|
995
|
-
r.get("/api/v1/backtrack", {
|
|
1560
|
+
r.get("/api/v1/backtrack", {
|
|
1561
|
+
schema: {
|
|
1562
|
+
querystring: backtrackQueryParams,
|
|
1563
|
+
response: { 200: rowsFor("/api/v1/backtrack") },
|
|
1564
|
+
},
|
|
1565
|
+
}, async (req, reply) => {
|
|
996
1566
|
const projectId = await authProject(req, reply, store);
|
|
997
1567
|
if (!projectId)
|
|
998
1568
|
return reply;
|
|
@@ -1000,7 +1570,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
1000
1570
|
});
|
|
1001
1571
|
// XR motion-sickness proxy (#50, scene-metrics §F) — per-session head/view
|
|
1002
1572
|
// rotation rate over the camera pose stream; rapid rotation flags discomfort.
|
|
1003
|
-
r.get("/api/v1/xr/rotation", {
|
|
1573
|
+
r.get("/api/v1/xr/rotation", {
|
|
1574
|
+
schema: {
|
|
1575
|
+
querystring: xrRotationQueryParams,
|
|
1576
|
+
response: { 200: rowsFor("/api/v1/xr/rotation") },
|
|
1577
|
+
},
|
|
1578
|
+
}, async (req, reply) => {
|
|
1004
1579
|
const projectId = await authProject(req, reply, store);
|
|
1005
1580
|
if (!projectId)
|
|
1006
1581
|
return reply;
|
|
@@ -1008,7 +1583,9 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
1008
1583
|
});
|
|
1009
1584
|
// XR input-source usage (#50, scene-metrics §F) — hand vs. controller (vs.
|
|
1010
1585
|
// gaze) split read from `source` on the interaction events.
|
|
1011
|
-
r.get("/api/v1/xr/sources", {
|
|
1586
|
+
r.get("/api/v1/xr/sources", {
|
|
1587
|
+
schema: { querystring: heatmapQueryParams, response: { 200: rowsFor("/api/v1/xr/sources") } },
|
|
1588
|
+
}, async (req, reply) => {
|
|
1012
1589
|
const projectId = await authProject(req, reply, store);
|
|
1013
1590
|
if (!projectId)
|
|
1014
1591
|
return reply;
|
|
@@ -1016,7 +1593,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
1016
1593
|
});
|
|
1017
1594
|
// XR session abandonment (#50, scene-metrics §F) — per XR session, its time
|
|
1018
1595
|
// bounds and event/interaction counts; a short span signals headset drop-off.
|
|
1019
|
-
r.get("/api/v1/xr/abandonment", {
|
|
1596
|
+
r.get("/api/v1/xr/abandonment", {
|
|
1597
|
+
schema: {
|
|
1598
|
+
querystring: heatmapQueryParams,
|
|
1599
|
+
response: { 200: rowsFor("/api/v1/xr/abandonment") },
|
|
1600
|
+
},
|
|
1601
|
+
}, async (req, reply) => {
|
|
1020
1602
|
const projectId = await authProject(req, reply, store);
|
|
1021
1603
|
if (!projectId)
|
|
1022
1604
|
return reply;
|
|
@@ -1025,7 +1607,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
1025
1607
|
// XR locomotion & comfort (#148) — per XR session, its locomotion-style mix
|
|
1026
1608
|
// (fly / navigate / teleport + duration) and wall-clock span, so heavy
|
|
1027
1609
|
// locomotion can be correlated with early exits (a discomfort proxy).
|
|
1028
|
-
r.get("/api/v1/xr/locomotion", {
|
|
1610
|
+
r.get("/api/v1/xr/locomotion", {
|
|
1611
|
+
schema: {
|
|
1612
|
+
querystring: heatmapQueryParams,
|
|
1613
|
+
response: { 200: rowsFor("/api/v1/xr/locomotion") },
|
|
1614
|
+
},
|
|
1615
|
+
}, async (req, reply) => {
|
|
1029
1616
|
const projectId = await authProject(req, reply, store);
|
|
1030
1617
|
if (!projectId)
|
|
1031
1618
|
return reply;
|
|
@@ -1036,7 +1623,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
1036
1623
|
// the near-boundary zone. A room-scale comfort signal beside VR locomotion;
|
|
1037
1624
|
// frequent contact means the physical space didn't fit the experience. Built
|
|
1038
1625
|
// from `xr_boundary_proximity` (one event per approach) — no room geometry.
|
|
1039
|
-
r.get("/api/v1/xr/boundary-contacts", {
|
|
1626
|
+
r.get("/api/v1/xr/boundary-contacts", {
|
|
1627
|
+
schema: {
|
|
1628
|
+
querystring: heatmapQueryParams,
|
|
1629
|
+
response: { 200: rowsFor("/api/v1/xr/boundary-contacts") },
|
|
1630
|
+
},
|
|
1631
|
+
}, async (req, reply) => {
|
|
1040
1632
|
const projectId = await authProject(req, reply, store);
|
|
1041
1633
|
if (!projectId)
|
|
1042
1634
|
return reply;
|
|
@@ -1045,7 +1637,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
1045
1637
|
// XR tracking quality (#155, ADR 0048) — per XR session that reported a
|
|
1046
1638
|
// tracking transition, how much of the session ran with degraded / lost
|
|
1047
1639
|
// spatial tracking, split by hand vs. controller (a tracking-quality timeline).
|
|
1048
|
-
r.get("/api/v1/xr/tracking", {
|
|
1640
|
+
r.get("/api/v1/xr/tracking", {
|
|
1641
|
+
schema: {
|
|
1642
|
+
querystring: heatmapQueryParams,
|
|
1643
|
+
response: { 200: rowsFor("/api/v1/xr/tracking") },
|
|
1644
|
+
},
|
|
1645
|
+
}, async (req, reply) => {
|
|
1049
1646
|
const projectId = await authProject(req, reply, store);
|
|
1050
1647
|
if (!projectId)
|
|
1051
1648
|
return reply;
|
|
@@ -1054,7 +1651,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
1054
1651
|
// Input-source breakdown (ADR 0011) — per (event_type, source), how many
|
|
1055
1652
|
// interactions came from each input source (mouse / touch / xr-controller /
|
|
1056
1653
|
// hand / …) and across how many sessions. Turns `source` into an insight.
|
|
1057
|
-
r.get("/api/v1/interactions/sources", {
|
|
1654
|
+
r.get("/api/v1/interactions/sources", {
|
|
1655
|
+
schema: {
|
|
1656
|
+
querystring: pointerHeatmapQueryParams,
|
|
1657
|
+
response: { 200: rowsFor("/api/v1/interactions/sources") },
|
|
1658
|
+
},
|
|
1659
|
+
}, async (req, reply) => {
|
|
1058
1660
|
const projectId = await authProject(req, reply, store);
|
|
1059
1661
|
if (!projectId)
|
|
1060
1662
|
return reply;
|
|
@@ -1063,7 +1665,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
1063
1665
|
// Most-used shortcuts / actions (#75, ADR 0023) — rank `input_action` events by
|
|
1064
1666
|
// their app-level `action` label, split by `source` (keyboard / gamepad / …).
|
|
1065
1667
|
// Pairs with the input-source breakdown for the input-modality panel.
|
|
1066
|
-
r.get("/api/v1/input-actions/top", {
|
|
1668
|
+
r.get("/api/v1/input-actions/top", {
|
|
1669
|
+
schema: {
|
|
1670
|
+
querystring: pointerHeatmapQueryParams,
|
|
1671
|
+
response: { 200: rowsFor("/api/v1/input-actions/top") },
|
|
1672
|
+
},
|
|
1673
|
+
}, async (req, reply) => {
|
|
1067
1674
|
const projectId = await authProject(req, reply, store);
|
|
1068
1675
|
if (!projectId)
|
|
1069
1676
|
return reply;
|
|
@@ -1077,14 +1684,24 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
1077
1684
|
return store.scenes(projectId, req.query);
|
|
1078
1685
|
});
|
|
1079
1686
|
// Event-volume time-series (the 4th dimension) — bucketed event counts + FPS.
|
|
1080
|
-
r.get("/api/v1/timeseries", {
|
|
1687
|
+
r.get("/api/v1/timeseries", {
|
|
1688
|
+
schema: {
|
|
1689
|
+
querystring: timeseriesQueryParams,
|
|
1690
|
+
response: { 200: rowsFor("/api/v1/timeseries") },
|
|
1691
|
+
},
|
|
1692
|
+
}, async (req, reply) => {
|
|
1081
1693
|
const projectId = await authProject(req, reply, store);
|
|
1082
1694
|
if (!projectId)
|
|
1083
1695
|
return reply;
|
|
1084
1696
|
return store.timeseries(projectId, req.query);
|
|
1085
1697
|
});
|
|
1086
1698
|
// Per-event-type counts over the range — powers the scene health panel.
|
|
1087
|
-
r.get("/api/v1/event-counts", {
|
|
1699
|
+
r.get("/api/v1/event-counts", {
|
|
1700
|
+
schema: {
|
|
1701
|
+
querystring: eventCountsQueryParams,
|
|
1702
|
+
response: { 200: rowsFor("/api/v1/event-counts") },
|
|
1703
|
+
},
|
|
1704
|
+
}, async (req, reply) => {
|
|
1088
1705
|
const projectId = await authProject(req, reply, store);
|
|
1089
1706
|
if (!projectId)
|
|
1090
1707
|
return reply;
|
|
@@ -1095,7 +1712,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
1095
1712
|
// of step predicates, validated against the shared `funnelStepsSchema`; the
|
|
1096
1713
|
// OSS dashboard is a passive viewer, so steps are supplied by the caller
|
|
1097
1714
|
// (CLI / hosted), never authored or persisted here.
|
|
1098
|
-
r.get("/api/v1/funnel", {
|
|
1715
|
+
r.get("/api/v1/funnel", {
|
|
1716
|
+
schema: {
|
|
1717
|
+
querystring: funnelQueryParams,
|
|
1718
|
+
response: { 200: rowsFor("/api/v1/funnel"), 400: badRequestResponse },
|
|
1719
|
+
},
|
|
1720
|
+
}, async (req, reply) => {
|
|
1099
1721
|
const projectId = await authProject(req, reply, store);
|
|
1100
1722
|
if (!projectId)
|
|
1101
1723
|
return reply;
|
|
@@ -1108,7 +1730,9 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
1108
1730
|
}
|
|
1109
1731
|
const result = funnelStepsSchema.safeParse(parsed);
|
|
1110
1732
|
if (!result.success) {
|
|
1111
|
-
return reply
|
|
1733
|
+
return reply
|
|
1734
|
+
.code(400)
|
|
1735
|
+
.send({ error: "invalid funnel steps", details: result.error.issues });
|
|
1112
1736
|
}
|
|
1113
1737
|
const { since, until, scene, cameraMode } = req.query;
|
|
1114
1738
|
return store.funnel(projectId, {
|
|
@@ -1122,7 +1746,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
1122
1746
|
// Canned scene/level retention funnel (#147) — session counts flowing scene →
|
|
1123
1747
|
// scene in observed order, built directly from `scene_change` markers with no
|
|
1124
1748
|
// caller-authored steps (the zero-config complement to `/api/v1/funnel`).
|
|
1125
|
-
r.get("/api/v1/scene-retention", {
|
|
1749
|
+
r.get("/api/v1/scene-retention", {
|
|
1750
|
+
schema: {
|
|
1751
|
+
querystring: sceneRetentionQueryParams,
|
|
1752
|
+
response: { 200: rowsFor("/api/v1/scene-retention") },
|
|
1753
|
+
},
|
|
1754
|
+
}, async (req, reply) => {
|
|
1126
1755
|
const projectId = await authProject(req, reply, store);
|
|
1127
1756
|
if (!projectId)
|
|
1128
1757
|
return reply;
|
|
@@ -1134,7 +1763,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
1134
1763
|
// `bands` are optional caller-supplied ascending ms boundaries; the store
|
|
1135
1764
|
// applies a sensible default when omitted. Derived from existing events — no
|
|
1136
1765
|
// schema change.
|
|
1137
|
-
r.get("/api/v1/load-bounce", {
|
|
1766
|
+
r.get("/api/v1/load-bounce", {
|
|
1767
|
+
schema: {
|
|
1768
|
+
querystring: loadBounceQueryParams,
|
|
1769
|
+
response: { 200: rowsFor("/api/v1/load-bounce") },
|
|
1770
|
+
},
|
|
1771
|
+
}, async (req, reply) => {
|
|
1138
1772
|
const projectId = await authProject(req, reply, store);
|
|
1139
1773
|
if (!projectId)
|
|
1140
1774
|
return reply;
|
|
@@ -1147,7 +1781,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
1147
1781
|
// the next switch/conversion. `variant`/`conversion` are JSON-encoded step
|
|
1148
1782
|
// predicates validated against the shared `funnelStepSchema`; like the funnel,
|
|
1149
1783
|
// OSS is a passive viewer and the caller supplies them (ADR 0038).
|
|
1150
|
-
r.get("/api/v1/variant-leaderboard", {
|
|
1784
|
+
r.get("/api/v1/variant-leaderboard", {
|
|
1785
|
+
schema: {
|
|
1786
|
+
querystring: variantLeaderboardQueryParams,
|
|
1787
|
+
response: { 200: rowsFor("/api/v1/variant-leaderboard"), 400: badRequestResponse },
|
|
1788
|
+
},
|
|
1789
|
+
}, async (req, reply) => {
|
|
1151
1790
|
const projectId = await authProject(req, reply, store);
|
|
1152
1791
|
if (!projectId)
|
|
1153
1792
|
return reply;
|
|
@@ -1200,12 +1839,20 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
1200
1839
|
querystring: z.object({ format: z.enum(["json", "ndjson"]).optional() }),
|
|
1201
1840
|
},
|
|
1202
1841
|
}, async (req, reply) => {
|
|
1203
|
-
|
|
1204
|
-
|
|
1842
|
+
// Raw per-session data needs BOTH halves of the gate (ADR 0051 §7):
|
|
1843
|
+
// retention enabled on the collector AND `query:raw` on the key. A plain
|
|
1844
|
+
// `query` key is authenticated (so an unknown key still gets a 401) but
|
|
1845
|
+
// refused the raw stream even when retention is on.
|
|
1846
|
+
const resolved = await requireCapability(req, reply, store, "query");
|
|
1847
|
+
if (!resolved)
|
|
1205
1848
|
return reply;
|
|
1206
1849
|
if (!config.enableRawSessionRetention) {
|
|
1207
1850
|
return reply.code(403).send({ error: "raw session retention is disabled" });
|
|
1208
1851
|
}
|
|
1852
|
+
if (!resolved.capabilities.includes("query:raw")) {
|
|
1853
|
+
return reply.code(403).send({ error: "api key not permitted to read raw session data" });
|
|
1854
|
+
}
|
|
1855
|
+
const projectId = resolved.projectId;
|
|
1209
1856
|
const accept = req.headers.accept ?? "";
|
|
1210
1857
|
const wantsNdjson = req.query.format === "ndjson" || accept.includes("application/x-ndjson");
|
|
1211
1858
|
if (!wantsNdjson) {
|
|
@@ -1223,7 +1870,12 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
1223
1870
|
});
|
|
1224
1871
|
// Coarse session descriptor (device/scene/anonymized user). Not raw data, so it
|
|
1225
1872
|
// is not gated by raw-session retention.
|
|
1226
|
-
r.get("/api/v1/sessions/:id/meta", {
|
|
1873
|
+
r.get("/api/v1/sessions/:id/meta", {
|
|
1874
|
+
schema: {
|
|
1875
|
+
params: z.object({ id: z.string().min(1) }),
|
|
1876
|
+
response: { 200: singleRowFor("/api/v1/sessions/:id/meta"), 404: notFoundResponse },
|
|
1877
|
+
},
|
|
1878
|
+
}, async (req, reply) => {
|
|
1227
1879
|
const projectId = await authProject(req, reply, store);
|
|
1228
1880
|
if (!projectId)
|
|
1229
1881
|
return reply;
|
|
@@ -1252,7 +1904,15 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
1252
1904
|
return store.putSceneProxy(projectId, req.body.proxy, req.body.label);
|
|
1253
1905
|
});
|
|
1254
1906
|
// Fetch one scene's representation (including the proxy blob), or 404.
|
|
1255
|
-
r.get("/api/v1/scenes/:sceneId/representation", {
|
|
1907
|
+
r.get("/api/v1/scenes/:sceneId/representation", {
|
|
1908
|
+
schema: {
|
|
1909
|
+
params: sceneParams,
|
|
1910
|
+
response: {
|
|
1911
|
+
200: singleRowFor("/api/v1/scenes/:sceneId/representation"),
|
|
1912
|
+
404: notFoundResponse,
|
|
1913
|
+
},
|
|
1914
|
+
},
|
|
1915
|
+
}, async (req, reply) => {
|
|
1256
1916
|
const projectId = await authProject(req, reply, store);
|
|
1257
1917
|
if (!projectId)
|
|
1258
1918
|
return reply;
|
|
@@ -1261,5 +1921,42 @@ export const queryRoutes = async (app, { store, config }) => {
|
|
|
1261
1921
|
return reply.code(404).send({ error: "scene representation not found" });
|
|
1262
1922
|
return representation;
|
|
1263
1923
|
});
|
|
1924
|
+
// --- Scene regions (ADR 0051 §2 / sketch §B.2) ---------------------------
|
|
1925
|
+
//
|
|
1926
|
+
// Named, labelled boxes that give a scene a vocabulary for *where* — so a
|
|
1927
|
+
// spatial answer can say "the checkout counter" instead of a voxel centre, and
|
|
1928
|
+
// `?region=<id>` can drill a heatmap into that place by name.
|
|
1929
|
+
//
|
|
1930
|
+
// AUTH: the two **reads** take a `query`-capable key, like every other read.
|
|
1931
|
+
// The **write** takes an `annotate`-capable key (#309, ADR 0051 §5/§7) — the
|
|
1932
|
+
// dedicated metadata-write capability, so a read-only key handed to an agent
|
|
1933
|
+
// cannot redraw the project's spatial vocabulary. `annotate` is a write
|
|
1934
|
+
// capability only: mint `query,annotate` for a client that both declares
|
|
1935
|
+
// regions and reads them back.
|
|
1936
|
+
// List every region the project has declared, across all scenes (names only,
|
|
1937
|
+
// no boxes) — the whole spatial vocabulary in one read, for a region picker or
|
|
1938
|
+
// an agent's project context. The sibling of `/scene-representations`.
|
|
1939
|
+
r.get("/api/v1/scene-regions", async (req, reply) => {
|
|
1940
|
+
const projectId = await authProject(req, reply, store);
|
|
1941
|
+
if (!projectId)
|
|
1942
|
+
return reply;
|
|
1943
|
+
return store.listSceneRegions(projectId);
|
|
1944
|
+
});
|
|
1945
|
+
// Declare a scene's regions, replacing whatever was stored for that scene.
|
|
1946
|
+
r.put("/api/v1/scenes/:sceneId/regions", { schema: { params: sceneParams, body: putRegionsBody } }, async (req, reply) => {
|
|
1947
|
+
const resolved = await requireCapability(req, reply, store, "annotate");
|
|
1948
|
+
if (!resolved)
|
|
1949
|
+
return reply;
|
|
1950
|
+
return store.putSceneRegions(resolved.projectId, req.params.sceneId, req.body.regions);
|
|
1951
|
+
});
|
|
1952
|
+
// Read one scene's regions. An unregistered scene is an empty set, not a 404 —
|
|
1953
|
+
// "this scene has no regions" is a normal answer, and the caller need not know
|
|
1954
|
+
// whether the scene was ever registered.
|
|
1955
|
+
r.get("/api/v1/scenes/:sceneId/regions", { schema: { params: sceneParams } }, async (req, reply) => {
|
|
1956
|
+
const projectId = await authProject(req, reply, store);
|
|
1957
|
+
if (!projectId)
|
|
1958
|
+
return reply;
|
|
1959
|
+
return store.getSceneRegions(projectId, req.params.sceneId);
|
|
1960
|
+
});
|
|
1264
1961
|
};
|
|
1265
1962
|
//# sourceMappingURL=query.js.map
|