This is the full developer documentation for Uptimizr # Uptimizr Documentation > Analytics for 3D web. Capture what users look at, click, and miss inside any 3D scene — then replay it. ## What is Uptimizr? [Section titled “What is Uptimizr?”](#what-is-uptimizr) Uptimizr is an open-source, self-hostable analytics platform for 3D scenes. Drop a connector into your Babylon.js, three.js, PlayCanvas, react-three-fiber, or A-Frame app and it captures the spatial signals flat web analytics can’t — gaze, clicks, mesh interactions, performance — then aggregates them into heatmaps, rankings, and full session replay. Instrument in minutes Install one connector, call `trackScene`, and heatmaps start filling in. See the [Quickstart](/docs/quickstart/). Every major engine One event schema across Babylon.js, three.js, PlayCanvas, R3F, and A-Frame. See [Connectors](/docs/connectors/overview/). Privacy-first Cookieless, no client persistent IDs, no PII by default. See [Privacy & configuration](/docs/deploy/privacy/). Self-host anywhere A single collector with an embedded store — zero infrastructure to start. See [Deploy](/docs/deploy/collector/). Try it in your browser The [live demo](https://demo.uptimizr.com) runs the playground and dashboard side-by-side with the analytics database in your browser — no account, no server, nothing uploaded. HTTP API Ingest events and query heatmaps, sessions, and performance over a simple, key-authenticated REST API. See the [API reference](/docs/api/overview/). Built for agents Every read is declared in a semantic metric registry — with units, limits and caveats — and generated into MCP tools, an OpenAPI 3.1 document and bounded `summary` results. See [Building agents on Uptimizr](/docs/guides/agents/). # Project context > GET /api/v1/context — the one read that tells an agent what your project's scenes, regions, custom events and capture channels actually are. An agent that has never seen your project knows the *shape* of this API — every metric, every parameter, every row schema, from the [metric registry](/docs/api/query/). It knows nothing about *your* project: what the scenes are called, which regions have names, which capture channels are on, what your application calls its own events, whether raw retention is enabled, or how fresh the data is. `GET /api/v1/context` answers all of it in one read. It takes a `query`-capable `x-api-key`, it is bounded (well under 16 KB for a typical project), and the collector caches it per project for about 30 seconds. ```bash curl -H "x-api-key: $KEY" "https://collect.example.com/api/v1/context" ``` ## The document [Section titled “The document”](#the-document) ```jsonc { "project": { "id": "…", "store": "duckdb", // the COLLECTOR_STORE engine behind this collector "schemaVersion": "1.0", // event wire format "collectorVersion": "2.1.0", }, "dataQuality": { "lastEventAt": 1757600000000, // epoch ms, or null for an empty project "sessions24h": 91, "events24h": 18023, "retention": { "rawSessions": false }, }, "capture": { "channels": { "camera_sample": { "seen": true, "events28d": 41203 }, "mesh_visibility": { "seen": false }, }, }, "scenes": [ { "id": "lobby", "label": "Main Lobby", "regions": [{ "id": "counter", "label": "Checkout counter" }], "proxy": true, "events28d": 12043, }, ], "vocabulary": { "customEvents": [ { "name": "add_to_cart", "count28d": 311, "sessions28d": 180, "props": { "sku": "string", "qty": "number" }, }, ], "meshes": { "count": 63, "top": ["checkout_button", "door_left"] }, "inputActions": ["jump", "sprint"], }, "definitions": { "funnels": [], "segments": [], "glossary": [] }, "annotations": { "recent": [] }, "metrics": { "available": ["top_meshes", "…"], "disabledByCapture": ["mesh_dwell"], }, "window": { "since": 1755008000000, "until": 1757600000000 }, "generatedAt": 1757600000000, } ``` ## How to read it [Section titled “How to read it”](#how-to-read-it) | Field | What to do with it | | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `scenes[].id` | The exact value for the `scene=` filter. Never infer one. | | `scenes[].regions[].id` | The exact value for the `region=` filter — a named place instead of a hand-drawn box. | | `vocabulary.customEvents[].name` | The exact name for a funnel step or a variant predicate. | | `vocabulary.customEvents[].props` | Observed prop keys with a coarse type. Prop **values** are never reported. | | `capture.channels` | One entry per canonical event type, so an absence is never ambiguous. | | `metrics.disabledByCapture` | These metrics return empty because every capture channel that feeds them is off. Say the channel is off — do not report the zero as a finding. | | `dataQuality.retention` | `rawSessions: false` means session timelines and replay are unavailable **by design** (privacy, opt-in), not broken. | | `dataQuality.lastEventAt` | How stale the answer you are about to give is. `null` means the project has recorded nothing. | Everything in the document is aggregate and project-scoped — nothing a `query` key could not already read through the query API. ## Bounds [Section titled “Bounds”](#bounds) The document is capped so it always fits in a model’s context window: at most 40 scenes (20 regions each), 25 custom events (40 prop keys each), 10 top meshes, 25 input actions, 10 annotations and 50 glossary entries. `definitions.funnels` / `definitions.segments` and `annotations.recent` are present and empty until the metadata store holds any. The 28-day figures are computed over `window`. ## Custom-event vocabulary [Section titled “Custom-event vocabulary”](#custom-event-vocabulary) The `vocabulary.customEvents` block is also a metric in its own right, so it is a generated agent tool (`custom_event_vocabulary`) and takes the usual `since` / `until` / `scene` / `limit` / [`format`](/docs/api/query/) parameters: ```bash curl -H "x-api-key: $KEY" \ "https://collect.example.com/api/v1/vocabulary/custom-events?since=$SINCE&limit=50" ``` ```json [ { "name": "add_to_cart", "count": 311, "sessions": 180, "props": { "sku": "string", "qty": "number" } } ] ``` `count` and `sessions` are exact over the range. `props` is discovered from the **20 most recent events per name**, so treat it as a vocabulary hint rather than a schema: a key that stopped being emitted long ago will be absent, and a rarely-sent optional key can be missed. A key seen with more than one JSON kind is reported as `"mixed"`; `"null"` means every sampled value was null. ## Clients [Section titled “Clients”](#clients) * **MCP** — the same document is the `uptimizr://context` resource. Every curated prompt tells the agent to read it first. * **The in-browser assistant** — `useAssistant` fetches it when the collector connection is established and injects a compact rendering into the system prompt. A collector too old to serve the endpoint is not an error: the assistant simply runs without it. * **Anything else** — `renderContextForPrompt()` in `@uptimizr/agent-core` turns the document into the same short prompt block, if you are building your own loop. # Ingestion > The collector's batched event ingestion endpoint. The SDK delivers events to a single ingestion endpoint. You normally never call this directly — a connector’s `trackScene` (via `@uptimizr/sdk-core`) batches events and sends them for you — but the contract is documented here for custom transports and self-hosting. ## `POST /api/v1/collect` [Section titled “POST /api/v1/collect”](#post-apiv1collect) Batched events. The SDK uses `navigator.sendBeacon` (credentialed) and falls back to `fetch` with `keepalive`. Authenticate with the project API key. | Property | Value | | ------------ | ------------------------------------------------------------ | | Method | `POST` | | Path | `/api/v1/collect` | | Auth | `x-api-key: ` | | Content-Type | `application/json` | | Body | A batch of events, each validated against `@uptimizr/schema` | Events are validated against the Zod schemas in `@uptimizr/schema` at the edge; invalid batches are rejected. Every event carries the shared envelope (ordered, timestamped, keyed by `sessionId`). ## Delivery behavior [Section titled “Delivery behavior”](#delivery-behavior) * **Batching.** `@uptimizr/sdk-core` flushes when the batch reaches `batchSize` (default `20`) or after `flushIntervalMs` (default `5000`). * **Beacon first.** Sends use `navigator.sendBeacon` so events survive page unload, falling back to `fetch({ keepalive: true })`. * **Privacy.** No cookies and no persistent client id; the `sessionId` is in-memory only. ## Scene registry (write) [Section titled “Scene registry (write)”](#scene-registry-write) A scene can register a **proxy** of its geometry (per-mesh bounding boxes) so the dashboard’s 3D heatmaps draw against a recognizable backdrop. Writes use the same project API key. | Method | Path | Purpose | Body | | ------ | ---------------------------------------- | -------------------------------------------------------------------- | ------------------- | | `PUT` | `/api/v1/scenes/:sceneId/representation` | Register/replace a scene proxy. `proxy.sceneId` must match the path. | `{ proxy, label? }` | ```bash curl -X PUT -H "x-api-key: $KEY" -H "content-type: application/json" \ -d '{"proxy": , "label": "Main Lobby"}' \ "https://collect.example.com/api/v1/scenes/lobby/representation" ``` The proxy is produced client-side by `scanSceneProxy(scene, { sceneId })` in `@uptimizr/babylon`. # Metadata endpoints > Annotations, the project glossary and saved analyses — the collector's only write path besides ingestion, behind the `annotate` capability. Numbers alone do not carry what a team knows. These three endpoints are where that knowledge goes: a note on a spike, a definition of a name only your project uses, a question worth re-asking with the answer it got. | Table | What it holds | | ---------------- | --------------------------------------------------------------------------------------------- | | `annotations` | Notes pinned to the project, a scene, a mesh, a region, a metric, or a period of time. | | `glossary` | What a name means **in this project** — a mesh name, a scene id, a custom event, a shorthand. | | `saved_analyses` | A titled question plus the conclusion drawn from it, so it need not be re-derived. | Events stay read-only This is the collector’s only write path besides ingestion, and it writes **metadata, never events**. Nothing here can write, alter or delete an analytics event: the query API remains read-only and aggregate-only. Every write needs a key holding the `annotate` capability, reads need `query`, and every write is recorded in the [agent audit log](/docs/api/overview/). ## Endpoints [Section titled “Endpoints”](#endpoints) | Method | Path | Purpose | Capability | | -------- | ------------------------- | -------------------------------------------------- | ---------- | | `GET` | `/api/v1/annotations` | The project’s notes, newest first. | `query` | | `POST` | `/api/v1/annotations` | Leave a note. Answers `201` with the stored row. | `annotate` | | `DELETE` | `/api/v1/annotations/:id` | Remove a note. `204`, or `404` if it is not there. | `annotate` | | `GET` | `/api/v1/glossary` | The whole glossary, ordered by term. | `query` | | `PUT` | `/api/v1/glossary/:term` | Define (or redefine) a term — idempotent. | `annotate` | | `DELETE` | `/api/v1/glossary/:term` | Undefine a term. | `annotate` | | `GET` | `/api/v1/analyses` | Saved analyses, newest first. | `query` | | `POST` | `/api/v1/analyses` | Save an analysis. Answers `201`. | `annotate` | | `DELETE` | `/api/v1/analyses/:id` | Remove a saved analysis. | `annotate` | ```bash # A note about a period of time curl -X POST -H "x-api-key: $KEY" -H "content-type: application/json" \ -d '{"targetKind":"window","since":1757000000000,"until":1757003600000, "text":"CDN incident — ignore the load-time spike here."}' \ "https://collect.example.com/api/v1/annotations" # What a mesh name means in this project curl -X PUT -H "x-api-key: $KEY" -H "content-type: application/json" \ -d '{"meaning":"the till cluster by the exit"}' \ "https://collect.example.com/api/v1/glossary/checkout%20counter" # A question worth keeping curl -X POST -H "x-api-key: $KEY" -H "content-type: application/json" \ -d '{"title":"Lobby FPS after the lighting change", "query":{"metric":"perf_summary","scene":"lobby"}, "conclusion":"p50 fell from 58 to 41 on integrated GPUs."}' \ "https://collect.example.com/api/v1/analyses" ``` ## An annotation’s target [Section titled “An annotation’s target”](#an-annotations-target) `targetKind` is one of `project`, `scene`, `mesh`, `region`, `metric` or `window`. The four middle ones name something and need a `targetId` (the scene id, mesh name, region id or metric id); `window` needs a `since`; `project` is a standing note about everything. `since`/`until` are epoch milliseconds. On a read they are an **overlap** filter — a note matches when its period intersects the requested window, and a standing note always matches, because it is about the project rather than about a moment. ## Who wrote it [Section titled “Who wrote it”](#who-wrote-it) Every stored row carries `authorKind` (`user` or `agent`) and `authorKeyId` (the key’s row id, never the key itself). The collector decides `authorKind` from the calling client, never from the payload: a request carrying `x-uptimizr-client: dashboard` — a person clicking in a first-party UI — is `user`; everything else holding an `annotate` key, including the in-browser assistant writing up its own answer, is `agent`. ## Bounds [Section titled “Bounds”](#bounds) Free text is capped at the boundary — `text` ≤ 2 000 characters, `meaning` ≤ 500, `title` ≤ 120, `conclusion` ≤ 4 000, and a saved `query` document ≤ 8 000 serialized characters. Each project holds at most 500 annotations, 200 glossary terms and 200 saved analyses. A write past a cap answers `409`: the payload was fine, the project is full — delete something and retry. `query` on a saved analysis is an **opaque JSON object**: the collector stores it and does not interpret it. Put the endpoint and filters that produced the answer in it, so the next reader can re-run what you actually ran. ## Privacy [Section titled “Privacy”](#privacy) These rows are written by your own operators and agents, not captured from visitors, so they are the one place in the collector that is not machine-bounded to non-PII. They are project-scoped, readable only with that project’s key, bounded in size and count, and audited — but do not paste personal data into them. See [Privacy & configuration](/docs/deploy/privacy/). ## From an agent [Section titled “From an agent”](#from-an-agent) `@uptimizr/mcp` exposes the same three writes as the MCP tools `annotate`, `define_term` and `save_analysis`, plus `list_annotations`, `list_glossary` and `list_analyses`. They are registered **only** when the configured key holds `annotate` — the server asks `GET /api/v1/whoami` once at start-up — so a read-only key yields a read-only server. See [MCP server](/docs/guides/mcp/) and [Building agents on Uptimizr](/docs/guides/agents/). # HTTP API — overview & auth > How to authenticate against the collector's ingestion and query API, and the shared parameters. The collector exposes one **ingestion** endpoint and a set of **read** endpoints. All requests authenticate with a **project API key** in the `x-api-key` header; the project is resolved from the key server-side, so a client can only ever read or write its own data. ```bash KEY=… # a project API key BASE=https://collect.example.com # or http://localhost:8787 locally curl -s -H "x-api-key: $KEY" "$BASE/api/v1/sessions?limit=20" ``` * No key / unknown key → `401`. * There is no cross-project query — a caller can only ever read its own data. * Do **not** add a `projectId` query param to “widen” a query; it is ignored. ## API keys and capabilities [Section titled “API keys and capabilities”](#api-keys-and-capabilities) A key is not “all or nothing”: it carries a **set of capabilities**, so a key you hand to an agent can be scoped to exactly what that agent needs (ADR 0051 §7). | Capability | Grants | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `query` | The aggregate analytics API — every endpoint on the [query reference](/docs/api/query/) — plus the scene registry reads, the live token exchange, `whoami` and the audit trail. | | `query:raw` | Raw **per-session** streams only: `GET /api/v1/sessions/:id/events` and the live per-session follow `GET /api/v1/live/sessions/:id`. | | `annotate` | The project **metadata** write path (today: [`PUT /api/v1/scenes/:sceneId/regions`](/docs/api/query/#scene-regions)). Never events — events stay append-only. | | `ingest` | Reserved for server-side write paths. Public ingestion is keyless by design, so issued keys are normally read keys. | `uptimizr init` and `uptimizr new-project` mint one **owner** key — `query`, `query:raw` and `annotate` — because it is the operator’s own key, driving the dashboard, replay, live follow and scene regions. Every other key comes from `uptimizr new-key`, which defaults to `query` (read-only) — the key to hand an agent or MCP client: ```bash npx -p @uptimizr/collector-server uptimizr new-key \ --capabilities query --label "mcp-agent" ``` * A key that is authenticated but **lacks** the capability a route requires → `403`, not `401`. The body names the missing permission (`api key not permitted to read raw session data`, …). * `query:raw` is honoured only when the collector also runs with `ENABLE_RAW_SESSION_RETENTION`; both halves are required, and either one missing is a `403`. ### `GET /api/v1/whoami` [Section titled “GET /api/v1/whoami”](#get-apiv1whoami) Ask what the calling key actually holds, so an agent can register only the tools its capabilities permit instead of discovering a `403` mid-conversation. It needs `query` and reports the key’s **id**, never the key itself: ```jsonc { "projectId": "3f2a…", "keyId": "9c41…", "capabilities": ["query", "query:raw"], "label": "replay", "rateLimit": { "max": 600, "windowMs": 60000 }, "rateLimitSource": "default", // or "key" when the key carries its own budget } ``` ### Per-key rate limits [Section titled “Per-key rate limits”](#per-key-rate-limits) `uptimizr new-key … --rate-limit-max 120 --rate-limit-window-ms 60000` gives a key its own request budget, bucketed on the **key id** rather than the client IP — so one busy agent cannot spend another’s allowance. Keys without one fall back to the collector’s `COLLECTOR_RATE_LIMIT_MAX` / `COLLECTOR_RATE_LIMIT_WINDOW_MS` defaults (600 requests / 60 s). `whoami` always reports the budget actually in force. Ingestion is untouched: it is keyless and keeps its own `COLLECTOR_INGEST_RATE_LIMIT_*` budget. ### Agent audit log [Section titled “Agent audit log”](#agent-audit-log) Every authenticated request made with a key that is not the dashboard’s own session is recorded — `{ id, projectId, keyId, at, surface, toolOrPath, params, rowCount, durationMs, status }` — and `GET /api/v1/audit` (a `query` key, newest first, `since` / `until` / `limit`) reads it back. Refusals are recorded too, which is precisely what a project owner wants to see. Rows carry the route **pattern** rather than the URL, bounded and credential-redacted params, and never any key material. Writes happen after the response is flushed, so the log can never block or fail a request. Rows older than `AUDIT_RETENTION_DAYS` (default `30`) are swept. The dashboard identifies itself with `x-uptimizr-client: dashboard` and is skipped by default so its panel refreshes do not bury agent activity; `AUDIT_DASHBOARD_REQUESTS=1` records everything. That is a volume filter, not a security boundary. ### Machine-readable description [Section titled “Machine-readable description”](#machine-readable-description) ```bash curl https://collect.example.com/api/v1/openapi.json ``` `GET /api/v1/openapi.json` serves an **OpenAPI 3.1** document for the whole read API — no key required, because it is documentation and contains no project data. It is generated from the same semantic metric registry the endpoints are, so it lists exactly the aggregations *that* collector can compute, with the schema that actually validates each parameter and the `apiKey` security scheme declared. See the [MCP guide](/docs/guides/mcp/#openapi). ## Shared parameters [Section titled “Shared parameters”](#shared-parameters) Binned and aggregate endpoints accept these (coerced and bounded by Zod at the edge — out-of-range values are rejected with `400`, not clamped): | Param | Unit / type | Notes | | ------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | | `since` | epoch **milliseconds** | Range start. Omit for the server default window. | | `until` | epoch **milliseconds** | Range end. | | `bins` | `1`–`500` | Bin count for binned heatmaps. | | `limit` | `1`–`1000` | Result cap on list/top endpoints (no cursors beyond this). | | `cellSize` | `> 0`, `≤ 1000` | Voxel size in world units for world / click-ray heatmaps. | | `interval` | **seconds** | Bucket width for `timeseries`. | | `scene` | id | Scope to one scene/area/level (the value passed to `setScene`). | | `source` | enum | Input-source filter: `mouse`, `touch`, `stylus`, `pen`, `xr-controller`, `hand`, `gaze`, `transient`, `other`. | | `session` | id | Scope an aggregate to a single session. | | `cameraMode` | `viewer` \| `first-person` | Restrict to orbit (`viewer`) or free/walkable (`first-person`) camera sessions. | | `format` | `full` \| `table` \| `summary` | Result envelope, not a filter. `full` (the default) is the bare rows; see [result formats](/docs/api/query/#result-formats). | Common pitfalls * **`since`/`until` are milliseconds; `interval` is seconds.** Mixing units is the most common “empty result” cause. * **Aggregate columns are JSON numbers on every store** — no client-side coercion needed. But a `null` means “no samples”, not `0`: a one-row summary is still returned over a range that matched nothing, with its aggregate columns `null`. * **Empty (200 + no rows) is not an error** — usually the time window or a filter excluded everything. Widen `since`/`until` and drop filters to confirm data exists, then narrow. ## Prefer the MCP server for agents [Section titled “Prefer the MCP server for agents”](#prefer-the-mcp-server-for-agents) For an AI agent (or any MCP client) that should read analytics, use the read-only [`@uptimizr/mcp`](https://github.com/RaananW/Uptimizr/tree/main/oss/packages/mcp) server instead of hand-rolling HTTP calls. It wraps each read endpoint as a typed, GET-only tool, configured with `UPTIMIZR_COLLECTOR_URL` + `UPTIMIZR_API_KEY`. It exposes no ingestion, mutation, or raw-event tools. # Query endpoints > The collector's read API — sessions, heatmaps, mesh and performance aggregates, and replay. All read endpoints take `x-api-key` and the [shared parameters](/docs/api/overview/). Reads are scoped to the project resolved from the key. Below are the most-used endpoints, grouped by need; see the [overview](/docs/api/overview/) for auth and parameter units. ## Result formats [Section titled “Result formats”](#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. ```bash curl -H "x-api-key: $KEY" \ "https://collect.example.com/api/v1/meshes/top?since=$SINCE&format=summary" ``` ```jsonc { "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 — …"], } ``` Note `reading` is templated from the metric registry’s column semantics by pure code — no model is involved, so the same rows always produce the same sentence. Shares (and the Wilson `confidence` note) appear only where the measure can honestly be summed: a metric measured in FPS or as a ratio reports `total: null` and no shares. Cluster coordinates are **grid indices** — multiply by the effective `cellSize` to place them in world space. ### Labelled spatial clusters [Section titled “Labelled spatial clusters”](#labelled-spatial-clusters) A hotspot at `centroid: [7, 2, 11]` is unreadable. When the selected scene has a registered proxy and [regions](#scene-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 | ```jsonc // 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](#scene-regions). The tables below are generated from the collector’s [semantic metric registry](https://github.com/RaananW/Uptimizr/blob/main/docs/adr/0051-ai-first-analytics-layer.md), 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”](#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”](#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”](#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”](#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”](#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”](#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. | ## WebXR [Section titled “WebXR”](#webxr) | 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”](#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”](#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”](#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)”](#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. ```bash 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=` takes the same document in one parameter (8 KiB max), for clients that only issue `GET`s. 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”](#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”](#comparing-explaining-drilling) ```bash 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. Two generic-tier-only filters `filters.event` is an ADR 0038 step predicate used as a **cohort** — keep the sessions in which that event happened at least once, then compute the metric over them. `filters.device` matches `os` and `browser` on the session’s `session_start` (both derived server-side from the User-Agent, ADR 0042). Neither exists on a metric with no generic tier, which refuses them by name. `gpuTier` is in the published grammar but no connector reports one, so it is refused rather than silently matching nothing — group by `device.renderer` instead. 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: ```jsonc { "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”](#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. Note Window bounds are **snapped down to whole buckets**, so the day or hour in progress is excluded — otherwise every morning would look like a traffic collapse. The window that was actually measured comes back in the `table` and `summary` envelopes. `scene-health` is the one exception (it says why below): none of its factors is a count, so a partial bucket does not distort it. ### `/api/v1/insights/baseline` [Section titled “/api/v1/insights/baseline”](#apiv1insightsbaseline) `metric` (required) plus `scene`, `window` (days, default 28, max 365), `bucket` (`day` | `hour`) and the usual `since` / `until`. One row: ```bash curl -H "x-api-key: $KEY" \ "https://collect.example.com/api/v1/insights/baseline?metric=perf_summary&scene=lobby" ``` ```jsonc { "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”](#apiv1insightsmovers) `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: ```jsonc { "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. Bounded by design, and not every metric qualifies A request scans at most **24** metrics, one grouped scan each; without a `metrics` allowlist a curated default set is used, so a move outside that set is not reported unless you name it. Only `comparable` metrics with a faithful per-bucket form participate at all — funnels, cohort metrics and anything defined by the relationship between *consecutive* events (click→gaze rays, flow links, reachability, backtracking, rage clusters, perf churn) are excluded by construction, because bucketing them would change what they mean. Asking for one returns `400` listing every metric id that *is* available. The same rule governs `/api/v1/insights/anomalies`. ### `/api/v1/insights/anomalies` [Section titled “/api/v1/insights/anomalies”](#apiv1insightsanomalies) `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: ```bash curl -H "x-api-key: $KEY" \ "https://collect.example.com/api/v1/insights/anomalies?metric=error_heatmap&scene=lobby" ``` ```jsonc { "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. Note A bucket with fewer than five buckets of history behind it is never judged, so a new project reads “no anomalies” rather than “every day is an anomaly”. Buckets with no matching events are absent from the series rather than zero, so a gap in capture is not reported as a drop. Attribution costs **at most three** extra grouped scans per request however many buckets are anomalous: adjacent findings share one window, and only the three most extreme windows are re-read — quieter findings carry `contributor: null`. ### `/api/v1/insights/significance` [Section titled “/api/v1/insights/significance”](#apiv1insightssignificance) `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` | ```jsonc { "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. Two windows, not two segments A variant-versus-variant or device-versus-device contrast needs the bucket series split by a promoted dimension, which is not available yet. Passing `segment` or `refSegment` returns `400` naming the window parameters, rather than answering a segment question with a window comparison. ### `/api/v1/insights/scene-health` [Section titled “/api/v1/insights/scene-health”](#apiv1insightsscene-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 | ```jsonc { "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. Note This is the one insight window that **includes the bucket in progress**. Every health factor is a rate or a percentile, and a partial bucket gives half the numerator *and* half the denominator — so flooring `until` here would only make the score answer about yesterday. The window actually measured comes back as `since` / `until` on every row. ## Subscriptions [Section titled “Subscriptions”](#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](/docs/deploy/collector/#subscriptions--webhooks) for the predicate vocabulary, the signature scheme and the egress allow-list. ## Pinned panels [Section titled “Pinned panels”](#pinned-panels) Declarative panel specs (ADR 0051 §7) are **not** metrics either — they are what an agent leaves on the dashboard when an answer is worth keeping — so they live outside the generated table too. | Endpoint | Method | Capability | What it does | | -------------------- | -------- | ---------- | -------------------------------------------------- | | `/api/v1/panels` | `GET` | `query` | The project’s pinned panels, **oldest first**. | | `/api/v1/panels` | `POST` | `annotate` | Pin a panel. `201` with the stored row. | | `/api/v1/panels/:id` | `PUT` | `annotate` | Replace a spec, keeping its id, place and author. | | `/api/v1/panels/:id` | `DELETE` | `annotate` | Unpin a panel. `204`, or `404` if it is not there. | A spec is a **closed document** — a metric id, a chart name, some column names — stored as data and drawn with the panel components the dashboard already ships. Nothing is imported and nothing is evaluated, so pinning a panel does not widen the dashboard’s trust boundary (ADR 0041). ```json { "v": 1, "title": "Meshes people actually touch", "query": { "v": 1, "metric": "top_meshes", "range": "inherit", "limit": 10 }, "chart": "bar", "encoding": { "x": "mesh", "y": "count" }, "span": 1, "note": "The crate outsells everything else three to one." } ``` The spec’s `query` is a `queryV1` document with one addition: `range` may be the literal `"inherit"`, meaning “whatever the dashboard’s filter bar currently says”, substituted at render time. `format` and `explain` are not part of a spec — a panel draws rows. Two validators run, in order: `panelSpecV1Schema` for the shape, then the metric registry for the vocabulary. The second answers the question that decides whether the panel draws anything — does this `chart` suit the metric’s grain, and do these `encoding` columns exist in the result — and a failure is `400 { error, issues }` carrying the same issue shape a rejected query gets, plus the codes `chart_grain_mismatch` and `unknown_encoding_column`. The compatibility table is in [Custom dashboard panels](/docs/guides/custom-panels/#declarative-spec-panels). A project holds at most 50 panels; a write past the cap answers `409`. ## Scene regions [Section titled “Scene regions”](#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=`. 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 --file regions.json`. Caution The two reads take a `query`-capable API key; the **write** takes an `annotate`-capable one — the dedicated metadata-write capability, so a read-only key you hand to an agent cannot redraw your spatial vocabulary. Mint one with `uptimizr new-key --capabilities annotate` (or `query,annotate` if the same key reads the regions back); a `query`-only key is refused with `403`. `uptimizr regions set` writes straight to the store and needs no key at all. And as with the proxy upload, never ship the API key in a public bundle: register regions from a build script, a server-side route, or a developer-only path. ## Replay (raw events) [Section titled “Replay (raw events)”](#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` | — | Raw per-session access has two gates Both raw routes need **both** halves, and either one missing is a `403`: 1. the collector running with `ENABLE_RAW_SESSION_RETENTION` (ADR 0003), **and** 2. the `query:raw` capability on the key (ADR 0051 §7). A plain `query` key reads every aggregate endpoint on this page but is refused here. Mint a raw-capable key with `uptimizr new-key --capabilities query,query:raw --label "replay"`, and check what a key already holds with [`GET /api/v1/whoami`](/docs/api/overview/#api-keys-and-capabilities). `/api/v1/live/sessions/:id` authenticates with a short-lived token rather than the `x-api-key` header, because `EventSource` cannot send one; the key’s capability set rides along inside the token, so `query:raw` is enforced there too. The aggregate endpoints never expose raw events — don’t reach for the raw stream to build an aggregate. `/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](/docs/guides/sessions/#session-narrative) for the parameters and [Privacy & retention](/docs/deploy/privacy/#what-a-session-narrative-shows) for what it omits. ## Example [Section titled “Example”](#example) ```bash curl -H "x-api-key: $KEY" \ "https://collect.example.com/api/v1/perf?session=" ``` # Concepts > The core ideas behind Uptimizr — events, the envelope, scenes, heatmaps, sessions, and replay. A short tour of the vocabulary you’ll meet throughout these docs. ## Events & the envelope [Section titled “Events & the envelope”](#events--the-envelope) Everything Uptimizr captures is an **event**. Every event shares a common **envelope** — it is ordered, timestamped, and keyed by `sessionId` — and a typed payload. All event shapes live once, as Zod schemas, in `@uptimizr/schema`; connectors and the collector import them rather than redefining them. This is what makes a session **replay-complete**: the ordered event stream is enough to reconstruct what happened. Events fall into two groups: * **Continuous channels** — `camera_sample`, `pointer_move`, `frame_perf`. Sampled at a configurable rate (Hz) and idle-suppressed. * **Discrete events** — `pointer_click`, `mesh_interaction`, `scene_change`, `custom`, `session_start`/`session_end`, and more. Always captured at 100%; cannot be rate-limited. ## Sessions & visitors [Section titled “Sessions & visitors”](#sessions--visitors) A **session** is one continuous visit, identified by an in-memory `sessionId` that never touches disk on the client. A **visitor** is counted with a hash computed **server-side** that rotates every day — so visits can be de-duplicated within a day without tracking anyone across days. There are no cookies and no persistent client identifiers (see [Privacy](/docs/deploy/privacy/)). ## Scenes [Section titled “Scenes”](#scenes) A **scene** is a named area, level, or view within your app. Call `tracker.setScene("level-2")` (or pass `meta.sceneId`) to segment analytics. Most query endpoints accept a `scene` filter so you can scope a heatmap to one area. Optionally, a scene can register a lightweight **proxy** of its geometry (per-mesh bounding boxes) so the dashboard can draw 3D heatmaps against a recognizable backdrop without your real assets. ## Heatmaps [Section titled “Heatmaps”](#heatmaps) Uptimizr aggregates raw spatial events into several heatmap types: * **Pointer heatmap** — 2D screen-space hover/click density. * **World heatmap** — 3D world-space pointer density, voxel-binned. * **Camera (view-direction) heatmap** — spherical bins of where the camera looked. * **Gaze heatmap** — world-space points where the audience actually looked on the geometry (opt-in). * **Click rays / flow** — clicks ray-cast into the scene, and gaze→mesh flow links. Pose inputs (XR controllers, hands, gaze) use their own pointing-ray origin; flat pointers (mouse, touch, stylus) reconstruct the ray origin on the camera’s near plane from the click’s screen position (ADR 0043), so the rays fan out the way the clicks were actually made. In **first-person / walkable** scenes that use the browser Pointer Lock API, the cursor is hidden and the aim point is the fixed crosshair at the viewport centre. The connectors detect pointer lock and report pointer/click events from screen centre (ADR 0034), so the 2D pointer heatmap naturally clusters at the centre — for those scenes read the cursor-independent **gaze heatmap**, the floor-plan position heatmap, and session trajectories instead. ## Performance [Section titled “Performance”](#performance) `frame_perf` samples carry FPS, frame time, long-frame counts, and draw metrics. The query API returns percentile aggregates (median/p50, min, avg) so a “p95 frame time” is one request away. ## Replay [Section titled “Replay”](#replay) Because the event stream is replay-complete, a recorded session can be **re-driven** inside your own scene — re-applying the visitor’s camera, pointer, and interactions (and, opt-in, scene-actor motion). Replay reads the raw, ordered event stream, which requires the collector to run with `ENABLE_RAW_SESSION_RETENTION` enabled. # A-Frame > Instrument an A-Frame (WebXR) scene with the @uptimizr/aframe connector. The A-Frame (WebXR) connector. A-Frame renders three.js under the hood, so this package is a thin A-Frame layer over [`@uptimizr/three`](/docs/connectors/three/) — it does not re-implement capture. A declarative component reads `sceneEl.object3D`, `sceneEl.camera`, and `sceneEl.renderer` and hands them to the three connector. `three` is a peer dependency; **A-Frame itself is supplied by the host page** (the connector never imports `aframe` — it registers against the global `AFRAME`). ## Install [Section titled “Install”](#install) ```bash npm install @uptimizr/aframe three ``` ## Usage [Section titled “Usage”](#usage) Load A-Frame, import this package (it registers the `uptimizr` component), and add the attribute to your ``: ```html ``` A bare `import "@uptimizr/aframe"` is enough — the entry point registers the component against the global `AFRAME`. To register against an explicit instance, call `registerUptimizrComponent(AFRAME)`. Capture starts when the component initializes and stops on teardown, tearing down every listener, timer, and animation-frame callback — no cookies, no persistent IDs. World-space data is normalized to the canonical wire frame by `@uptimizr/three`; the session is attributed to the `aframe` connector. ## Scene id and camera kind [Section titled “Scene id and camera kind”](#scene-id-and-camera-kind) Every connector tags events with a scene/area id (ADR 0010) so you can filter and compare scenes. Declaratively that is the `sceneId` attribute. three’s classification records every `PerspectiveCamera` as a first-person (`free`) camera, so orbit-style scenes should set `cameraType: arc-rotate` to avoid being treated as walkable in the dashboard: ```html ``` | Attribute | Meaning | | ------------ | ----------------------------------------------------------------------- | | `sceneId` | Scene/area id every event carries (empty ⇒ the connector default scene) | | `cameraType` | `arc-rotate`, `free`, `follow`, `static`, or `other`; empty ⇒ automatic | ## WebXR input [Section titled “WebXR input”](#webxr-input) A-Frame is WebXR-first, so the connector maps XR input onto the existing source-neutral events with no new event types: controller/gaze **pose** → `pointer_move` with a world-space `ray` and `source` (`xr-controller`, `hand`, `gaze`, `transient`), **select** → `pointer_click` (+ `mesh_interaction` `kind: select`), **squeeze** → `mesh_interaction` `kind: squeeze`. The headset pose keeps flowing as `camera_sample`. In-scene hit resolution is on by default: the component builds a raycast probe over the live three.js scene graph, so ray samples carry `hitPoint`/`hitMesh` and select/squeeze attach to the object actually hit. Name your entities’ meshes (`object3D.name`) to make `hitMesh` meaningful. | Attribute | Meaning | | ------------ | ------------------------------------------------- | | `xr` | WebXR controller/gaze capture (default `true`) | | `xrSampleMs` | XR pose sampling interval in ms (`0` ⇒ 250) | | `xrRaycast` | Resolve XR rays to in-scene hits (default `true`) | ```html ``` # Babylon.js > Instrument a Babylon.js scene with the @uptimizr/babylon connector. The Babylon.js connector. `@babylonjs/core` is a peer dependency — the connector reads your existing scene and never bundles or mutates it. ## Install [Section titled “Install”](#install) ```bash npm install @uptimizr/babylon @babylonjs/core ``` ## Usage [Section titled “Usage”](#usage) The quickest integration is a single call: ```ts import { trackScene } from "@uptimizr/babylon"; const tracker = trackScene(scene, { projectId: "your-project", endpoint: "https://collect.example.com", }); // later, on teardown tracker.dispose(); ``` `trackScene` creates the client, registers the Babylon collector, reads device/GPU caps, and starts the session. The return value exposes the session id, custom events, and lifecycle: ```ts tracker.track("add_to_cart", { sku: "ABC-123" }); tracker.setScene("level-2"); tracker.dispose(); ``` ## Choosing the camera [Section titled “Choosing the camera”](#choosing-the-camera) By default the connector records the engine’s active camera. For multi-camera scenes (picture-in-picture, split-screen, render-target rigs) set `camera` explicitly — otherwise the “active” camera is ambiguous and the view-direction heatmap can collapse to a single direction: ```ts trackScene(scene, { projectId, endpoint, camera: mainCamera }); ``` Each `camera_sample` also carries the camera’s projection intrinsics — vertical `fov`, viewport `aspect`, and the near-plane distance `near` — captured automatically when the engine exposes them. They let the click-ray heatmap reconstruct a flat pointer’s ray origin on the near plane instead of collapsing it to the camera point (ADR 0043); they’re optional, so a camera that doesn’t expose them simply falls back to the camera position. ## First-person scenes (pointer lock) [Section titled “First-person scenes (pointer lock)”](#first-person-scenes-pointer-lock) When the rendering canvas holds the browser [Pointer Lock](https://developer.mozilla.org/docs/Web/API/Pointer_Lock_API) (`engine.enterPointerlock()`, first-person/FPS navigation), the OS cursor is hidden and the aim point is the fixed crosshair at the viewport centre. The connector detects pointer lock and reports `pointer_move` / `pointer_down` / `pointer_up` / `pointer_click` from screen centre (`screen = [0.5, 0.5]`), re-picking at the render-target centre — the crosshair the visitor actually aims with. The 2D pointer heatmap therefore clusters at the centre for locked scenes; read the cursor-independent gaze / floor-plan heatmaps instead. Cursor (orbit/viewer) scenes are unaffected. See [Concepts → pointer lock](/docs/concepts/) (ADR 0034). ## Capture fidelity [Section titled “Capture fidelity”](#capture-fidelity) Continuous channels are sampled at conservative defaults (≈1 Hz camera, ≈4 Hz pointer, ≈0.5 Hz perf). Raise or lower them per channel with `sampling` (rates in Hz, `"frame"` for every tick, or `0` to disable): ```ts trackScene(scene, { projectId, endpoint, sampling: { camera: 10, // 10 Hz camera pose pointerMove: 60, // 60 Hz pointer movement perf: 0.5, // a perf sample every 2 s }, }); ``` Discrete events (clicks, mesh interactions, scene changes, custom events) are always captured at 100% and cannot be rate-limited. ## Opt-in channels [Section titled “Opt-in channels”](#opt-in-channels) Off by default for privacy and cost — enable as needed: ```ts trackScene(scene, { projectId, endpoint, capture: { hoverDwell: true, resourceSample: true, gaze: true }, meshVisibility: { meshes: ["product-hero"], windowMs: 5000 }, }); ``` * `meshVisibility` — per-object dwell summaries (`mesh_visibility`). * `capture.hoverDwell` — hover hesitation episodes (`hover_dwell`). * `capture.resourceSample` — GPU/memory footprint (`resource_sample`). * `capture.gaze` — world-space gaze hit points (`camera_sample.hitPoint`). * `captureErrors` — opt-in `runtime_error` capture (not auto-redacted). ## Scene proxy (3D heatmap backdrop) [Section titled “Scene proxy (3D heatmap backdrop)”](#scene-proxy-3d-heatmap-backdrop) To draw 3D heatmaps against a recognizable backdrop without shipping your real geometry, register a lightweight proxy (per-mesh bounding boxes): ```ts import { scanSceneProxy } from "@uptimizr/babylon"; const proxy = scanSceneProxy(scene, { sceneId: "lobby" }); // PUT it to /api/v1/scenes/lobby/representation (see the HTTP API docs) ``` For a custom transport or a `beforeSend` hook, see [sdk-core (advanced)](/docs/connectors/sdk-core/). ## AR placement (WebXR “view in your room”) [Section titled “AR placement (WebXR “view in your room”)”](#ar-placement-webxr-view-in-your-room) Retail AR placement is app-driven, so `babylonArPlacementCollector` turns three `Observable`s from your placement UI into exactly **one** `ar_placement` event per settle — never per frame. It counts re-placement attempts, times how long placement took, records the final scale, and classifies the surface coarsely from the WebXR hit-test normal (`floor` / `wall` / `table` / `ceiling` / `unknown`): ```ts import { UptimizrClient } from "@uptimizr/sdk-core"; import { babylonCollector, babylonArPlacementCollector } from "@uptimizr/babylon"; const client = new UptimizrClient({ projectId, endpoint }); client.use(babylonCollector({ scene })); client.use( babylonArPlacementCollector({ mesh: "Sofa", onPlacementStartObservable, // { mesh } — user entered placement mode onPlaceObservable, // { position, normal? } — one fire per (re-)place onSettleObservable, // { position, scale?, final? } — the commit hitTest: xrHitTest, // optional Babylon WebXRHitTest feature }), ); client.start(); ``` Signals are coarse and on-device only ([privacy](/docs/deploy/privacy/)): no plane polygons, no room dimensions, no PII — only the coarse surface bucket, counts, and durations leave the client. The data powers the dashboard’s **AR placement funnel** panel and the `/api/v1/ar/placement/*` endpoints. # Babylon Lite > Instrument a Babylon Lite (@babylonjs/lite) scene with the @uptimizr/babylon-lite connector. The **Babylon Lite** connector. Babylon Lite is a functional / data-oriented, WebGPU-only Babylon engine — no classes, no scene observables. Because the app owns the canvas and drives the render loop, this connector mirrors the three.js adapter’s shape: pass the scene, camera, and canvas explicitly. `@babylonjs/lite` is an optional peer dependency — the connector reads your existing instance and never bundles its own. ## Install [Section titled “Install”](#install) ```bash npm install @uptimizr/babylon-lite @babylonjs/lite ``` ## Usage [Section titled “Usage”](#usage) ```ts import { createEngine, createSceneContext, createArcRotateCamera, attachControl, } from "@babylonjs/lite"; import { trackScene } from "@uptimizr/babylon-lite"; const engine = await createEngine(canvas); const scene = createSceneContext(engine); const camera = createArcRotateCamera(-Math.PI / 2, Math.PI / 2.5, 16, { x: 0, y: 1, z: 0 }); attachControl(camera, canvas, scene); // The app owns the canvas and render loop, so pass scene + camera + canvas explicitly. const client = trackScene(scene, camera, canvas, { projectId: "your-project", endpoint: "https://collect.example.com", }); // later, on teardown await client.stop("manual"); ``` `trackScene` returns the `@uptimizr/sdk-core` `UptimizrClient`. Read `client.sessionId`, emit custom events with `client.track(...)`, or stop the session early. ## Coordinate frame & graphics [Section titled “Coordinate frame & graphics”](#coordinate-frame--graphics) Babylon Lite is **left-handed, y-up, unit-scale 1** — the same as Uptimizr’s canonical wire frame — so the coordinate normalizers are identities (still applied for provenance). It is **WebGPU-only**, so `graphics.api` is always `webgpu` and the shading language is `wgsl`. ## Capture [Section titled “Capture”](#capture) The Lite connector captures camera pose (`camera_sample`), pointer move/click and buttons, mesh picks via GPU picking (`mesh_interaction`), and frame perf. It has no named-bone API, so scene-actor capture is Tier 1 (node/root transforms) only. For finer control, register `liteCollector` with an sdk-core `UptimizrClient` via `client.use(...)` — see [sdk-core (advanced)](/docs/connectors/sdk-core/). # Install via CDN / script tag > Use Uptimizr connectors with no build step — load a standalone bundle from a CDN or your collector and call window.Uptimizr. You don’t have to use npm. Every connector also ships a **standalone global (IIFE) bundle** you can drop onto a page with a single ` ``` * **From your own collector / static host** — self-host the file (any static host, object store, or CDN works) and point the ` ``` * three.js ```html ``` * PlayCanvas ```html ``` * A-Frame ```html ``` `Uptimizr.trackScene(...)` returns the same client as the npm path, so you can read `client.sessionId`, emit custom events with `client.track(...)`, change scenes with `client.setScene(...)`, and `client.stop()` — everything in the [configuration reference](/docs/guides/configuration/) works identically. ## Replay from a script tag [Section titled “Replay from a script tag”](#replay-from-a-script-tag) The replay bundle exposes `window.UptimizrReplay` with a one-call `replayInScene` helper — see the [session replay guide](/docs/guides/replay/#option-b--script-tag). # Godot > Instrument a Godot 4 web export with the @uptimizr/godot connector. The Godot (web export) connector. Godot 4 compiles to WebAssembly and renders into a ``, so it is built on the [web-export foundation](/connectors/web-export) and works in two tiers: a **JS-only tier** (no engine code — pointer heatmaps, FPS, JS errors) and a **bridged tier** (a thin copy-in shim adds camera pose, world-space picks, and replay). ## Install [Section titled “Install”](#install) ```bash npm install @uptimizr/godot ``` ## Usage [Section titled “Usage”](#usage) ```ts import { trackGodot } from "@uptimizr/godot"; const { client, bridge } = trackGodot({ projectId: "your-project", endpoint: "https://collect.example.com", canvas: () => document.querySelector("#godot-canvas"), }); // later, on teardown await client.stop("manual"); ``` `trackGodot` creates the client, registers the JS-only tier collector, exposes the engine `bridge` (default `window.__uptimizr_godot__`), and starts the session with Godot’s connector provenance. The JS-only tier captures immediately; wire the engine-side shim to `bridge` to add camera pose, picks, and replay. ## Engine-side bridge [Section titled “Engine-side bridge”](#engine-side-bridge) The bridged tier needs a thin **copy-in shim** — a `JavaScriptBridge` autoload that samples the active `Camera3D` and calls the bridge each frame. It ships with the package as a copy-in asset (not an npm dependency), in both GDScript and C#: 1. Copy [`UptimizrGodot.gd`](https://github.com/RaananW/Uptimizr/blob/main/oss/packages/godot/bridge/UptimizrGodot.gd) (or [`UptimizrGodot.cs`](https://github.com/RaananW/Uptimizr/blob/main/oss/packages/godot/bridge/UptimizrGodot.cs) for .NET projects) into your Godot 4 project. 2. Register it as a singleton: **Project → Project Settings → Globals → Autoload**, add the script with node name `UptimizrGodot`, and enable it. On the next Web export the autoload finds `window.__uptimizr_godot__` (exposed by `trackGodot`), asserts the bridge protocol version, and starts pushing camera pose, FPS, and left-click raycast picks automatically. Off the Web export it guards on `OS.has_feature("web")` and is a no-op, so it is safe to leave enabled in every build. For world-space object engagement and replay completeness, mark nodes with `add_to_group("uptimizr_tracked")` and call `UptimizrGodot.push_scene_proxy()` once after your scene is built. The full contract, options, and coordinate notes live in the package’s [`bridge/README.md`](https://github.com/RaananW/Uptimizr/blob/main/oss/packages/godot/bridge/README.md). ## Verification status [Section titled “Verification status”](#verification-status) | Tier | Status | Verified by | | ----------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **JS-only** | **Stable** | Unit tests + the web-export Playwright round trip. | | **Bridged** | **Verified** | An **automated headless Godot 4.7.2 Web export** driven by Playwright in CI: the real WASM build boots with the shipped `UptimizrGodot.gd` autoload, and the test asserts `camera_sample` (Z negated), `mesh_interaction` (a raycast pick naming the node), `frame_perf`, and the scene proxy reach the collector. | ### Reference integration [Section titled “Reference integration”](#reference-integration) [`examples/godot-web-export`](https://github.com/RaananW/Uptimizr/tree/main/examples/godot-web-export) is the reference project the CI proof exports: a minimal Godot 4 scene with the autoload registered, a `Camera3D`, and named pickable `StaticBody3D` props (`Crate`, `Orb`) that opt into the scene proxy from `main.gd`. Its copy of the shim is checked byte-for-byte against the package source, so the test always exercises the asset you copy in. Reproduce it locally from the repo root: ```bash pnpm godot:fetch # pinned headless editor + web_nothreads_release template (~85 MB) pnpm godot:export # headless --export-release Web → examples/godot-web-export/dist pnpm test:e2e:godot # boots the export in the playground and asserts the round trip ``` The sample uses the **nothreads** Web template (`variant/thread_support=false`), so the export runs without SharedArrayBuffer and the host page needs no COOP/COEP headers — the simplest deployment for self-hosters. ## Coordinate frame [Section titled “Coordinate frame”](#coordinate-frame) Godot’s native world frame is **right-handed, y-up, meters**, so the connector negates Z to reach the canonical wire frame (left-handed, y-up). The engine-side shim does no coordinate math. The session records Godot’s native frame in `connector.coordinateSystem`. ## Capture [Section titled “Capture”](#capture) JS-only tier: pointer move/click → screen heatmaps, FPS / long frames → performance, JS errors. Bridged tier: camera pose → view-direction heatmap, world-space picks → object engagement, scene proxy, and replay. ## Privacy [Section titled “Privacy”](#privacy) No client-side persistent IDs and no PII by default (ADR 0003). `client.stop()` tears down every listener, timer, and animation-frame callback. # Connectors overview > How Uptimizr connectors work, which engines are supported, and what they all capture. A **connector** is the engine-specific adapter that observes your 3D scene and emits events. Every connector registers as a collector on `@uptimizr/sdk-core`, captures the same channels, and emits the same versioned event schema — so heatmaps, rankings, and replay behave identically no matter which renderer you use. ## Supported engines [Section titled “Supported engines”](#supported-engines) | Engine | Package | Status | Entry point | | ----------------- | ------------------------ | ------ | ---------------------------------------- | | Babylon.js | `@uptimizr/babylon` | Stable | `trackScene(scene, …)` | | Babylon Lite | `@uptimizr/babylon-lite` | Stable | `trackScene(scene, camera, canvas, …)` | | three.js | `@uptimizr/three` | Stable | `trackScene(scene, camera, renderer, …)` | | PlayCanvas | `@uptimizr/playcanvas` | Stable | `trackScene(app, camera, …)` | | react-three-fiber | `@uptimizr/r3f` | Stable | `` / `useUptimizr()` | | A-Frame | `@uptimizr/aframe` | Stable | `uptimizr` HTML component | | Unity (WebGL) | `@uptimizr/unity` | Beta | `trackUnity(…)` | | Godot (web) | `@uptimizr/godot` | Beta | `trackGodot(…)` | | Unreal (web) | `@uptimizr/unreal` | Alpha | `trackUnreal(…)` | > **Web-export engines** (Unity, Godot, Unreal) compile to WebAssembly and render into a ``, so there is no live JS scene to read. They share the [`@uptimizr/web-export`](/connectors/web-export) foundation and capture in **two tiers**: a **JS-only tier** (pointer heatmaps, FPS, JS errors — no engine code) and a **bridged tier** (camera pose, world-space picks, replay — via a thin copy-in engine-side shim). For these, the engine is **not** an npm peer dependency. ## What every connector captures [Section titled “What every connector captures”](#what-every-connector-captures) * **Camera pose** (position + forward direction) → view-direction heatmap * **Pointer move / click** (normalized screen + optional raycast hit) → screen & world heatmaps * **Mesh picks** → object-engagement analytics * **FPS / frame perf** → performance * **Mesh visibility**, **hover dwell**, **resource sample** (opt-in) → attention & footprint ## Asset-load capture (`asset_load`) [Section titled “Asset-load capture (asset_load)”](#asset-load-capture-asset_load) The `asset_load` event reports per-asset load timing (`name`, `loadMs`, optional `bytes`/`ttffMs`/ `ttiMs`). Whether a connector captures it **automatically** depends on the engine exposing a global load lifecycle to hook. Engines without an always-on asset registry can still report `asset_load` **from your app** by emitting it on the `UptimizrClient` directly. | Engine | `asset_load` capture | How | | ----------------- | -------------------- | ------------------------------------------------------------------------------------ | | PlayCanvas | ✅ Automatic | Hooks the `app.assets` registry lifecycle (`load:start` → `load` / `error`). | | Babylon.js | ⚙️ App-reported | `SceneLoader` / `AssetsManager` are per-call — no always-on global registry to hook. | | Babylon Lite | ⚙️ App-reported | Same as Babylon.js. | | three.js | ⚙️ App-reported | `LoadingManager` is per-loader and optional — no guaranteed global hook. | | react-three-fiber | ⚙️ App-reported | Wraps three.js; same as three.js. | | A-Frame | ⚙️ App-reported | Wraps three.js; same as three.js. | Privacy (ADR 0003): the PlayCanvas connector records only the asset’s app-defined **name**, never the file URL. Disable it with `capture: { assetLoad: false }`. ## Shared principles [Section titled “Shared principles”](#shared-principles) * **The engine is a peer dependency.** Connectors read your existing engine instance and never bundle or mutate the scene. * **Clean teardown.** Calling `dispose()` / `stop()` removes every DOM listener, timer, and animation-frame callback. No cookies, no persistent IDs. * **Canonical coordinate frame.** World-space data is normalized to Uptimizr’s canonical wire frame (left-handed, y-up) at the emission boundary, while `connector.coordinateSystem` records the engine’s native frame for provenance. * **Same options everywhere.** Capture fidelity (`sampling`), channel toggles (`capture`), scene actors, and opt-in channels work the same across connectors. Engine differences are only in how you hand the scene/camera/renderer to `trackScene`. ## Advanced setup [Section titled “Advanced setup”](#advanced-setup) Every connector’s one-call `trackScene` returns an `@uptimizr/sdk-core` `UptimizrClient`. For a custom transport, a `beforeSend` hook, or registering multiple collectors on one session, compose the pieces yourself — see [sdk-core (advanced)](/docs/connectors/sdk-core/). # PlayCanvas > Instrument a PlayCanvas app with the @uptimizr/playcanvas connector. The PlayCanvas connector. `playcanvas` is a peer dependency — the connector reads your existing PlayCanvas instance and never bundles or mutates it. ## Install [Section titled “Install”](#install) ```bash npm install @uptimizr/playcanvas playcanvas ``` ## Usage [Section titled “Usage”](#usage) Because PlayCanvas supports multiple camera entities with no single “active” camera, and the connector reads FPS and the canvas from `app.graphicsDevice`, the camera `Entity` is an explicit argument: ```ts import { trackScene } from "@uptimizr/playcanvas"; const client = trackScene(app, cameraEntity, { projectId: "your-project", endpoint: "https://collect.example.com", }); // later, on teardown await client.stop("manual"); ``` `trackScene` returns the `@uptimizr/sdk-core` `UptimizrClient`. Read `client.sessionId`, emit custom events, or stop the session early. ## Coordinate frame [Section titled “Coordinate frame”](#coordinate-frame) World-space data is normalized from PlayCanvas’ native **right-handed, y-up** frame to the canonical wire frame (**left-handed, y-up**) at the emission boundary; the session is attributed to the `playcanvas` connector. ## Capture [Section titled “Capture”](#capture) Captures camera pose, pointer move/click (with optional raycast hit), mesh picks, FPS, and **asset load timing** (`asset_load`, on by default — see below). Opt-in: mesh visibility and hover dwell. It tears down all DOM listeners, timers, `frameend` handlers, and asset-registry listeners on stop. ### Asset load timing (`asset_load`) [Section titled “Asset load timing (asset_load)”](#asset-load-timing-asset_load) The connector hooks the PlayCanvas [`app.assets`](https://developer.playcanvas.com/api/pc.AssetRegistry.html) registry load lifecycle (`load:start` → `load` / `error`) and emits one `asset_load` per observed load, carrying the asset’s **name**, load **duration** (`loadMs`), and **byte size** (`bytes`, when the engine knows it). Privacy (ADR 0003): only the asset’s app-defined `name` is recorded — never the file URL — and loads the connector didn’t observe from the start (already-cached assets) are skipped rather than reported with a fabricated duration. Disable with `capture: { assetLoad: false }`. ## First-person scenes (pointer lock) [Section titled “First-person scenes (pointer lock)”](#first-person-scenes-pointer-lock) When the canvas holds the browser [Pointer Lock](https://developer.mozilla.org/docs/Web/API/Pointer_Lock_API) (first-person/FPS navigation), the OS cursor is hidden and the aim point is the fixed crosshair at the viewport centre. The connector detects pointer lock and reports pointer/click events from screen centre (`screen = [0.5, 0.5]`), raycasting from the centre — so the 2D pointer heatmap clusters at the centre for locked scenes. Read the cursor-independent gaze / floor-plan heatmaps instead; cursor (orbit/viewer) scenes are unaffected. See [Concepts → pointer lock](/docs/concepts/) (ADR 0034). ## Advanced [Section titled “Advanced”](#advanced) ```ts import { UptimizrClient } from "@uptimizr/sdk-core"; import { playcanvasCollector, readDeviceCaps } from "@uptimizr/playcanvas"; const client = new UptimizrClient({ projectId: "your-project", endpoint: "https://collect.example.com", }); client.use(playcanvasCollector({ app, camera: cameraEntity })); ``` See [sdk-core (advanced)](/docs/connectors/sdk-core/) for the full client API. # Playgrounds & online editors > Add Uptimizr to the Babylon Playground, CodePen/JSFiddle, Glitch, StackBlitz, and the PlayCanvas Editor — no build step required. You don’t need a bundler or a local project to try Uptimizr. Every connector ships a [standalone global bundle](/docs/connectors/cdn/) that exposes `window.Uptimizr`, so you can instrument a scene that lives in an online editor with a few lines. There are two kinds of surface, and they integrate differently: * **Code playgrounds** — you write the scene in JS (Babylon Playground, CodePen, JSFiddle, Glitch, StackBlitz). Load the bundle, then call `trackScene(...)` once the engine scene exists. * **Visual editors** — you build the scene in a UI and attach scripts (PlayCanvas Editor). Bring in the connector (as an external global bundle or an ESM import), then call `trackScene(...)` from a script component. You still need a collector + project `trackScene` needs a `projectId` and an `endpoint` (your collector’s URL). Spin one up with the [run-the-collector guide](/docs/deploy/collector/) — locally it serves at `http://localhost:4318`. Every option works exactly as in the [configuration reference](/docs/guides/configuration/). ## Babylon Playground [Section titled “Babylon Playground”](#babylon-playground) The [Babylon Playground](https://playground.babylonjs.com) supports ES module imports, so you can import the connector directly and call `trackScene` once your `scene` exists — no DOM injection and no global needed. Add this inside `createScene`, just before `return scene;` (swap in your `projectId` and `endpoint`): * ESM import (recommended) Import the connector from a CDN ESM URL and call it after the scene is built. jsDelivr’s `/+esm` endpoint serves the published npm package as a ready ES module (deps resolved) — no bundler and nothing to host: ```ts import { trackScene } from "https://cdn.jsdelivr.net/npm/@uptimizr/babylon/+esm"; // ...inside createScene, after the scene is built: trackScene(scene, { projectId: "your-project", endpoint: "https://collect.example.com", meta: { sceneId: "playground" }, }); ``` In a real project (npm + bundler) you’d use the bare specifier `import { trackScene } from "@uptimizr/babylon"` — node resolution maps it. The Playground doesn’t resolve arbitrary bare names, so use the full URL there (or [esm.sh](https://esm.sh): `https://esm.sh/@uptimizr/babylon`). * Script tag (older Playground) On a Playground build that predates ESM-import support, inject the [global bundle](/docs/connectors/cdn/) through the DOM — the Playground strips static ` ``` * three.js ```html ``` * PlayCanvas ```html ``` * A-Frame ```html ``` Note react-three-fiber is React-only and has no global bundle. In a React sandbox (StackBlitz, CodeSandbox) use the [npm package](/docs/connectors/r3f/) instead. ## PlayCanvas Editor [Section titled “PlayCanvas Editor”](#playcanvas-editor) The PlayCanvas Editor attaches scripts to entities and runs them in the published app. There are two ways to bring in the connector — pick the one that matches how your project is scripted. * External script (global bundle) Load the Uptimizr [global bundle](/docs/connectors/cdn/) as an **external script** (it runs before your scripts and exposes `window.Uptimizr`), then call `trackScene` from a classic script component. Simplest path, works with PlayCanvas’ classic `pc.createScript` scripts. 1. **Project Settings → External Scripts** — add the bundle URL so it loads before your code: ```text https://cdn.jsdelivr.net/npm/@uptimizr/playcanvas/dist/uptimizr-playcanvas.global.js ``` 2. Create a script (for example `analytics.js`), attach it to an entity, and reference your camera entity via a script attribute: ```js var Analytics = pc.createScript("analytics"); // Drag your camera entity onto this attribute in the Editor. Analytics.attributes.add("camera", { type: "entity" }); Analytics.prototype.initialize = function () { this.tracker = Uptimizr.trackScene(this.app, this.camera, { projectId: "your-project", endpoint: "https://collect.example.com", }); this.on("destroy", () => this.tracker.stop("manual")); }; ``` 3. Launch the scene and interact with it; events flow to your collector. Read `this.tracker.sessionId` or emit custom events with `this.tracker.track(...)` — the return value is the same [`UptimizrClient`](/docs/connectors/sdk-core/) as the npm path. * ESM script asset If your project uses PlayCanvas’ **ESM scripts** (script assets written as ES modules), skip the global bundle and `import` the connector directly — the package’s ESM entry resolves the same way as any other module in your project (via the Editor’s module resolution / import map): ```js import { Script } from "playcanvas"; import { trackScene } from "@uptimizr/playcanvas"; export class Analytics extends Script { initialize() { // `camera` is an entity attribute set in the Editor. this.tracker = trackScene(this.app, this.camera, { projectId: "your-project", endpoint: "https://collect.example.com", }); this.on("destroy", () => this.tracker.stop("manual")); } } ``` Use this when you already author ESM scripts; otherwise the external-script tab is the lighter setup. Both return the same [`UptimizrClient`](/docs/connectors/sdk-core/). ## Babylon Editor & other engine editors [Section titled “Babylon Editor & other engine editors”](#babylon-editor--other-engine-editors) The [Babylon Editor](https://github.com/BabylonJS/Editor), the [three.js editor](https://threejs.org/editor/), Unity/Unreal WebGL exports, and similar tools all produce a **standard web project or page** rather than an embedded runtime you script inline. There’s no editor-specific Uptimizr plugin — once you have the exported project: * If it has a build step, follow the connector’s [npm install](/docs/connectors/overview/). * If it’s a static page, drop in the [CDN `