Skip to content

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.

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 isranked 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.

Terminal window
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 — …"],
}

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 regionregion=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.

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.
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.
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.
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.
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.
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.
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.
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.
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.

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.

Terminal window
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.

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.

Terminal window
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_anomalieswhen 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.

metric (required) plus scene, window (days, default 28, max 365), bucket (day | hour) and the usual since / until. One row:

Terminal window
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.

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,
}
  • z ranks, deltaPct describes. z is 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.
  • direction is 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 of delta: above is a rise in a down metric, i.e. a regression.
  • aboveMinSample: false is 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.

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:

Terminal window
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 / drop are one bucket; shift is a level. The first two come from a rolling median and MAD over the trailing window (14 buckets at day grain, 168 at hour) 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 — the shift at its change-point, then drop rows for the days after it until the trailing window catches up.
  • z is in standard deviations. The trailing MAD, rescaled by 1.4826 and floored at 1% of the level, so sensitivity: 3 means “beyond three sigma”. insight_movers reports the same ratio unscaled — it ranks rather than thresholds — so the two columns differ by that constant.
  • contributor says 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. A share above ~0.8 means the anomaly is that value. It is null for a metric with no such dimension — and for a scene-split metric on a request already scoped to one scene, where the split could only ever return a share of 1.

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 ci95 before p. A narrow interval around a small effect says nothing much changed; a wide interval containing 0 says nothing at all, and powerNote tells you which you are looking at.
  • Welch counts buckets, not events. n is the number of days or hours compared. Frame samples inside one day are not independent, and counting them would manufacture a p-value of 1e-40 for ordinary drift.
  • Significance is not importance. A large enough sample makes an irrelevant difference significant; effect and effectUnit say whether it matters.

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.

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.

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.

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.

Terminal window
curl -H "x-api-key: $KEY" \
"https://collect.example.com/api/v1/perf?session=<session-id>"