@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.
- package/LICENSE +201 -0
- package/README.md +169 -0
- package/dist/app.d.ts +22 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +96 -0
- package/dist/app.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +148 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +59 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +55 -0
- package/dist/config.js.map +1 -0
- package/dist/csp.d.ts +18 -0
- package/dist/csp.d.ts.map +1 -0
- package/dist/csp.js +91 -0
- package/dist/csp.js.map +1 -0
- package/dist/duckdbStore.d.ts +14 -0
- package/dist/duckdbStore.d.ts.map +1 -0
- package/dist/duckdbStore.js +69 -0
- package/dist/duckdbStore.js.map +1 -0
- package/dist/enrich.d.ts +8 -0
- package/dist/enrich.d.ts.map +1 -0
- package/dist/enrich.js +9 -0
- package/dist/enrich.js.map +1 -0
- package/dist/liveBus.d.ts +86 -0
- package/dist/liveBus.d.ts.map +1 -0
- package/dist/liveBus.js +195 -0
- package/dist/liveBus.js.map +1 -0
- package/dist/liveToken.d.ts +11 -0
- package/dist/liveToken.d.ts.map +1 -0
- package/dist/liveToken.js +44 -0
- package/dist/liveToken.js.map +1 -0
- package/dist/memoryStore.d.ts +22 -0
- package/dist/memoryStore.d.ts.map +1 -0
- package/dist/memoryStore.js +207 -0
- package/dist/memoryStore.js.map +1 -0
- package/dist/routes/collect.d.ts +25 -0
- package/dist/routes/collect.d.ts.map +1 -0
- package/dist/routes/collect.js +55 -0
- package/dist/routes/collect.js.map +1 -0
- package/dist/routes/live.d.ts +18 -0
- package/dist/routes/live.d.ts.map +1 -0
- package/dist/routes/live.js +179 -0
- package/dist/routes/live.js.map +1 -0
- package/dist/routes/query.d.ts +14 -0
- package/dist/routes/query.d.ts.map +1 -0
- package/dist/routes/query.js +610 -0
- package/dist/routes/query.js.map +1 -0
- package/dist/serve.d.ts +18 -0
- package/dist/serve.d.ts.map +1 -0
- package/dist/serve.js +48 -0
- package/dist/serve.js.map +1 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +12 -0
- package/dist/server.js.map +1 -0
- package/dist/store.d.ts +263 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +2 -0
- package/dist/store.js.map +1 -0
- package/dist/visitor.d.ts +12 -0
- package/dist/visitor.d.ts.map +1 -0
- package/dist/visitor.js +18 -0
- package/dist/visitor.js.map +1 -0
- 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
|