Query endpoints
All read endpoints take x-api-key and the shared parameters. Reads are
scoped to the project resolved from the key. Below are the most-used endpoints, grouped by need; see
the overview for auth and parameter units.
Result formats
Section titled “Result formats”Every aggregate endpoint below accepts format=full | table | summary. It filters nothing — it
picks the envelope the rows arrive in.
format |
Response |
|---|---|
full |
The endpoint default. The bare rows, unchanged. What the dashboard uses. |
table |
The agent tools’ default. { meta, rows } — the same rows plus the metric, range, applied filters, sample size, row count, whether the cap truncated them, and limits. |
summary |
A bounded digest: top rows, a trend or spatial clusters, with shares, a sample size, a templated reading sentence and the metric’s caveats. |
The generated agent tools (@uptimizr/agent-core, @uptimizr/mcp) send format=table when the
caller names none, so a model gets the meta context with its rows; the endpoint itself still
defaults to full for every other client.
summary is the one to use from an assistant or an MCP client: it is capped at the metric’s
maxSummaryRows, so a 500-bin heatmap costs the same as a 5-bin one. Its shape follows what one row
of the metric is — ranked top rows for a leaderboard, a series (first/last/min/max/trend/slope)
for a time bucket, merged clusters for a binned or voxelised heatmap, and the record itself for a
single-row metric.
curl -H "x-api-key: $KEY" \ "https://collect.example.com/api/v1/meshes/top?since=$SINCE&format=summary"{ "kind": "ranked", "metric": "top_meshes", "sampleSize": { "sessions": null, "events": 9130 }, "total": 9130, "top": [{ "label": "checkout_button", "value": 2210, "share": 0.242 }], "rest": { "rows": 61, "value": 5430, "share": 0.595 }, "reading": "Most-interacted meshes: checkout_button leads on count with 2,210 (24.2% of 9,130)…", "caveats": ["Rows backed by fewer than ~30 events are directional only — …"],}Labelled spatial clusters
Section titled “Labelled spatial clusters”A hotspot at centroid: [7, 2, 11] is unreadable. When the selected scene has a registered
proxy and regions, every cluster of a world-space heatmap is labelled with the
scene’s own vocabulary, so a spatial answer can be written in words:
| Field | What it is |
|---|---|
region |
the smallest containing region by volume, null when none contains it |
regions |
every containing region id, ascending — regions may overlap |
nearestMesh |
a proxy mesh whose box contains the centroid, else the nearest box centre within cellSize × 2 |
distance |
world units to that mesh, 0 when its box contains the hotspot |
// GET /api/v1/heatmaps/world?scene=lobby&format=summary{ "kind": "clusters", "clusters": [ { "centroid": [7, 2, 11], "weight": 2210, "share": 0.242, "region": "counter", "regions": ["counter", "shop-floor"], "nearestMesh": "checkout_button", "distance": 0, "drill": { "region": "counter" }, }, ], "reading": "3D world-space pointer heatmap: 3 hotspots over 412 occupied voxels. The densest spans 3x2x3 voxels on `checkout_button` in region `counter`, centred at (7, 2, 11) on vx/vy/vz, holding 2,210 (24.2%). …",}The scene is the request’s scene filter, or the project’s only registered scene when it has
exactly one; with several scenes and no filter nothing is labelled, because there is no single
vocabulary to label against. Once a region contains a hotspot, drill.region becomes that
region id, ready to send straight back as ?region=. A scene with no proxy gets
nearestMesh: null and a caveat that says so rather than a guess — as does a mesh further away
than cellSize × 2. Labelling is summary-only: full and table return exactly the rows they
always have, and grids that are not world-space (the viewport pointer/UV bins, the angular
view-direction grid) carry no label fields at all.
Unrelated older parameter, same name: /api/v1/sessions/:id/events takes format=json|ndjson to
negotiate the raw replay stream. It is not an aggregate endpoint and is unaffected.
For large scenes (ADR 0040): leave cellSize off and the collector derives a
sensible voxel size from the scene’s registered world bounds — or from a
region=minX,minY,minZ,maxX,maxY,maxZ box when you pass one to drill in. The
/stats siblings report the real occupied-cell and hit totals behind the
truncated top-N voxels so cold spots and coverage stay honest. The dashboard’s 3D
world heatmap normalizes to the 95th-percentile cell so a couple of hotspots no
longer wash out the rest.
region also takes the id of a registered scene region — region=entrance
instead of six numbers. The collector resolves the id to that region’s stored box
before the query runs, so you can drill into a place by name. Pass scene= too
(a region belongs to a scene); an unregistered id returns 400. See
Scene regions.
The tables below are generated from the collector’s
semantic metric registry,
so they list exactly the aggregations this collector can compute. The same registry drives
GET /api/v1/openapi.json — fetch that document for the full parameter and response schemas,
including each column’s unit and each metric’s caveats.
Sessions & scenes
Section titled “Sessions & scenes”| Method | Path | Metric | One row is | Parameters | Purpose |
|---|---|---|---|---|---|
GET |
/api/v1/sessions |
list_sessions |
session | since, until, bins, limit, cameraMode, format |
One row per session seen in the range: its id, the server-derived daily-rotating visitor hash, how many events it produced, and its first/last event timestamps. The entry point for ‘what traffic did this project get’ and for picking a session to drill into. |
GET |
/api/v1/sessions/:id/meta |
session_meta |
session | session |
The coarse descriptor for one session — start time, the device/graphics block reported at session_start, the scene metadata and the app-supplied anonymous user descriptor. A single-object resource read from the store, not an aggregation, and deliberately not the raw event stream. |
GET |
/api/v1/sessions/:id/narrative |
session_narrative |
row | session, minDwellMs, fpsThreshold, maxEntries, format |
An ordered, compacted account of what one session did — scene changes, the meshes it dwelled on, its interactions, performance dips, errors and how it ended — timestamps relative to its first event, plus a closing totals entry. A compaction of the raw per-session stream, gated on query:raw and raw-session retention (ADR 0003). |
GET |
/api/v1/scenes/:sceneId/representation |
scene_representation |
scene | scene |
The registered proxy geometry for one scene (ADR 0014): its world bounds, up-axis and unit scale, and the named proxy boxes when one was uploaded. A metadata resource read, not an aggregation — it is what turns the voxel coordinates of the spatial metrics into named places. |
GET |
/api/v1/scenes |
list_scenes |
scene | since, until, limit, format |
The distinct developer-assigned scenes (ADR 0010) that saw activity in the range, with their event count and most recent activity. One row per scene; the orientation query before any scene-scoped question. |
GET |
/api/v1/timeseries |
timeseries |
bucket | since, until, interval, scene, type, format |
Event volume bucketed into fixed interval-second windows, with the average FPS of any frame_perf samples in the same bucket. One row per bucket: the shape of traffic with the coarse perf trend beside it. |
GET |
/api/v1/event-counts |
event_counts |
row | since, until, scene, format |
How many events of each type were recorded in the range, optionally for one scene. One row per event type. The scene-health overview: error rate, context losses, focus/visibility gaps and interaction volume all read off this single query. |
Attention & heatmaps
Section titled “Attention & heatmaps”| Method | Path | Metric | One row is | Parameters | Purpose |
|---|---|---|---|---|---|
GET |
/api/v1/heatmaps/pointer |
pointer_heatmap |
bin | since, until, bins, limit, scene, session, source, cameraMode, format |
Screen-space pointer activity binned into a bins × bins grid over the normalized viewport. One row per occupied cell. Answers ‘where on screen do people point and click’ — the classic web heatmap, for a 3D canvas. |
GET |
/api/v1/heatmaps/mesh-uv |
mesh_uv_heatmap |
bin | since, until, bins, limit, scene, session, source, mesh, format |
Interaction hits on one object binned into a bins × bins grid over that object’s own [0,1] UV space (#149). One row per occupied cell. Answers ‘which part of this product model gets attention’, independent of where the object sits in the scene. |
GET |
/api/v1/heatmaps/world |
world_heatmap |
voxel | since, until, cellSize, limit, scene, source, cameraMode, region, format |
Pointer raycast hit points voxel-binned into a uniform grid of cellSize-sized cubes. One row per occupied voxel, busiest first. Answers ‘where in the scene do people point and click’ in world coordinates rather than on screen. |
GET |
/api/v1/heatmaps/world/stats |
world_heatmap_stats |
project | since, until, cellSize, scene, source, cameraMode, region, format |
The un-truncated totals behind world_heatmap (ADR 0040 §3): how many voxels are occupied and how many hits they hold, computed with no row cap. Always a single row. |
GET |
/api/v1/heatmaps/gaze |
gaze_heatmap |
voxel | since, until, cellSize, limit, scene, session, cameraMode, region, format |
Where the camera-forward (gaze) ray landed on real geometry, voxel-binned into a uniform grid (ADR 0030). One row per occupied voxel, busiest first. This is ‘what did people actually look at’, as opposed to what they clicked. |
GET |
/api/v1/heatmaps/gaze/stats |
gaze_heatmap_stats |
project | since, until, cellSize, scene, session, cameraMode, region, format |
The un-truncated totals behind gaze_heatmap (ADR 0040 §3): occupied voxels and total gaze hits, with no row cap. Always a single row. |
GET |
/api/v1/heatmaps/camera |
camera_heatmap |
bin | since, until, bins, limit, scene, session, cameraMode, format |
Camera forward vectors binned by spherical angle into a bins × bins azimuth/elevation grid. One row per occupied direction bin. The abstract ‘which way did people look’ dome — it needs no scene geometry, so it works even without the gaze raycast. |
GET |
/api/v1/coverage/view-histogram |
view_coverage_histogram |
bucket | since, until, bins, limit, scene, session, cameraMode, format |
How much of the view dome each session actually looked at, bucketed across sessions (#146). One row per 25-point coverage band. Answers ‘how many visitors saw less than a quarter of the product’. |
GET |
/api/v1/meshes/dwell |
mesh_dwell |
mesh | since, until, bins, limit, scene, session, format |
How long each object spent on screen and near the view centre, from mesh_visibility summaries (#37). One row per mesh, ranked by total on-screen time. The 3D analogue of time-on-element. |
GET |
/api/v1/meshes/blind-spots |
mesh_blind_spots |
mesh | since, until, bins, limit, scene, session, format |
Per mesh, how long it was visible against how much it was engaged with (#143). One row per mesh that was seen at least once, most-seen-yet-least-touched first. A product detail with high visibility and near-zero interaction is a blind spot. |
GET |
/api/v1/hover/dwell |
hover_dwell |
mesh | since, until, bins, limit, scene, session, source, cameraMode, format |
Per mesh, how long visitors lingered on an object without clicking it, over how many episodes, and the longest single hover (#48). One row per mesh. High dwell with few interactions flags objects that look interactive but are not. |
Navigation & coverage
Section titled “Navigation & coverage”| Method | Path | Metric | One row is | Parameters | Purpose |
|---|---|---|---|---|---|
GET |
/api/v1/heatmaps/position |
position_heatmap |
bin | since, until, cellSize, limit, scene, session, cameraMode, region, format |
Camera positions binned onto the X/Z ground plane in cellSize-sized cells, with the mean height per cell (ADR 0026). One row per occupied cell, busiest first. The ‘where do visitors stand and linger’ map for a walkable scene. |
GET |
/api/v1/sessions/:sessionId/trajectory |
session_trajectory |
row | session, since, until, limit, scene, format |
One session’s ordered camera positions, oldest first (ADR 0026). One row per sampled point. The single-visitor path behind the crowd view in aggregate_paths. |
GET |
/api/v1/paths |
aggregate_paths |
row | since, until, cellSize, limit, scene, cameraMode, format |
Every session’s camera path binned onto the ground grid and returned as ordered, session-keyed points (#73, ADR 0037). One row per (session, sampled point). Overlaying the poly-lines makes the routes visitors actually walk self-reinforce into desire lines. |
GET |
/api/v1/coverage |
scene_coverage |
voxel | since, until, cellSize, limit, scene, session, format |
Camera positions voxel-binned into a uniform 3D grid. One row per occupied voxel with its visit count. Exploration completeness and never-visited regions are computed by comparing the occupied voxels against the scene’s registered bounds. |
GET |
/api/v1/camera/distance |
camera_distance |
bucket | since, until, centerX, centerY, centerZ, bucketSize, limit, scene, session, format |
Histogram of the distance from each camera sample to a reference point (by default the world origin; pass the scene-AABB centre for a product view). One row per bucketSize-wide distance band. A proxy for engagement intensity — how close visitors get to the subject. |
GET |
/api/v1/camera-gestures |
camera_gestures |
row | since, until, bins, limit, scene, session, source, cameraMode, format |
How often visitors moved the viewpoint and for how long, per gesture kind — orbit, pan, dolly, zoom, roll, fly, navigate (ADR 0025). One row per kind. Separates deliberate navigation intent from object selection. |
GET |
/api/v1/navigation |
navigation_stats |
session | since, until, moveThreshold, limit, scene, session, format |
Per session, how far the camera travelled and how much of that travel was active rather than idle dwell. One row per session. A high segment count with low active distance flags a stuck or lost visitor. |
GET |
/api/v1/backtrack |
backtrack_ratio |
scene | since, until, cellSize, limit, scene, session, format |
Per scene, the share of coarse-grid cell entries that re-entered an already-visited cell (#153). One row per scene. A high ratio flags a dead end, a missed cue, or a puzzle that is not reading clearly. |
Meshes & interactions
Section titled “Meshes & interactions”| Method | Path | Metric | One row is | Parameters | Purpose |
|---|---|---|---|---|---|
GET |
/api/v1/heatmaps/click-rays |
click_rays |
voxel | since, until, cellSize, limit, scene, source, session, format |
Each click aggregated into a ray from an origin voxel to the hit voxel, sharing the world heatmap’s grid. One row per (origin voxel, hit voxel, mesh). Shows not just what was clicked but from where — the standpoint an interaction was made from. |
GET |
/api/v1/heatmaps/flow |
flow_links |
bin | since, until, bins, limit, scene, session, cameraMode, cellSize, groupByOrigin, originVoxel, format |
Weighted links from a camera-direction bin to the mesh that was clicked while facing that way. One row per (direction bin, mesh), or per (standpoint voxel, direction bin, mesh) in position-aware mode. Connects where people looked from to what they acted on. |
GET |
/api/v1/meshes/top |
top_meshes |
mesh | since, until, bins, limit, session, format |
Meshes ranked by how many events referenced them. One row per mesh. The 3D analogue of a top-pages report: which objects in the scene draw activity. |
GET |
/api/v1/meshes/sources |
mesh_sources |
mesh | since, until, bins, limit, scene, session, source, cameraMode, format |
The mesh leaderboard broken out by the input source that drove each interaction (#74, ADR 0011). One row per (mesh, source). Scoped to active interactions, so passive gaze never inflates popularity. |
GET |
/api/v1/meshes/trend |
mesh_trend |
bucket | since, until, bins, limit, scene, session, source, cameraMode, interval, format |
The active-interaction tally per mesh, bucketed into fixed interval-second windows (#74). One row per (mesh, bucket), oldest bucket first — the per-mesh sparkline behind the leaderboard. |
GET |
/api/v1/meshes/kinds |
mesh_interaction_kinds |
mesh | since, until, bins, limit, scene, session, source, cameraMode, format |
Per-mesh counts of each interaction kind — hover, pick, click, drag, select, squeeze, grab, release, teleport (#72, ADR 0023). One row per (mesh, kind). Separates an object that is merely hovered from one that is actually picked or dragged. |
GET |
/api/v1/meshes/reachability |
mesh_reachability |
mesh | since, until, bins, limit, scene, session, source, cameraMode, bucketSize, format |
How far each interacted mesh sat from where the visitor actually stood (#151). One row per (mesh, distance band) with the mean distance in the band. Meshes whose interactions cluster in far bands are consistently reached from an uncomfortable range. |
GET |
/api/v1/clicks/dead |
dead_clicks |
project | since, until, bins, limit, scene, session, source, cameraMode, format |
Of all clicks in the range, how many hit nothing at all (#46). Always a single row. A high dead-click share is a 3D discoverability problem: visitors click where they expect something interactive and get no response. |
GET |
/api/v1/clicks/rage |
rage_clicks |
row | since, until, bins, limit, scene, session, source, cameraMode, interval, minRepeats, format |
Rapid repeated clicks on the same mesh inside one time window (#47) — the ‘I keep clicking and nothing happens’ frustration signal. One row per (session, mesh, window) that reached minRepeats, biggest burst first. |
GET |
/api/v1/interactions/sources |
interaction_sources |
row | since, until, bins, limit, scene, session, source, cameraMode, format |
For every interaction event that carries an input source, how many fired per (event type, source) and across how many distinct sessions (ADR 0011). One row per pairing. Turns source from a filter into the modality mix of the audience. |
GET |
/api/v1/input-actions/top |
top_input_actions |
row | since, until, bins, limit, scene, session, source, cameraMode, format |
App-level input_action labels — bound keyboard chords and gamepad buttons — ranked by how often they fired, split by input source (#75, ADR 0023). One row per (action, source). |
GET |
/api/v1/vocabulary/custom-events |
custom_event_vocabulary |
row | since, until, scene, limit, format |
Which developer-defined custom event names the project actually emits, how often, over how many distinct sessions, and the union of props keys observed on each name with a coarse type per key (ADR 0051 §5). One row per custom-event name. This is how an agent learns that add_to_cart exists and carries sku and qty — nothing else in the read surface enumerates an application’s own event vocabulary. |
Performance & stability
Section titled “Performance & stability”| Method | Path | Metric | One row is | Parameters | Purpose |
|---|---|---|---|---|---|
GET |
/api/v1/perf |
perf_summary |
project | since, until, bins, limit, session, format |
The pooled FPS headline over the range: how many frame_perf samples were seen and their average, minimum and median FPS. Always a single row. The quickest ‘is this scene smooth’ check. |
GET |
/api/v1/perf/render-scale |
render_scale_truth |
project | since, until, bins, limit, session, format |
The FPS headline paired with the resolution the engine actually rendered at (#71, ADR 0021). Always a single row. A scene can report a healthy frame rate only because an adaptive renderer quietly dropped the render scale below 1. |
GET |
/api/v1/perf/distribution |
perf_distribution |
project | since, until, bins, limit, scene, session, format |
FPS percentiles computed per session and then aggregated (ADR 0028 §1): the median across sessions of each session’s p05 / p50 / p95. Always a single row. The honest smoothness headline — one visitor, one vote. |
GET |
/api/v1/perf/fps-histogram |
fps_histogram |
bucket | since, until, bins, limit, scene, session, bucket, format |
How many sessions fell into each FPS band, where a session contributes a single data point — its median FPS (ADR 0028 §1). One row per bucket-wide band. Answers ‘how many experiences were smooth’, not how many frames. |
GET |
/api/v1/perf/frame-time |
frame_time_percentiles |
project | since, until, bins, limit, scene, session, format |
Frame cost in milliseconds, computed per session then aggregated (ADR 0028 §1): the typical frame and the tail. Always a single row. Milliseconds are the budget developers actually work in — FPS is the reciprocal. |
GET |
/api/v1/perf/jank |
jank_rate |
project | since, until, bins, limit, scene, session, format |
How often frames ran long, per session then aggregated (ADR 0028 §1): the median session’s long-frames-per-window rate and the worst decile’s. Always a single row. Surfaces the janky minority instead of averaging it away. |
GET |
/api/v1/perf/churn |
perf_churn |
project | since, until, bins, limit, scene, session, windowMs, fpsThreshold, stallMs, format |
Does a stutter actually cost sessions (#144)? Of the sessions that ended in range, how many ended shortly after an FPS dip or a compile stall, with the cause attributed. Always a single row of aggregate counts. |
GET |
/api/v1/perf/by-device |
perf_by_device |
row | since, until, bins, limit, scene, session, format |
Median FPS attributed to the graphics backend, mobile flag, GPU renderer and the coarse browser/OS families derived at ingestion (ADR 0028 §2, ADR 0042). One row per device combination. Where a bimodal FPS histogram gets explained. |
GET |
/api/v1/perf/by-scene |
perf_by_scene |
scene | since, until, bins, limit, scene, session, format |
Median FPS attributed to each scene, per session then aggregated (ADR 0028 §1). One row per scene. The comparison that tells you which level is expensive. |
GET |
/api/v1/heatmaps/perf |
perf_heatmap |
voxel | since, until, cellSize, limit, scene, session, format |
frame_perf samples voxel-binned by the camera position they were captured at (#145), with each cell’s sample count, mean FPS and worst sample. One row per occupied voxel, worst-FPS-first. Answers where performance degrades. |
GET |
/api/v1/perf/compile-stalls |
compile_stalls |
row | since, until, bins, limit, scene, session, format |
Per compile phase, how many main-thread compile hitches happened and their total, average and worst duration (#42). One row per phase. Compilation is the biggest single source of first-interaction jank, and frame-rate averages hide it. |
GET |
/api/v1/perf/resources |
resource_summary |
project | since, until, bins, limit, session, format |
The average and peak of each footprint metric over the range (#44): JS heap, submitted triangles and vertices, resident texture and geometry bytes. Always a single row — the actual cost the scene asked of the device. |
GET |
/api/v1/perf/resource-percentiles |
resource_percentiles |
project | since, until, bins, limit, scene, session, format |
Footprint percentiles computed per session then aggregated (ADR 0028 §1): a typical (p50) and peak (p95) JS heap, texture bytes and triangle count per session, summarised as the median across sessions. Always a single row. |
GET |
/api/v1/rendering-technology |
rendering_technology |
row | since, until, bins, limit, scene, session, format |
Session counts crossed by (api, backend, api version, shading language) from the always-on session_start graphics block (ADR 0021 part 1, ADR 0046). One row per combination — WebGPU vs WebGL2 adoption, and which shading language is in play. |
Errors & diagnostics
Section titled “Errors & diagnostics”| Method | Path | Metric | One row is | Parameters | Purpose |
|---|---|---|---|---|---|
GET |
/api/v1/perf/stability |
stability_counts |
project | since, until, bins, limit, scene, session, format |
GPU context losses and shader/pipeline compile stalls over the range, plus their total. Always a single row. These are the hard failures a frame-rate average cannot show — a context loss blanks the canvas, a compile stall freezes first interaction. |
GET |
/api/v1/graphics-diagnostics |
graphics_diagnostics |
row | since, until, bins, limit, scene, session, format |
Opt-in engine diagnostics crossed by (severity, category, backend) with a rollup-aware incident total (ADR 0021 part 2). One row per combination. Surfaces validation errors, shader-compile failures and context-loss detail the engine reports. |
GET |
/api/v1/heatmaps/errors |
error_heatmap |
voxel | since, until, cellSize, limit, scene, session, region, severity, category, errorKind, format |
Positioned runtime errors and engine diagnostics voxel-binned into a uniform grid (#154). One row per occupied voxel, busiest first. Reveals where in the scene things break, not only when. |
GET |
/api/v1/capabilities |
capability_changes |
row | since, until, bins, limit, scene, session, format |
How often the app reported a capability fallback or recovery, per (kind, from, to) (#49). One row per transition. Explains perf and visual-fidelity variance — e.g. how many sessions fell back from WebGPU to WebGL2. |
| Method | Path | Metric | One row is | Parameters | Purpose |
|---|---|---|---|---|---|
GET |
/api/v1/xr/rotation |
xr_rotation |
session | since, until, rapidTurn, limit, scene, session, format |
Per session, how fast the view turned over the camera pose stream — the angular path, the worst single jerk, and how many steps cleared the rapid-turn threshold. One row per session. A motion-sickness proxy. |
GET |
/api/v1/xr/sources |
xr_sources |
row | since, until, bins, limit, scene, session, format |
The immersive input mix: one row per XR input source (hand, controller, gaze, transient) with its interaction count and how many sessions used it. Flat-screen sources are excluded so the split is purely XR. |
GET |
/api/v1/xr/abandonment |
xr_abandonment |
session | since, until, bins, limit, scene, session, format |
For every session that used an XR input source, its wall-clock bounds and event / interaction counts. One row per XR session. A short span with few interactions is headset drop-off. |
GET |
/api/v1/xr/locomotion |
xr_locomotion |
session | since, until, bins, limit, scene, session, format |
Per XR session, its locomotion-style mix — fly and navigate gestures, discrete teleports, and total time in locomotion — plus the session’s wall-clock span (#148). One row per XR session. Constant smooth locomotion is a motion-sickness risk; teleport-dominant sessions are not. |
GET |
/api/v1/xr/tracking |
xr_tracking_quality |
session | since, until, bins, limit, scene, session, format |
Per session that reported a tracking transition, how much of it ran with degraded or lost spatial tracking, split by hand vs controller (#155, ADR 0048). One row per session. A session that looked fine on FPS can still have been unusable because the hands kept disappearing. |
GET |
/api/v1/heatmaps/boundary |
boundary_heatmap |
voxel | since, until, cellSize, limit, scene, session, region, format |
Where room-scale VR visitors approached their play-space boundary, voxel-binned into a uniform grid (#157, ADR 0048). One row per occupied voxel, busiest first. The ‘where did people keep bumping into their guardian’ map. |
GET |
/api/v1/heatmaps/boundary/stats |
boundary_heatmap_stats |
project | since, until, cellSize, scene, session, region, format |
The un-truncated totals behind boundary_heatmap (ADR 0040 §3): occupied voxels and total boundary contacts, with no row cap. Always a single row. |
GET |
/api/v1/xr/boundary-contacts |
xr_boundary_contacts |
session | since, until, bins, limit, scene, session, format |
For every session that touched its play-space boundary, how many approaches it made and how long it spent in the near-boundary zone (#157, ADR 0048). One row per session. Frequent contact means the physical space did not fit the experience. |
WebXR AR placement
Section titled “WebXR AR placement”| Method | Path | Metric | One row is | Parameters | Purpose |
|---|---|---|---|---|---|
GET |
/api/v1/ar/placement/time-to-place |
ar_placement_time_to_place |
bucket | since, until, bins, limit, scene, session, bucketMs, format |
How long visitors took to place a model on a surface, histogrammed into bucketMs-wide bins (#156, ADR 0048 §1). One row per bin, one settle per data point. The felt cost of getting a ‘view in your room’ model down — the AR analogue of a slow add-to-cart. |
GET |
/api/v1/ar/placement/attempts |
ar_placement_attempts |
bucket | since, until, bins, limit, scene, session, format |
How many place / re-place actions visitors made before committing (#156, ADR 0048 §1). One row per attempt count. attempts = 1 is a clean first try; a long right tail is placement friction. |
GET |
/api/v1/ar/placement/surfaces |
ar_placement_surfaces |
row | since, until, bins, limit, scene, session, format |
Per coarse surface bucket — floor, wall, table, ceiling, unknown — how many settles landed there and their average committed scale (#156, ADR 0048 §1). One row per surface. Shows where visitors place models and how far off the authored size they settle. |
Funnels & conversion
Section titled “Funnels & conversion”| Method | Path | Metric | One row is | Parameters | Purpose |
|---|---|---|---|---|---|
GET |
/api/v1/funnel |
funnel |
bucket | since, until, scene, cameraMode, steps, format |
An ordered, per-session conversion funnel over caller-supplied step predicates (ADR 0038): how many sessions reached each step in order. One row per step, 0-based. The OSS collector has no authoring surface, so the steps come from the caller. |
GET |
/api/v1/scene-retention |
scene_retention |
row | since, until, limit, format |
Directed scene→scene links weighted by how many distinct sessions made each consecutive transition (#147), derived purely from the observed order of scene_change markers. One row per link, busiest first. The zero-config level funnel. |
GET |
/api/v1/load-bounce |
load_bounce_funnel |
bucket | since, until, scene, bands, format |
Sessions bucketed by their initial load time, with how many bounced in each band (#152) — a bounce being a session that produced no interaction at or after its first asset load. One row per band. Turns ‘slow loads cost you customers’ into a number. |
GET |
/api/v1/variant-leaderboard |
variant_leaderboard |
row | since, until, scene, cameraMode, variant, conversion, limit, format |
For a product configurator (#150): per variant — a custom event grouped by its name — how often it was viewed, over how many sessions, how many of those converted, and the mean dwell before the visitor switched or converted. One row per variant, ranked by views. |
Insights & anomalies
Section titled “Insights & anomalies”| Method | Path | Metric | One row is | Parameters | Purpose |
|---|---|---|---|---|---|
GET |
/api/v1/insights/baseline |
insight_baseline |
project | metric, scene, window, bucket, since, until, format |
What is normal for one metric in one scene. Buckets a comparable metric’s headline column into days or hours over a trailing window and reduces the series to its centre (mean, median), its ordinary spread (MAD, p10, p90) and its drift (least-squares slope per bucket). One row per request: the reference distribution a single later observation should be judged against, so ‘is 42 FPS bad here?’ has an answer that does not depend on the reader’s memory. |
GET |
/api/v1/insights/movers |
insight_movers |
row | scene, metrics, bucket, limit, since, until, refSince, refUntil, format |
What moved, ranked. For every comparable metric in scope, compares the current range with a reference range (the previous equal window by default) and ranks the differences by a robust z-score — the change divided by how much that metric normally swings, so a metric that is always volatile has to move much further than a steady one before it is called a mover. One row per metric: the top risers, the top fallers, and the ones that did not move. |
GET |
/api/v1/insights/anomalies |
insight_anomalies |
row | metric, scene, window, bucket, sensitivity, since, until, format |
When one metric stopped behaving, and what inside it accounts for that. Walks a comparable metric’s day or hour bucket series and returns only the buckets that do not belong in it: a spike or a drop when a single bucket sits more than sensitivity median absolute deviations from the buckets just before it, and a shift at the bucket where a CUSUM change-point says the level moved and stayed moved. Where the metric declares a dimension it can be split by, the row also names the dimension value holding the largest share of the excess — the difference between ‘errors tripled on the 14th’ and ‘graphics diagnostics tripled on the 14th’. |
GET |
/api/v1/insights/significance |
insight_significance |
project | metric, scene, bucket, since, until, refSince, refUntil, format |
Is that difference real? Compares one comparable metric across two windows and reports the effect, its 95% confidence interval and a two-sided p-value, with the test chosen from what the measure is: a two-proportion z with Wilson intervals for a declared rate, Welch’s t over the per-bucket values for a level, an exact Poisson rate test for a bare count. One row per request, and a powerNote saying what these sample sizes could and could not have detected. |
GET |
/api/v1/insights/scene-health |
insight_scene_health |
scene | scene, window, since, until, bucket, limit, weights, format |
Which scene is in trouble, and why. Scores each scene 0-100 over six weighted factors — perf stability, jank, errors, dead clicks, exploration coverage and XR abandonment — each normalised against the project’s own baseline over the preceding equal window. One row per scene, least healthy first, and every factor carries the metric id, the raw value, the baseline it was compared with and the weight it contributed, so the score can always be taken apart. |
One query endpoint (the query DSL)
Section titled “One query endpoint (the query DSL)”Every metric above also answers to one endpoint. Rather than finding the route that carries the flag you need, name the metric, the window and the filters that metric declares, in a single validated JSON document.
curl -X POST -H "x-api-key: $KEY" -H "content-type: application/json" \ -d '{ "v": 1, "metric": "mesh_sources", "range": { "since": 1757000000000, "until": 1757600000000 }, "filters": { "scene": "lobby", "cameraMode": "first-person" }, "limit": 20, "format": "summary" }' \ "https://collect.example.com/api/v1/query"GET /api/v1/query?q=<url-encoded JSON> takes the same document in one parameter (8 KiB max), for
clients that only issue GETs. Both need the ordinary query capability, and both are audited like
every other read — the DSL can compute nothing the endpoints above cannot.
| Field | Required | What it is |
|---|---|---|
v |
yes | Grammar version. Always 1. |
metric |
yes | A metric id from the tables above. |
range |
yes | { since, until } in epoch milliseconds. There is no unbounded query. |
filters |
no | The filters that metric declares, as JSON — the same names as its querystring parameters. |
dimensions |
no | Up to 3 group-by dimensions: the metric’s grain, or any subset it declares if it can be regrouped. |
segment |
no | { dimension: value } held fixed for the whole query. |
compare |
no | Another { range } or { segment } to measure this query against; the result comes back joined. |
order |
no | { by, dir } over a measure column of a ranked or regrouped result. |
explain |
no | true returns the compiled plan and its warnings instead of the rows. |
limit |
no | Row cap, at most 1000 and at most the metric’s own limit. |
format |
no | full / table / summary. Defaults to table here, unlike the endpoints above. |
The grammar is closed: no SQL, no expression language, every identifier checked against the registry,
unknown keys rejected, output bounded. filters.steps, filters.variant and filters.conversion
take real step predicates (not JSON-encoded strings), and filters.region takes a registered region
id or the six-number box as an array.
Two compilation tiers
Section titled “Two compilation tiers”Which compiler answers a query is a property of the metric, not of the request.
Most metrics are delegated: the metric’s existing aggregation runs, so the result is exactly what
its own endpoint returns. That is the only tier a spatial heatmap or a percentile has — their measure
is their grain, and neither decomposes onto another one, so grouping them by something else is a
400 naming the grain they do support.
Metrics whose measure is a portable count or sum are also available through a generic group-by tier, which recomputes them at any grain they declare:
| Metric | Default grain | Can also group by | Measures |
|---|---|---|---|
event_counts |
event_type |
scene, session, source, mesh, name, cameraMode, device.os, device.browser, device.engine, device.renderer |
count |
top_meshes |
mesh |
session, scene, source, event_type, cameraMode, device.os, device.browser, device.engine, device.renderer |
count |
mesh_sources |
mesh, source |
scene, session, cameraMode, name, event_type, device.os, device.browser, device.engine, device.renderer |
count |
mesh_interaction_kinds |
mesh, name |
scene, session, source, cameraMode, device.os, device.browser, device.engine, device.renderer |
count |
interaction_sources |
event_type, source |
scene, session, cameraMode, mesh, name, device.os, device.browser, device.engine, device.renderer |
count, sessions |
top_input_actions |
name, source |
scene, session, cameraMode, device.os, device.browser, device.engine, device.renderer |
count |
camera_gestures |
name |
scene, session, source, cameraMode, device.os, device.browser, device.engine, device.renderer |
gestures, total_ms, avg_ms, max_ms |
device.isMobile is never a group-by — a boolean would key rows as true/false on one engine and
1/0 on another. Filter by it, or group by device.os.
Comparing, explaining, drilling
Section titled “Comparing, explaining, drilling”curl -X POST -H "x-api-key: $KEY" -H "content-type: application/json" \ -d '{ "v": 1, "metric": "top_meshes", "range": { "since": 1757600000000, "until": 1758204800000 }, "compare": { "range": { "since": 1756995200000, "until": 1757600000000 } }, "format": "summary" }' \ "https://collect.example.com/api/v1/query"compare runs the query twice — with the comparison’s range, or with its segment substituted —
and joins the two results on the dimension key, so each row is
{ key, label, current, previous, delta, deltaPct }. A key on one side only is still a row:
previous: null is an arrival and current: null a disappearance. significance is attached only
where the measure is a count and both windows clear the metric’s own minimum (a pooled
two-proportion z with Wilson intervals); for a mean-shaped measure the field is absent and a caveat
says why. format=summary returns the biggest movers with a one-sentence reading.
explain: true answers with the plan instead of the rows: the tier that would run, the dialect,
the SQL with its parameters left as placeholders, params as names and logical types (never values),
rowsScanned, and warnings — a capture channel that produced nothing in the window, a sample below
the metric’s minimum, a spatial result with no proxy or regions to name hotspots after, a result cut
off by limit. Worth one call before reporting a zero.
Drill-down is built in: every row of a format=summary result carries drillQuery, the whole
query narrowed to that row and ready to send straight back.
A query naming something the registry does not know returns a 400 listing every objection, each
with a stable code, the offending path and — where it is a closed list — the values that would have
been accepted, so a client can correct itself instead of guessing:
{ "error": "the query cannot be answered: \"top_meshes\" does not accept the filter \"scene\"…", "issues": [ { "code": "unsupported_filter", "path": "filters.scene", "message": "\"top_meshes\" does not accept the filter \"scene\". It accepts `session`, `bins`, `limit`.", "accepted": ["session", "bins", "limit"], }, ],}Insights — baselines, movers, anomalies, significance and health
Section titled “Insights — baselines, movers, anomalies, significance and health”Five of the metrics above are not measurements but readings about the other
metrics: insight_baseline answers “is this number normal here?”,
insight_movers “what changed?”, insight_anomalies “when did it go wrong,
and what inside it did?”, insight_significance “is that change real?” and
insight_scene_health “which scene should I look at first?”. All five are
ordinary registry metrics, so they take the same parameters, the same format
envelopes and the same API key as everything else on this page.
They all work the same way: one comparable metric’s headline column is bucketed
by day or hour, and every statistic over that series — mean, median, MAD,
percentiles, slope, robust z, confidence intervals and p-values alike — is
computed in plain TypeScript rather than SQL. That is deliberate: five SQL
engines disagree about quantile and median, and an insight that changes when
you switch storage backend is not an insight.
/api/v1/insights/baseline
Section titled “/api/v1/insights/baseline”metric (required) plus scene, window (days, default 28, max 365), bucket
(day | hour) and the usual since / until. One row:
curl -H "x-api-key: $KEY" \ "https://collect.example.com/api/v1/insights/baseline?metric=perf_summary&scene=lobby"{ "metric": "perf_summary", "scene": "lobby", "sampleSize": 18420, "buckets": 28, "mean": 54.1, "median": 55, "mad": 2, // the typical day-to-day swing "p10": 48.3, "p90": 58.6, "slope": -0.21, // change per bucket, i.e. drift within the window}median is the centre and mad the tolerance: a later reading more than a few
MADs away is unusual for this scene; one inside p10..p90 is ordinary. A
baseline with a steep slope is not a stable reference — re-read it over a
shorter window before judging anything against it. null means “no data”, never
zero.
/api/v1/insights/movers
Section titled “/api/v1/insights/movers”scene, since / until (default: the last 7 complete days), refSince /
refUntil (default: the equal window immediately before), bucket, limit
(default 10, max 50) and an optional comma-separated metrics allowlist. One row
per scanned metric — risers, then fallers, then the ones that did not move:
{ "metric": "error_heatmap", "current": 412, "previous": 96, "delta": 316, "deltaPct": 3.2917, "z": 39.5, // delta ÷ the reference window's spread "direction": "down", // a *rise* in this metric is bad "aboveMinSample": true, "sampleSize": 412,}zranks,deltaPctdescribes.zis the change divided by the median absolute deviation of the reference window’s buckets, so a metric that swings wildly every day must move much further than a steady one to be called a mover. Under ~2 is ordinary noise for that metric; over ~3 is worth explaining.directionis not the direction of the move. It is the registry’s opinion of what a rise in that metric means — good (up), bad (down) or neutral. Read it with the sign ofdelta: above is a rise in adownmetric, i.e. a regression.aboveMinSample: falseis not a finding. The delta is real arithmetic but the denominator is below the metric’s declared minimum. Such rows are kept — “we cannot tell” and “nothing changed” are different answers — and always sort below every gated mover.
/api/v1/insights/anomalies
Section titled “/api/v1/insights/anomalies”metric (required) plus scene, window (days, default 28, max 365), bucket
(day | hour), sensitivity (1–10, default 3) and the usual since /
until. One row per bucket that does not belong in the series, oldest first:
curl -H "x-api-key: $KEY" \ "https://collect.example.com/api/v1/insights/anomalies?metric=error_heatmap&scene=lobby"{ "metric": "error_heatmap", "scene": "lobby", "bucketStart": 1718064000000, // the day itself, not the day it was detected "value": 32, "expected": 2, // the median of the trailing window "z": 1500, // signed, in standard deviations "kind": "spike", // spike | drop | shift "contributor": { "dimension": "event_type", "value": "graphics_diagnostic", "share": 1 }, "sampleSize": 32,}spike/dropare one bucket;shiftis a level. The first two come from a rolling median and MAD over the trailing window (14 buckets atdaygrain, 168 athour) with the bucket itself excluded. The third comes from a CUSUM over the same series and catches what a point detector structurally cannot: a regression that is only a MAD or two deep on any single day but never recovers. A sustained change appears as both — theshiftat its change-point, thendroprows for the days after it until the trailing window catches up.zis in standard deviations. The trailing MAD, rescaled by 1.4826 and floored at 1% of the level, sosensitivity: 3means “beyond three sigma”.insight_moversreports the same ratio unscaled — it ranks rather than thresholds — so the two columns differ by that constant.contributorsays where the excess sits, not what caused it. When the metric declares a dimension it can be split by (a mesh, a source, an input action, an event type, a scene), the anomalous window is re-read grouped by that column and the value with the largest share of the excess is reported. Ashareabove ~0.8 means the anomaly is that value. It isnullfor a metric with no such dimension — and for ascene-split metric on a request already scoped to one scene, where the split could only ever return a share of 1.
/api/v1/insights/significance
Section titled “/api/v1/insights/significance”metric (required) plus scene, bucket, the usual since / until and
refSince / refUntil (default: the equal window immediately before). One row —
and the test is picked from what the measure is, never from the caller:
| The measure is… | Test | ci95 |
|---|---|---|
a rate (its headline column declares a rateOf denominator) |
two-proportion z, pooled | Newcombe hybrid score, from the two Wilson intervals |
| a bare count | Poisson rate test (exact conditional binomial) | normal approximation on the rate difference |
a level or a summed quantity (fps, ms, bytes) |
Welch’s t over the per-bucket values | effect ± t(0.975, ν) · SE |
{ "metric": "dead_clicks", "a": { "value": 0.166667, "n": 6 }, // the window under test "b": { "value": 0, "n": 8 }, // the window it is compared with "effect": 0.166667, "ci95": [-0.185333, 0.563503], // straddles 0 → cannot tell yet "p": 0.230804, "test": "two_proportion_z", "effectUnit": "ratio", "significant": false, "powerNote": "With these sample sizes the smallest difference detectable at 80% power (alpha 0.05) is about 0.4262 in the rate; …",}- Read
ci95beforep. A narrow interval around a small effect says nothing much changed; a wide interval containing 0 says nothing at all, andpowerNotetells you which you are looking at. - Welch counts buckets, not events.
nis the number of days or hours compared. Frame samples inside one day are not independent, and counting them would manufacture a p-value of1e-40for ordinary drift. - Significance is not importance. A large enough sample makes an irrelevant
difference significant;
effectandeffectUnitsay whether it matters.
/api/v1/insights/scene-health
Section titled “/api/v1/insights/scene-health”scene (default: the busiest scenes), window (days, default 7, max 90),
bucket, limit (default 5, max 10), an optional weights JSON object, and the
usual since / until. One row per scene, least healthy first, each carrying the
six factors behind its score:
| Factor | Metric | Raw value | Good is | Weight |
|---|---|---|---|---|
perf_stability |
perf_summary |
5th-percentile FPS | higher | 0.25 |
error_rate |
error_heatmap |
errors + diagnostics per session | lower | 0.25 |
jank_rate |
jank_rate |
long frames per sampled perf window | lower | 0.20 |
dead_click_rate |
dead_clicks |
share of clicks that hit nothing | lower | 0.15 |
coverage |
scene_coverage |
positioned camera samples per session | higher | 0.10 |
xr_abandonment |
xr_abandonment |
interactions per XR session | higher | 0.05 |
{ "scene": "lobby", "score": 27.5, // weighted mean of the factors that could be scored "factors": [ { "id": "perf_stability", "metric": "perf_summary", // the endpoint that explains this factor "raw": 19.4, // what that metric produced, in `unit` "baseline": 47.1, // the project norm it was compared with "score": 0, // 0-100; 50 is exactly the project norm "weight": 0.25, "unit": "FPS", "note": "The 5th-percentile FPS of the scene's sampled frames — …", }, ], "sampleSize": 214, "since": 1757376000000, "until": 1757980800000,}The score is a comparison, not a grade. Every factor is normalised against
the project’s own baseline over the preceding equal window, using the same robust
centre and spread movers ranks with. 50 is exactly the project norm, four
robust deviations better is 100 and the same distance worse is 0 — so a project
where every scene is equally bad reads 50 everywhere. That is the right shape for
“which scene should I look at first”, and the wrong shape for “is this scene
good”.
Default weights are declared in the registry entry (and so appear in
capabilities) precisely so they can be argued with; weights overrides any of
them, factors it does not name keep their default, and an unknown factor id is a
400. A factor with score: null could not be measured — its note says why —
and is left out of the weighted mean rather than folded in as an average.
Subscriptions
Section titled “Subscriptions”Conditional subscriptions (ADR 0051 §6) are not metrics — they are project configuration with an outbound side effect — so they live outside the generated table above.
| Endpoint | Method | Capability | What it does |
|---|---|---|---|
/api/v1/subscriptions |
GET |
query |
List the project’s subscriptions, oldest first. |
/api/v1/subscriptions |
POST |
annotate |
Create one. A webhook secret is write-only. |
/api/v1/subscriptions/:id |
GET / PATCH / DELETE |
see note | Read; enable/disable; delete with its firing log. |
/api/v1/subscriptions/:id/events |
GET |
query |
The last 100 firings, newest first. |
/api/v1/subscriptions/:id/test |
POST |
annotate |
Evaluate once now; ?deliver=true really delivers. |
/api/v1/subscriptions/stream?token= |
GET (SSE) |
live token | event: subscription frames; optional &id=. |
Reads need query; PATCH and DELETE need annotate, like POST — creating or changing a
subscription is how a caller asks the collector to act on its behalf.
Only enabled is patchable. Everything else changes what the subscription means, and silently
re-pointing a standing alert is how an on-call rotation ends up watching the wrong thing; replace
it instead.
POST …/test answers with the evaluation including why it did not fire — “window held 12
samples, below minSample 30” — which is what makes a subscription tunable rather than mysterious.
See Subscriptions & webhooks for the predicate vocabulary, the signature scheme and the egress allow-list.
Scene regions
Section titled “Scene regions”Named, labelled boxes that give a scene a vocabulary for where — “the
entrance”, “the checkout counter” — so spatial answers read in words and any
spatial endpoint can be drilled into a place with ?region=<id>. Regions may
overlap; at most 200 per scene.
| Method | Path | Purpose | Body / params |
|---|---|---|---|
PUT |
/api/v1/scenes/:sceneId/regions |
Declare a scene’s regions, replacing the stored set ([] clears it). |
{ regions: [...] } |
GET |
/api/v1/scenes/:sceneId/regions |
A scene’s stored regions. An unregistered scene is [], not 404. |
— |
GET |
/api/v1/scene-regions |
Every region in the project as { sceneId, regionId, label } — the vocabulary, no boxes. |
— |
These three are not registry metrics — they are the spatial vocabulary the
aggregations are read against, so they aggregate nothing and take no format.
A region is { id, label, bounds: [minX,minY,minZ,maxX,maxY,maxZ], description? }.
Author them with registerRegions from @uptimizr/sdk-core, with a plain PUT,
or offline with uptimizr regions set <sceneId> --file regions.json.
Replay (raw events)
Section titled “Replay (raw events)”| Method | Path | Purpose | Capability | Extra params |
|---|---|---|---|---|
GET |
/api/v1/sessions/:id/events |
Ordered raw event stream for replay. | query:raw |
format=json|ndjson |
GET |
/api/v1/sessions/:id/narrative |
Ordered, compacted account of what one session did. | query:raw |
minDwellMs, fpsThreshold, maxEntries, format=full|table|text |
GET |
/api/v1/live/sessions/:id |
Live per-session follow — an SSE tail of that same raw stream. | query:raw |
token |
POST |
/api/v1/live/token |
Exchange the API key for the short-lived live-stream token. |
query |
— |
/api/v1/sessions/:id/narrative is the readable sibling of the event stream: the same gates, but
it returns an ordered, bounded account of the session (scene changes, dwell, interactions, perf
dips, errors, how it ended, then a totals entry) instead of every sampled event. It is the only
route on this page that accepts format=text — a line per entry, for putting a whole session
in a model’s context — alongside the usual full and table. See
Session narrative for the parameters and
Privacy & retention for what it omits.
Example
Section titled “Example”curl -H "x-api-key: $KEY" \ "https://collect.example.com/api/v1/perf?session=<session-id>"