@uptimizr/collector-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +169 -0
  3. package/dist/app.d.ts +22 -0
  4. package/dist/app.d.ts.map +1 -0
  5. package/dist/app.js +96 -0
  6. package/dist/app.js.map +1 -0
  7. package/dist/cli.d.ts +3 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +148 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/config.d.ts +59 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +55 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/csp.d.ts +18 -0
  16. package/dist/csp.d.ts.map +1 -0
  17. package/dist/csp.js +91 -0
  18. package/dist/csp.js.map +1 -0
  19. package/dist/duckdbStore.d.ts +14 -0
  20. package/dist/duckdbStore.d.ts.map +1 -0
  21. package/dist/duckdbStore.js +69 -0
  22. package/dist/duckdbStore.js.map +1 -0
  23. package/dist/enrich.d.ts +8 -0
  24. package/dist/enrich.d.ts.map +1 -0
  25. package/dist/enrich.js +9 -0
  26. package/dist/enrich.js.map +1 -0
  27. package/dist/liveBus.d.ts +86 -0
  28. package/dist/liveBus.d.ts.map +1 -0
  29. package/dist/liveBus.js +195 -0
  30. package/dist/liveBus.js.map +1 -0
  31. package/dist/liveToken.d.ts +11 -0
  32. package/dist/liveToken.d.ts.map +1 -0
  33. package/dist/liveToken.js +44 -0
  34. package/dist/liveToken.js.map +1 -0
  35. package/dist/memoryStore.d.ts +22 -0
  36. package/dist/memoryStore.d.ts.map +1 -0
  37. package/dist/memoryStore.js +207 -0
  38. package/dist/memoryStore.js.map +1 -0
  39. package/dist/routes/collect.d.ts +25 -0
  40. package/dist/routes/collect.d.ts.map +1 -0
  41. package/dist/routes/collect.js +55 -0
  42. package/dist/routes/collect.js.map +1 -0
  43. package/dist/routes/live.d.ts +18 -0
  44. package/dist/routes/live.d.ts.map +1 -0
  45. package/dist/routes/live.js +179 -0
  46. package/dist/routes/live.js.map +1 -0
  47. package/dist/routes/query.d.ts +14 -0
  48. package/dist/routes/query.d.ts.map +1 -0
  49. package/dist/routes/query.js +610 -0
  50. package/dist/routes/query.js.map +1 -0
  51. package/dist/serve.d.ts +18 -0
  52. package/dist/serve.d.ts.map +1 -0
  53. package/dist/serve.js +48 -0
  54. package/dist/serve.js.map +1 -0
  55. package/dist/server.d.ts +3 -0
  56. package/dist/server.d.ts.map +1 -0
  57. package/dist/server.js +12 -0
  58. package/dist/server.js.map +1 -0
  59. package/dist/store.d.ts +263 -0
  60. package/dist/store.d.ts.map +1 -0
  61. package/dist/store.js +2 -0
  62. package/dist/store.js.map +1 -0
  63. package/dist/visitor.d.ts +12 -0
  64. package/dist/visitor.d.ts.map +1 -0
  65. package/dist/visitor.js +18 -0
  66. package/dist/visitor.js.map +1 -0
  67. package/package.json +64 -0
@@ -0,0 +1,610 @@
1
+ import { Readable } from "node:stream";
2
+ import { z } from "zod";
3
+ import { sceneProxySchema } from "@uptimizr/schema";
4
+ /** Developer-assigned scene/area filter (ADR 0010). */
5
+ const sceneFilter = z
6
+ .string()
7
+ .regex(/^[A-Za-z0-9._:-]{1,64}$/)
8
+ .optional();
9
+ /** Input-source filter (ADR 0011): restrict a heatmap to one kind of pointer. */
10
+ const sourceFilter = z
11
+ .enum(["mouse", "touch", "stylus", "pen", "xr-controller", "hand", "gaze", "transient", "other"])
12
+ .optional();
13
+ /** Single-session filter: scope an aggregate to one session id. */
14
+ const sessionFilter = z.string().min(1).max(128).optional();
15
+ /**
16
+ * Camera-mode filter (ADR 0026): the dashboard's high-level viewer/first-person
17
+ * toggle. `viewer` selects orbit/arc-rotate sessions, `first-person` selects
18
+ * free/walkable sessions. Translated to the stored `cameraType` via
19
+ * {@link cameraTypeForMode} before it reaches the aggregation layer.
20
+ */
21
+ const cameraModeFilter = z.enum(["viewer", "first-person"]).optional();
22
+ /** Map the dashboard camera-mode toggle to the stored `cameraType` value. */
23
+ function cameraTypeForMode(mode) {
24
+ if (mode === "first-person")
25
+ return "free";
26
+ if (mode === "viewer")
27
+ return "arc-rotate";
28
+ return undefined;
29
+ }
30
+ /** Shared time-range + binning query parameters. */
31
+ const rangeQuery = z.object({
32
+ since: z.coerce.number().int().optional(),
33
+ until: z.coerce.number().int().optional(),
34
+ bins: z.coerce.number().int().positive().max(500).optional(),
35
+ limit: z.coerce.number().int().positive().max(1000).optional(),
36
+ });
37
+ /** Sessions-list params: a time range, result cap, and camera-mode filter. */
38
+ const sessionsQueryParams = rangeQuery.extend({ cameraMode: cameraModeFilter });
39
+ /** Range params that also accept a single-session filter. */
40
+ const sessionScopedRangeQuery = rangeQuery.extend({ session: sessionFilter });
41
+ /** Bin-based heatmap params (camera) with optional scene + session filters. */
42
+ const heatmapQueryParams = rangeQuery.extend({ scene: sceneFilter, session: sessionFilter });
43
+ /** FPS-histogram params: range + scene/session filters plus an FPS `bucket` width. */
44
+ const fpsHistogramQueryParams = heatmapQueryParams.extend({
45
+ bucket: z.coerce.number().int().positive().max(240).optional(),
46
+ });
47
+ /** Camera direction/position heatmap params: bins + scene/session + camera-mode. */
48
+ const cameraHeatmapQueryParams = heatmapQueryParams.extend({ cameraMode: cameraModeFilter });
49
+ /** Pointer heatmap params: scene + optional input-source + session filters. */
50
+ const pointerHeatmapQueryParams = heatmapQueryParams.extend({
51
+ source: sourceFilter,
52
+ cameraMode: cameraModeFilter,
53
+ });
54
+ /** Rage-click params: pointer filters + burst window (sec) and repeat threshold. */
55
+ const rageClickQueryParams = pointerHeatmapQueryParams.extend({
56
+ interval: z.coerce.number().int().positive().max(60).optional(),
57
+ minRepeats: z.coerce.number().int().min(2).max(100).optional(),
58
+ });
59
+ /** World heatmap params: a positive voxel `cellSize` (world units) instead of bins. */
60
+ const worldHeatmapQueryParams = z.object({
61
+ since: z.coerce.number().int().optional(),
62
+ until: z.coerce.number().int().optional(),
63
+ cellSize: z.coerce.number().positive().max(1000).optional(),
64
+ limit: z.coerce.number().int().positive().max(1000).optional(),
65
+ scene: sceneFilter,
66
+ source: sourceFilter,
67
+ cameraMode: cameraModeFilter,
68
+ });
69
+ /**
70
+ * Gaze heatmap params (ADR 0030): a positive voxel `cellSize` plus scene/session
71
+ * + camera-mode filters. Unlike the pointer world heatmap there is no `source`
72
+ * (a camera-pose gaze hit has no input-source); a `session` scopes it to one visit.
73
+ */
74
+ const gazeHeatmapQueryParams = z.object({
75
+ since: z.coerce.number().int().optional(),
76
+ until: z.coerce.number().int().optional(),
77
+ cellSize: z.coerce.number().positive().max(1000).optional(),
78
+ limit: z.coerce.number().int().positive().max(1000).optional(),
79
+ scene: sceneFilter,
80
+ session: sessionFilter,
81
+ cameraMode: cameraModeFilter,
82
+ });
83
+ /**
84
+ * Floor-plan camera-position heatmap params (ADR 0026): a ground-plane bin
85
+ * `cellSize` (world units) plus scene/session/camera-mode filters and a cap.
86
+ */
87
+ const cameraPositionQueryParams = z.object({
88
+ since: z.coerce.number().int().optional(),
89
+ until: z.coerce.number().int().optional(),
90
+ cellSize: z.coerce.number().positive().max(1000).optional(),
91
+ limit: z.coerce.number().int().positive().max(10000).optional(),
92
+ scene: sceneFilter,
93
+ session: sessionFilter,
94
+ cameraMode: cameraModeFilter,
95
+ });
96
+ /** Session-trajectory params: a time range, scene filter, and point cap. */
97
+ const trajectoryQueryParams = z.object({
98
+ since: z.coerce.number().int().optional(),
99
+ until: z.coerce.number().int().optional(),
100
+ limit: z.coerce.number().int().positive().max(10000).optional(),
101
+ scene: sceneFilter,
102
+ });
103
+ /** Path param for a single session's trajectory. */
104
+ const sessionPathParams = z.object({
105
+ sessionId: z.string().min(1).max(128),
106
+ });
107
+ /** Click-ray params: voxel `cellSize` + scene/source/session filters + result cap. */
108
+ const clickRayQueryParams = z.object({
109
+ since: z.coerce.number().int().optional(),
110
+ until: z.coerce.number().int().optional(),
111
+ cellSize: z.coerce.number().positive().max(1000).optional(),
112
+ limit: z.coerce.number().int().positive().max(1000).optional(),
113
+ scene: sceneFilter,
114
+ source: sourceFilter,
115
+ session: sessionFilter,
116
+ });
117
+ /**
118
+ * Flow params: camera-direction bins joined to clicked meshes (§7.5), plus the
119
+ * optional position-aware dimension (§7.8): a standpoint voxel `cellSize`, a
120
+ * `groupByOrigin` toggle, an `originVoxel` (`"vx,vy,vz"`) standpoint filter, and
121
+ * the camera-mode filter that makes the panel walkable-scene aware (ADR 0026).
122
+ */
123
+ const flowHeatmapQueryParams = heatmapQueryParams.extend({
124
+ cameraMode: cameraModeFilter,
125
+ cellSize: z.coerce.number().positive().max(1000).optional(),
126
+ groupByOrigin: z.enum(["true", "false", "1", "0"]).optional(),
127
+ originVoxel: z
128
+ .string()
129
+ .regex(/^-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?$/)
130
+ .optional(),
131
+ });
132
+ /** Distinct-scenes params: a time range plus a result cap. */
133
+ const scenesQueryParams = z.object({
134
+ since: z.coerce.number().int().optional(),
135
+ until: z.coerce.number().int().optional(),
136
+ limit: z.coerce.number().int().positive().max(1000).optional(),
137
+ });
138
+ /** Time-series params: range + scene + optional event-type filter + bucket interval (seconds). */
139
+ const timeseriesQueryParams = z.object({
140
+ since: z.coerce.number().int().optional(),
141
+ until: z.coerce.number().int().optional(),
142
+ interval: z.coerce.number().int().positive().max(31_536_000).optional(),
143
+ scene: sceneFilter,
144
+ type: z
145
+ .string()
146
+ .regex(/^[a-z_]{1,40}$/)
147
+ .optional(),
148
+ });
149
+ /** Event-type-counts params: range + optional scene filter. */
150
+ const eventCountsQueryParams = z.object({
151
+ since: z.coerce.number().int().optional(),
152
+ until: z.coerce.number().int().optional(),
153
+ scene: sceneFilter,
154
+ });
155
+ /** Scene-coverage params: voxel `cellSize` + scene/session filters + result cap. */
156
+ const coverageQueryParams = z.object({
157
+ since: z.coerce.number().int().optional(),
158
+ until: z.coerce.number().int().optional(),
159
+ cellSize: z.coerce.number().positive().max(1000).optional(),
160
+ limit: z.coerce.number().int().positive().max(10000).optional(),
161
+ scene: sceneFilter,
162
+ session: sessionFilter,
163
+ });
164
+ /** Camera-distance params: reference `center` (3 coords) + `bucketSize` + filters. */
165
+ const cameraDistanceQueryParams = z.object({
166
+ since: z.coerce.number().int().optional(),
167
+ until: z.coerce.number().int().optional(),
168
+ centerX: z.coerce.number().optional(),
169
+ centerY: z.coerce.number().optional(),
170
+ centerZ: z.coerce.number().optional(),
171
+ bucketSize: z.coerce.number().positive().max(1000).optional(),
172
+ limit: z.coerce.number().int().positive().max(1000).optional(),
173
+ scene: sceneFilter,
174
+ session: sessionFilter,
175
+ });
176
+ /** Navigation-stats params: idle/active `moveThreshold` + scene/session filters. */
177
+ const navigationQueryParams = z.object({
178
+ since: z.coerce.number().int().optional(),
179
+ until: z.coerce.number().int().optional(),
180
+ moveThreshold: z.coerce.number().nonnegative().max(1000).optional(),
181
+ limit: z.coerce.number().int().positive().max(1000).optional(),
182
+ scene: sceneFilter,
183
+ session: sessionFilter,
184
+ });
185
+ /** XR rotation-rate params: `rapidTurn` (rad) threshold + scene/session filters. */
186
+ const xrRotationQueryParams = z.object({
187
+ since: z.coerce.number().int().optional(),
188
+ until: z.coerce.number().int().optional(),
189
+ rapidTurn: z.coerce.number().nonnegative().max(Math.PI).optional(),
190
+ limit: z.coerce.number().int().positive().max(1000).optional(),
191
+ scene: sceneFilter,
192
+ session: sessionFilter,
193
+ });
194
+ /** Path param for a single scene's representation. */
195
+ const sceneParams = z.object({
196
+ sceneId: z.string().regex(/^[A-Za-z0-9._:-]{1,64}$/),
197
+ });
198
+ /** Body for registering a scene proxy: the proxy plus an optional display label. */
199
+ const putRepresentationBody = z.object({
200
+ proxy: sceneProxySchema,
201
+ label: z.string().max(200).optional(),
202
+ });
203
+ /**
204
+ * Authenticate a read request with a project API key (`x-api-key`). Returns the
205
+ * resolved project id, or sends a 401 and returns `null`. Reads are always scoped
206
+ * to the authenticated project — any client-supplied project id is ignored.
207
+ */
208
+ async function authProject(request, reply, store) {
209
+ const key = request.headers["x-api-key"];
210
+ if (typeof key !== "string" || key.length === 0) {
211
+ await reply.code(401).send({ error: "missing api key" });
212
+ return null;
213
+ }
214
+ const projectId = await store.resolveApiKey(key);
215
+ if (!projectId) {
216
+ await reply.code(401).send({ error: "invalid api key" });
217
+ return null;
218
+ }
219
+ // Read endpoints require a `query`-capable key (ingest-only keys cannot read).
220
+ if (projectId.capability !== "query") {
221
+ await reply.code(403).send({ error: "api key not permitted to read" });
222
+ return null;
223
+ }
224
+ return projectId.projectId;
225
+ }
226
+ /**
227
+ * Query API. All aggregations are computed at query time (v1). Every route is
228
+ * scoped to the authenticated project.
229
+ */
230
+ export const queryRoutes = async (app, { store, config }) => {
231
+ const r = app.withTypeProvider();
232
+ r.get("/api/v1/sessions", { schema: { querystring: sessionsQueryParams } }, async (req, reply) => {
233
+ const projectId = await authProject(req, reply, store);
234
+ if (!projectId)
235
+ return reply;
236
+ const { cameraMode, ...rest } = req.query;
237
+ return store.listSessions(projectId, { ...rest, cameraType: cameraTypeForMode(cameraMode) });
238
+ });
239
+ r.get("/api/v1/heatmaps/pointer", { schema: { querystring: pointerHeatmapQueryParams } }, async (req, reply) => {
240
+ const projectId = await authProject(req, reply, store);
241
+ if (!projectId)
242
+ return reply;
243
+ const { cameraMode, ...rest } = req.query;
244
+ return store.pointerHeatmap(projectId, {
245
+ ...rest,
246
+ cameraType: cameraTypeForMode(cameraMode),
247
+ });
248
+ });
249
+ r.get("/api/v1/heatmaps/world", { schema: { querystring: worldHeatmapQueryParams } }, async (req, reply) => {
250
+ const projectId = await authProject(req, reply, store);
251
+ if (!projectId)
252
+ return reply;
253
+ const { cameraMode, ...rest } = req.query;
254
+ return store.worldHeatmap(projectId, { ...rest, cameraType: cameraTypeForMode(cameraMode) });
255
+ });
256
+ // World-space gaze heatmap (ADR 0030) — the "what did people actually look at"
257
+ // sibling of the world (click) heatmap: voxel-binned `camera_sample` gaze hits.
258
+ r.get("/api/v1/heatmaps/gaze", { schema: { querystring: gazeHeatmapQueryParams } }, async (req, reply) => {
259
+ const projectId = await authProject(req, reply, store);
260
+ if (!projectId)
261
+ return reply;
262
+ const { cameraMode, ...rest } = req.query;
263
+ return store.gazeHeatmap(projectId, { ...rest, cameraType: cameraTypeForMode(cameraMode) });
264
+ });
265
+ r.get("/api/v1/heatmaps/camera", { schema: { querystring: cameraHeatmapQueryParams } }, async (req, reply) => {
266
+ const projectId = await authProject(req, reply, store);
267
+ if (!projectId)
268
+ return reply;
269
+ const { cameraMode, ...rest } = req.query;
270
+ return store.cameraHeatmap(projectId, { ...rest, cameraType: cameraTypeForMode(cameraMode) });
271
+ });
272
+ // Floor-plan camera-position heatmap (ADR 0026) — the first-person analog of
273
+ // the 2D pointer heatmap: where visitors stand/dwell on the X/Z ground plane.
274
+ r.get("/api/v1/heatmaps/position", { schema: { querystring: cameraPositionQueryParams } }, async (req, reply) => {
275
+ const projectId = await authProject(req, reply, store);
276
+ if (!projectId)
277
+ return reply;
278
+ const { cameraMode, ...rest } = req.query;
279
+ return store.cameraPositionHeatmap(projectId, {
280
+ ...rest,
281
+ cameraType: cameraTypeForMode(cameraMode),
282
+ });
283
+ });
284
+ // One session's ordered walked path (ADR 0026) — camera positions, oldest first.
285
+ r.get("/api/v1/sessions/:sessionId/trajectory", { schema: { params: sessionPathParams, querystring: trajectoryQueryParams } }, async (req, reply) => {
286
+ const projectId = await authProject(req, reply, store);
287
+ if (!projectId)
288
+ return reply;
289
+ return store.sessionTrajectory(projectId, req.params.sessionId, req.query);
290
+ });
291
+ // View-gated click rays (design §7.2/§7.3) — camera-origin → hit rays per voxel/mesh.
292
+ r.get("/api/v1/heatmaps/click-rays", { schema: { querystring: clickRayQueryParams } }, async (req, reply) => {
293
+ const projectId = await authProject(req, reply, store);
294
+ if (!projectId)
295
+ return reply;
296
+ return store.clickGazeRays(projectId, req.query);
297
+ });
298
+ // Aggregate gaze→mesh flow links (design §7.5): direction bins → clicked meshes.
299
+ // Position-aware mode (§7.8): a standpoint voxel dimension for walkable scenes.
300
+ r.get("/api/v1/heatmaps/flow", { schema: { querystring: flowHeatmapQueryParams } }, async (req, reply) => {
301
+ const projectId = await authProject(req, reply, store);
302
+ if (!projectId)
303
+ return reply;
304
+ const { cameraMode, groupByOrigin, originVoxel, ...rest } = req.query;
305
+ return store.flowHeatmap(projectId, {
306
+ ...rest,
307
+ cameraType: cameraTypeForMode(cameraMode),
308
+ groupByOrigin: groupByOrigin === "true" || groupByOrigin === "1",
309
+ originVoxel: originVoxel
310
+ ? originVoxel.split(",").map(Number)
311
+ : undefined,
312
+ });
313
+ });
314
+ r.get("/api/v1/meshes/top", { schema: { querystring: sessionScopedRangeQuery } }, async (req, reply) => {
315
+ const projectId = await authProject(req, reply, store);
316
+ if (!projectId)
317
+ return reply;
318
+ return store.topMeshes(projectId, req.query);
319
+ });
320
+ // Object dwell ranking (#37) — per-mesh attention from `mesh_visibility`
321
+ // summaries (total visible/centered time, peak screen fraction).
322
+ r.get("/api/v1/meshes/dwell", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
323
+ const projectId = await authProject(req, reply, store);
324
+ if (!projectId)
325
+ return reply;
326
+ return store.meshDwell(projectId, req.query);
327
+ });
328
+ // Dead-click rate (#46) — total clicks vs. clicks that hit empty space; a 3D
329
+ // discoverability signal. The consumer derives the rate from the two counts.
330
+ r.get("/api/v1/clicks/dead", { schema: { querystring: pointerHeatmapQueryParams } }, async (req, reply) => {
331
+ const projectId = await authProject(req, reply, store);
332
+ if (!projectId)
333
+ return reply;
334
+ return store.deadClicks(projectId, req.query);
335
+ });
336
+ // Rage clicks (#47) — rapid repeated clicks on the same mesh within a short
337
+ // window; a frustration signal derived from the click stream.
338
+ r.get("/api/v1/clicks/rage", { schema: { querystring: rageClickQueryParams } }, async (req, reply) => {
339
+ const projectId = await authProject(req, reply, store);
340
+ if (!projectId)
341
+ return reply;
342
+ return store.rageClicks(projectId, req.query);
343
+ });
344
+ // Hover hesitation (#48) — per-mesh dwell spent hovering an object without
345
+ // clicking it; flags things that look interactive but aren't.
346
+ r.get("/api/v1/hover/dwell", { schema: { querystring: pointerHeatmapQueryParams } }, async (req, reply) => {
347
+ const projectId = await authProject(req, reply, store);
348
+ if (!projectId)
349
+ return reply;
350
+ return store.hoverDwell(projectId, req.query);
351
+ });
352
+ // Compile stalls (#42) — per-phase shader/pipeline compilation hitches; the
353
+ // felt first-interaction jank that `frame_perf` averages away.
354
+ r.get("/api/v1/perf/compile-stalls", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
355
+ const projectId = await authProject(req, reply, store);
356
+ if (!projectId)
357
+ return reply;
358
+ return store.compileStalls(projectId, req.query);
359
+ });
360
+ r.get("/api/v1/perf", { schema: { querystring: sessionScopedRangeQuery } }, async (req, reply) => {
361
+ const projectId = await authProject(req, reply, store);
362
+ if (!projectId)
363
+ return reply;
364
+ return store.perfSummary(projectId, req.query);
365
+ });
366
+ // Resource footprint (#44) — GPU / memory cost summary (avg + peak texture/
367
+ // geometry bytes, triangles/vertices, JS heap) the scene asked of the device.
368
+ r.get("/api/v1/perf/resources", { schema: { querystring: sessionScopedRangeQuery } }, async (req, reply) => {
369
+ const projectId = await authProject(req, reply, store);
370
+ if (!projectId)
371
+ return reply;
372
+ return store.resourceSummary(projectId, req.query);
373
+ });
374
+ // FPS distribution (#81, ADR 0028 §1) — per-session p05/p50/p95 FPS summarized
375
+ // across sessions (median-of-medians); the distribution-honest headline that
376
+ // replaces the volume-chart mean.
377
+ r.get("/api/v1/perf/distribution", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
378
+ const projectId = await authProject(req, reply, store);
379
+ if (!projectId)
380
+ return reply;
381
+ return store.perfDistribution(projectId, req.query);
382
+ });
383
+ // FPS histogram (#81, ADR 0028 §1) — per-session median FPS bucketed into
384
+ // `bucket`-wide bins; one session = one data point.
385
+ r.get("/api/v1/perf/fps-histogram", { schema: { querystring: fpsHistogramQueryParams } }, async (req, reply) => {
386
+ const projectId = await authProject(req, reply, store);
387
+ if (!projectId)
388
+ return reply;
389
+ return store.fpsHistogram(projectId, req.query);
390
+ });
391
+ // Frame-time percentiles (#81, ADR 0028 §1) — per-session median frame time and
392
+ // worst-window p95 (ms), summarized across sessions.
393
+ r.get("/api/v1/perf/frame-time", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
394
+ const projectId = await authProject(req, reply, store);
395
+ if (!projectId)
396
+ return reply;
397
+ return store.frameTimePercentiles(projectId, req.query);
398
+ });
399
+ // Jank rate (#81, ADR 0028 §1) — per-session long-frames-per-window rate,
400
+ // reported as the median and worst-decile session.
401
+ r.get("/api/v1/perf/jank", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
402
+ const projectId = await authProject(req, reply, store);
403
+ if (!projectId)
404
+ return reply;
405
+ return store.jankRate(projectId, req.query);
406
+ });
407
+ // FPS by device class (#82, ADR 0028 §2) — per-session median FPS attributed to
408
+ // the `session_start.device` block (backend / mobile / GPU renderer).
409
+ r.get("/api/v1/perf/by-device", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
410
+ const projectId = await authProject(req, reply, store);
411
+ if (!projectId)
412
+ return reply;
413
+ return store.perfByDevice(projectId, req.query);
414
+ });
415
+ // FPS by scene (#82, ADR 0028 §1) — per-session median FPS grouped by scene.
416
+ r.get("/api/v1/perf/by-scene", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
417
+ const projectId = await authProject(req, reply, store);
418
+ if (!projectId)
419
+ return reply;
420
+ return store.perfByScene(projectId, req.query);
421
+ });
422
+ // Resource-footprint percentiles (#83, ADR 0028 §1) — per-session p50/p95 of JS
423
+ // heap, texture bytes, and triangle count, summarized across sessions.
424
+ r.get("/api/v1/perf/resource-percentiles", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
425
+ const projectId = await authProject(req, reply, store);
426
+ if (!projectId)
427
+ return reply;
428
+ return store.resourcePercentiles(projectId, req.query);
429
+ });
430
+ // Stability incidents (#83) — context-loss and compile-stall counts over the
431
+ // range; the hard failures `frame_perf` cannot show.
432
+ r.get("/api/v1/perf/stability", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
433
+ const projectId = await authProject(req, reply, store);
434
+ if (!projectId)
435
+ return reply;
436
+ return store.stabilityCounts(projectId, req.query);
437
+ });
438
+ // Capability changes (#49) — per-transition fallback/recovery counts (e.g. how
439
+ // many sessions fell back WebGPU→WebGL2); explains perf / visual-fidelity
440
+ // variance across the user base. App-reported via reportCapabilityChange.
441
+ r.get("/api/v1/capabilities", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
442
+ const projectId = await authProject(req, reply, store);
443
+ if (!projectId)
444
+ return reply;
445
+ return store.capabilityChanges(projectId, req.query);
446
+ });
447
+ // Camera gestures (ADR 0025) — per-kind navigation breakdown (orbit / pan /
448
+ // dolly / zoom / roll / fly) separating deliberate viewpoint movement from
449
+ // object selection; reveals how an audience explores the scene.
450
+ r.get("/api/v1/camera-gestures", { schema: { querystring: pointerHeatmapQueryParams } }, async (req, reply) => {
451
+ const projectId = await authProject(req, reply, store);
452
+ if (!projectId)
453
+ return reply;
454
+ return store.cameraGestures(projectId, req.query);
455
+ });
456
+ // Scene coverage / dead zones (derived, scene-metrics §B) — occupied
457
+ // camera-position voxels; coverage % is layered in against the scene AABB.
458
+ r.get("/api/v1/coverage", { schema: { querystring: coverageQueryParams } }, async (req, reply) => {
459
+ const projectId = await authProject(req, reply, store);
460
+ if (!projectId)
461
+ return reply;
462
+ return store.sceneCoverage(projectId, req.query);
463
+ });
464
+ // Camera distance / zoom distribution (derived, scene-metrics §B) — histogram of
465
+ // camera-to-center distance. The center defaults to the origin when omitted.
466
+ r.get("/api/v1/camera/distance", { schema: { querystring: cameraDistanceQueryParams } }, async (req, reply) => {
467
+ const projectId = await authProject(req, reply, store);
468
+ if (!projectId)
469
+ return reply;
470
+ const { centerX, centerY, centerZ, ...rest } = req.query;
471
+ const center = centerX != null || centerY != null || centerZ != null
472
+ ? [centerX ?? 0, centerY ?? 0, centerZ ?? 0]
473
+ : undefined;
474
+ return store.cameraDistance(projectId, { ...rest, center });
475
+ });
476
+ // Navigation effort / friction (derived, scene-metrics §B) — per-session travel
477
+ // distance with active-vs-idle segmentation.
478
+ r.get("/api/v1/navigation", { schema: { querystring: navigationQueryParams } }, async (req, reply) => {
479
+ const projectId = await authProject(req, reply, store);
480
+ if (!projectId)
481
+ return reply;
482
+ return store.navigationStats(projectId, req.query);
483
+ });
484
+ // XR motion-sickness proxy (#50, scene-metrics §F) — per-session head/view
485
+ // rotation rate over the camera pose stream; rapid rotation flags discomfort.
486
+ r.get("/api/v1/xr/rotation", { schema: { querystring: xrRotationQueryParams } }, async (req, reply) => {
487
+ const projectId = await authProject(req, reply, store);
488
+ if (!projectId)
489
+ return reply;
490
+ return store.xrRotationRate(projectId, req.query);
491
+ });
492
+ // XR input-source usage (#50, scene-metrics §F) — hand vs. controller (vs.
493
+ // gaze) split read from `source` on the interaction events.
494
+ r.get("/api/v1/xr/sources", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
495
+ const projectId = await authProject(req, reply, store);
496
+ if (!projectId)
497
+ return reply;
498
+ return store.xrSourceUsage(projectId, req.query);
499
+ });
500
+ // XR session abandonment (#50, scene-metrics §F) — per XR session, its time
501
+ // bounds and event/interaction counts; a short span signals headset drop-off.
502
+ r.get("/api/v1/xr/abandonment", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
503
+ const projectId = await authProject(req, reply, store);
504
+ if (!projectId)
505
+ return reply;
506
+ return store.xrAbandonment(projectId, req.query);
507
+ });
508
+ // Input-source breakdown (ADR 0011) — per (event_type, source), how many
509
+ // interactions came from each input source (mouse / touch / xr-controller /
510
+ // hand / …) and across how many sessions. Turns `source` into an insight.
511
+ r.get("/api/v1/interactions/sources", { schema: { querystring: pointerHeatmapQueryParams } }, async (req, reply) => {
512
+ const projectId = await authProject(req, reply, store);
513
+ if (!projectId)
514
+ return reply;
515
+ return store.interactionsBySource(projectId, req.query);
516
+ });
517
+ // Distinct scenes for the project (ADR 0010) — powers the scene selector.
518
+ r.get("/api/v1/scenes", { schema: { querystring: scenesQueryParams } }, async (req, reply) => {
519
+ const projectId = await authProject(req, reply, store);
520
+ if (!projectId)
521
+ return reply;
522
+ return store.scenes(projectId, req.query);
523
+ });
524
+ // Event-volume time-series (the 4th dimension) — bucketed event counts + FPS.
525
+ r.get("/api/v1/timeseries", { schema: { querystring: timeseriesQueryParams } }, async (req, reply) => {
526
+ const projectId = await authProject(req, reply, store);
527
+ if (!projectId)
528
+ return reply;
529
+ return store.timeseries(projectId, req.query);
530
+ });
531
+ // Per-event-type counts over the range — powers the scene health panel.
532
+ r.get("/api/v1/event-counts", { schema: { querystring: eventCountsQueryParams } }, async (req, reply) => {
533
+ const projectId = await authProject(req, reply, store);
534
+ if (!projectId)
535
+ return reply;
536
+ return store.eventTypeCounts(projectId, req.query);
537
+ });
538
+ // Ordered session timeline for replay — gated by raw-session retention (ADR 0003).
539
+ // Negotiates NDJSON (`Accept: application/x-ndjson` or `?format=ndjson`): when
540
+ // requested, events stream one-per-line from ClickHouse with bounded server
541
+ // memory (ADR 0015); otherwise the buffered JSON array stays the default.
542
+ r.get("/api/v1/sessions/:id/events", {
543
+ schema: {
544
+ params: z.object({ id: z.string().min(1) }),
545
+ querystring: z.object({ format: z.enum(["json", "ndjson"]).optional() }),
546
+ },
547
+ }, async (req, reply) => {
548
+ const projectId = await authProject(req, reply, store);
549
+ if (!projectId)
550
+ return reply;
551
+ if (!config.enableRawSessionRetention) {
552
+ return reply.code(403).send({ error: "raw session retention is disabled" });
553
+ }
554
+ const accept = req.headers.accept ?? "";
555
+ const wantsNdjson = req.query.format === "ndjson" || accept.includes("application/x-ndjson");
556
+ if (!wantsNdjson) {
557
+ return store.getSessionEvents(projectId, req.params.id);
558
+ }
559
+ // Stream NDJSON: serialize each event as its own line. Returning a Readable
560
+ // lets Fastify pipe it directly, bypassing whole-body serialization.
561
+ const lines = (async function* () {
562
+ for await (const event of store.streamSessionEvents(projectId, req.params.id)) {
563
+ yield `${JSON.stringify(event)}\n`;
564
+ }
565
+ })();
566
+ reply.header("content-type", "application/x-ndjson; charset=utf-8");
567
+ return reply.send(Readable.from(lines, { objectMode: false }));
568
+ });
569
+ // Coarse session descriptor (device/scene/anonymized user). Not raw data, so it
570
+ // is not gated by raw-session retention.
571
+ r.get("/api/v1/sessions/:id/meta", { schema: { params: z.object({ id: z.string().min(1) }) } }, async (req, reply) => {
572
+ const projectId = await authProject(req, reply, store);
573
+ if (!projectId)
574
+ return reply;
575
+ const meta = await store.getSessionMeta(projectId, req.params.id);
576
+ if (!meta)
577
+ return reply.code(404).send({ error: "session not found" });
578
+ return meta;
579
+ });
580
+ // --- Spatial scene registry (ADR 0010 / 0014) ----------------------------
581
+ // List the project's registered scene representations (summaries, no proxy blob).
582
+ r.get("/api/v1/scene-representations", async (req, reply) => {
583
+ const projectId = await authProject(req, reply, store);
584
+ if (!projectId)
585
+ return reply;
586
+ return store.listSceneRepresentations(projectId);
587
+ });
588
+ // Register/replace a scene's proxy geometry. The path scene id must match the
589
+ // proxy's own `sceneId` (the proxy is the source of truth for the geometry).
590
+ r.put("/api/v1/scenes/:sceneId/representation", { schema: { params: sceneParams, body: putRepresentationBody } }, async (req, reply) => {
591
+ const projectId = await authProject(req, reply, store);
592
+ if (!projectId)
593
+ return reply;
594
+ if (req.body.proxy.sceneId !== req.params.sceneId) {
595
+ return reply.code(400).send({ error: "proxy sceneId does not match path" });
596
+ }
597
+ return store.putSceneProxy(projectId, req.body.proxy, req.body.label);
598
+ });
599
+ // Fetch one scene's representation (including the proxy blob), or 404.
600
+ r.get("/api/v1/scenes/:sceneId/representation", { schema: { params: sceneParams } }, async (req, reply) => {
601
+ const projectId = await authProject(req, reply, store);
602
+ if (!projectId)
603
+ return reply;
604
+ const representation = await store.getSceneRepresentation(projectId, req.params.sceneId);
605
+ if (!representation)
606
+ return reply.code(404).send({ error: "scene representation not found" });
607
+ return representation;
608
+ });
609
+ };
610
+ //# sourceMappingURL=query.js.map