This is the abridged 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. | ## 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. # 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. # 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/). # Run the collector > Self-host the Uptimizr collector from npm — the OSS default needs no Docker, just a single DuckDB file. The collector is a single Fastify process that ingests events and serves the query API. You run it **straight from npm** — there is nothing to clone. The **OSS default store is DuckDB**: one `.duckdb` file holds both events and metadata, so the collector self-hosts in **one process with no external database service**. ## OSS quickstart (no Docker, from npm) [Section titled “OSS quickstart (no Docker, from npm)”](#oss-quickstart-no-docker-from-npm) * Scaffold (recommended) The scaffolder generates a ready-to-run, Docker-free self-host folder — a strong secret, a local config, a tiny runner, and a client snippet for your engine: ```bash npm create uptimizr@latest ``` Follow the prompts, then start it: ```bash cd npm start # runs `uptimizr serve` under the hood ``` The prompts also offer two optional extras so you can go from a bare collector to the **full suite** and test everything in one go: * **Dashboard** — adds `@uptimizr/dashboard` and an `npm run dashboard` script (analytics UI on `http://localhost:3000`). * **Demo** — writes a self-contained Babylon scene + tiny static server and an `npm run demo` script (`http://localhost:5173`) that generates events end-to-end. The prompts also ask which **store** to use. `duckdb` (the default) needs nothing else; `postgres`, `clickhouse` and `mssql` point the scaffold at a database you already run — `.env` gets the matching `COLLECTOR_STORE` and connection variables (`POSTGRES_URL`, `CLICKHOUSE_*`, `MSSQL_URL`) with local-server placeholders to edit, and `package.json` gains the store package. `npm run setup` then creates the schema and mints the first project in that database. Skip the prompts with flags — `--full` (collector + dashboard + demo), `--dashboard`, `--demo`, `--minimal` (collector only), or `--store `: ```bash npm create uptimizr@latest my-analytics -- --full npm create uptimizr@latest my-analytics -- --store postgres ``` With the full suite, run the three processes together and watch events flow from the demo through the collector into the dashboard: ```bash npm start # collector — ingestion + query API npm run demo # demo scene — paste the projectId, then interact npm run dashboard # dashboard — point it at the collector with your API key ``` * CLI (manual) Use the `uptimizr` CLI shipped with `@uptimizr/collector-server` directly: 1. **Initialise.** Generates a `VISITOR_HASH_SECRET`, creates and migrates the store (DuckDB unless `COLLECTOR_STORE` selects another — see the store sections below), mints a first project + API key, and writes a local `.env`. The project id, endpoint, and key are printed **once** — copy the key into your app config. ```bash npx -p @uptimizr/collector-server uptimizr init ``` 2. **Serve.** Runs the ingestion + query API (migrations run automatically). Defaults to `COLLECTOR_STORE=duckdb` on port `4318`. ```bash npx -p @uptimizr/collector-server uptimizr serve ``` 3. **Send events.** Point your connector’s `endpoint` at the collector — e.g. `http://localhost:4318` — then query them back via the [HTTP API](/docs/api/overview/). ```ts trackScene(scene, { projectId: "", endpoint: "http://localhost:4318", }); ``` Need more projects/keys later? `npx -p @uptimizr/collector-server uptimizr new-project ""`, or `uptimizr new-key ` to add a key to an existing project — see [API keys and capabilities](#api-keys-and-capabilities) below. To name places inside a scene (so spatial queries can be drilled with `?region=`), use `uptimizr regions set --file regions.json --project ""` — see [Scene regions](/docs/api/query/#scene-regions). ## Inspect & back up [Section titled “Inspect & back up”](#inspect--back-up) * Inspect the store directly with the DuckDB CLI (`DUCKDB_PATH` defaults to `./data/uptimizr.duckdb`): ```bash duckdb ./data/uptimizr.duckdb "SELECT event_type, count(*) FROM events GROUP BY 1" ``` * **Back up** = copy the `.duckdb` file. **Reset** = delete it and re-run `uptimizr init`. Single-writer constraint DuckDB is an embedded, single-writer store: only **one process** may open the file read-write at a time. Run a single collector instance against a given file, and stop it before inspecting with another writer. Concurrent readers are fine in read-only mode. For multi-writer / horizontal scale, use the [`postgres` store](#relational-path-postgres), the [`mssql` store](#relational-path-sql-server) or the [`clickhouse` store](#scale-path-clickhouse). ## Serve the dashboard too [Section titled “Serve the dashboard too”](#serve-the-dashboard-too) The collector can optionally serve the dashboard static assets at `/`, so a single process exposes the ingestion API, query API, UI, and the DuckDB file. See [Serve the dashboard](/docs/deploy/dashboard/) for the all-in-one and standalone options. ## Relational path: Postgres [Section titled “Relational path: Postgres”](#relational-path-postgres) If you already operate **PostgreSQL** and want a familiar, multi-writer backend — several collector instances sharing one store, managed backups, your existing monitoring — switch the store to Postgres without changing any application code or queries: ```bash export COLLECTOR_STORE=postgres export POSTGRES_URL=postgresql://uptimizr:uptimizr@localhost:5432/uptimizr npx -p @uptimizr/collector-server uptimizr init # schema + first project + key, in Postgres npx -p @uptimizr/collector-server uptimizr serve ``` Or scaffold it: `npm create uptimizr@latest my-analytics -- --store postgres` writes this `.env` for you. `init`, `new-project` and `migrate` all honour `COLLECTOR_STORE`, so the project you mint is the one the collector resolves. This is a **single-tenant Postgres store**: events **and** metadata (projects, API keys, scene representations) live in one database. The database itself must exist (your provider or `docker compose` creates it); the collector creates the schema, tables, indexes and views on first boot, and several instances may boot concurrently (migrations are serialized behind an advisory lock). `DATABASE_URL` is accepted as a fallback for `POSTGRES_URL`, so platform-injected connection strings work as-is; `?sslmode=require` and the other libpq URI options apply. The full analytics surface returns **identical results** to DuckDB and ClickHouse (verified by the cross-engine parity suite against a live Postgres). Two row-store trade-offs are worth knowing: * **ASOF joins are emulated.** The click↔gaze ray, flow, reachability, navigation and backtrack aggregations pair each event with its nearest-in-time neighbour. Postgres has no `ASOF JOIN`, so these render as an indexed nearest-row lookup per left row (`JOIN LATERAL … ORDER BY ts DESC LIMIT 1`). Results are the same; on very large sessions these reads are slower than the columnar engines’ single merge pass. * **Daily rollups are recomputed at query time.** `perf_daily` / `events_daily` are plain views — fine at single-tenant scale; the incremental rollups remain the ClickHouse scale tier. Choose Postgres when you need: * **Multiple collector instances** behind a load balancer, without running ClickHouse. * A database your team **already runs** (managed Postgres, RDS, Cloud SQL, Supabase, Neon…). * Transactional, easily-inspected storage (`psql`, standard backups and replication). Choose [ClickHouse](#scale-path-clickhouse) instead for high-volume ingestion and large historical ranges. Postgres 14 or newer is supported; the monorepo’s `infra/docker` ships a `postgres:16` service (`pnpm stack:up`). ## Relational path: SQL Server [Section titled “Relational path: SQL Server”](#relational-path-sql-server) If your team is standardized on **Microsoft SQL Server** or **Azure SQL**, the same multi-writer relational path is available without operating a second database engine: ```bash export COLLECTOR_STORE=mssql export MSSQL_URL="Server=localhost,1433;Database=uptimizr;User Id=sa;Password=Uptimizr!Local1;Encrypt=true;TrustServerCertificate=true" npx -p @uptimizr/collector-server uptimizr init # database + schema + first project + key npx -p @uptimizr/collector-server uptimizr serve ``` Or scaffold it: `npm create uptimizr@latest my-analytics -- --store mssql`. `MSSQL_URL` is an ADO.NET-style connection string (Azure SQL’s works as-is); alternatively set the discrete `MSSQL_SERVER` / `MSSQL_PORT` / `MSSQL_DATABASE` / `MSSQL_USER` / `MSSQL_PASSWORD` fields (with `MSSQL_ENCRYPT`, on by default, and `MSSQL_TRUST_SERVER_CERTIFICATE` for a self-signed local container). This is a **single-tenant SQL Server store**: events **and** metadata live in one database. The collector creates the database on first boot when the login may (`CREATE ANY DATABASE`; otherwise create it up front), then creates the tables, indexes, views and one helper function; several instances may boot concurrently (migrations are serialized behind an application lock). SQL Server 2022 (16.x) or Azure SQL is required. The full analytics surface returns **identical results** to DuckDB (verified by the cross-engine parity suite against a live SQL Server). It is the heaviest relational port, and three T-SQL trade-offs are worth knowing: * **No array type.** Vector columns (camera position/direction, hit points, rays, screen coordinates) are stored as JSON arrays and read through `JSON_VALUE`. Correct, but the spatial heatmaps parse JSON per row — expect them to be slower than on DuckDB, ClickHouse or Postgres on large ranges. * **ASOF joins are emulated** with `CROSS APPLY` / `OUTER APPLY (SELECT TOP 1 … ORDER BY ts DESC)` — an indexed nearest-row lookup per left row, like the Postgres `LATERAL` form. * **Percentiles are computed by a helper function.** T-SQL has no aggregate `PERCENTILE_CONT`, so `p50`/`p95` reads pack each group’s values and interpolate them in `dbo.uptimizr_quantile` (exact, but O(n log n) per group). Daily rollups are recomputed at query time. Choose SQL Server when it is the database your team already operates (on-premises SQL Server, Azure SQL Database / Managed Instance) and you want multiple collector instances behind a load balancer. Choose [Postgres](#relational-path-postgres) or [ClickHouse](#scale-path-clickhouse) otherwise; the monorepo’s `infra/docker` ships a `mcr.microsoft.com/mssql/server:2022-latest` service (`pnpm stack:up`). ## Scale path: ClickHouse [Section titled “Scale path: ClickHouse”](#scale-path-clickhouse) When you outgrow DuckDB’s single-writer model — concurrent collector instances, high-volume ingestion, or large historical ranges — switch the store to **ClickHouse** without changing any application code or queries: ```bash export COLLECTOR_STORE=clickhouse export CLICKHOUSE_URL=http://localhost:8123 export CLICKHOUSE_DATABASE=uptimizr export CLICKHOUSE_USER=default export CLICKHOUSE_PASSWORD= npx -p @uptimizr/collector-server uptimizr init # database + tables + first project + key npx -p @uptimizr/collector-server uptimizr serve ``` Or scaffold it: `npm create uptimizr@latest my-analytics -- --store clickhouse`. This is a **single-tenant ClickHouse store**: events **and** metadata (projects, API keys, scene representations) live in one ClickHouse database, so there is no separate metadata service to run. The collector creates the database and tables on first boot. Every aggregation is authored once against the dialect-agnostic query layer, so the full analytics surface — heatmaps, sessions, performance percentiles, mesh dwell, daily rollups — returns **identical results** to DuckDB (verified by a cross-engine parity suite). Choose ClickHouse when you need: * **Concurrent writers / horizontal scale** — multiple collector instances against one store. * **High-volume ingestion** and large time-range queries. * A shared analytics database your team already operates. Keep **DuckDB** (the default) for single-instance self-hosting with no service to run. Spin up a local ClickHouse with the monorepo’s `infra/docker` (`pnpm stack:up`); see [Contributing](/docs/contributing/). ### Hosted / managed ClickHouse [Section titled “Hosted / managed ClickHouse”](#hosted--managed-clickhouse) A managed ClickHouse (ClickHouse Cloud, Aiven, or your own TLS-terminated server) works the same way — point `CLICKHOUSE_URL` at the **HTTPS** endpoint and pass the credentials. The client infers TLS from the `https://` scheme, so no extra configuration is needed for publicly-trusted certificates: ```bash export COLLECTOR_STORE=clickhouse export CLICKHOUSE_URL=https://your-instance.clickhouse.cloud:8443 export CLICKHOUSE_USER=default export CLICKHOUSE_PASSWORD="$YOUR_PASSWORD" export CLICKHOUSE_DATABASE=uptimizr ``` The collector creates the `CLICKHOUSE_DATABASE` on first boot, so the connecting user needs the `CREATE DATABASE` privilege (the default ClickHouse Cloud user has it) — or pre-create the database and grant the user access to it. Custom CA bundles and mutual-TLS client certificates are not currently exposed through env vars. ## Configuration [Section titled “Configuration”](#configuration) Key environment variables (the CLI writes a starter `.env`; see the repo’s `.env.example` for the full list): | Variable | Purpose | | -------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | `COLLECTOR_STORE` | `duckdb` (default), `postgres`, `mssql`, `clickhouse` (scale tier), or `memory` (dev/E2E). | | `DUCKDB_PATH` | Path to the DuckDB file (default `./data/uptimizr.duckdb`). | | `POSTGRES_URL` (or `DATABASE_URL`) | Postgres connection string when `COLLECTOR_STORE=postgres` (`postgresql://user:pw@host/db`). | | `POSTGRES_SCHEMA` | Schema for the store’s tables (default `public`; created on first boot). | | `POSTGRES_POOL_MAX` | Max pooled connections per collector process (default `10`). | | `MSSQL_URL` | SQL Server ADO.NET connection string when `COLLECTOR_STORE=mssql` (wins over the fields below). | | `MSSQL_SERVER` / `MSSQL_PORT` | SQL Server host / port (default `localhost` / `1433`). | | `MSSQL_DATABASE` | Database name (default `uptimizr`; created on first boot when the login may). | | `MSSQL_USER` / `MSSQL_PASSWORD` | SQL login (default `sa`; `MSSQL_SA_PASSWORD` is accepted as the password fallback). | | `MSSQL_ENCRYPT` / `MSSQL_TRUST_SERVER_CERTIFICATE` | TLS on (default `true`) / trust a self-signed certificate (default `false`). | | `MSSQL_POOL_MAX` | Max pooled connections per collector process (default `10`). | | `CLICKHOUSE_URL` | ClickHouse HTTP endpoint when `COLLECTOR_STORE=clickhouse` (default `http://localhost:8123`). | | `CLICKHOUSE_DATABASE` | ClickHouse database name (default `uptimizr`; created on first boot). | | `CLICKHOUSE_USER` / `CLICKHOUSE_PASSWORD` | ClickHouse credentials (default `default` / empty). | | `COLLECTOR_PORT` | Port the collector listens on (default `4318`). | | `VISITOR_HASH_SECRET` | Secret for the daily-rotating, server-side visitor hash. | | `COLLECTOR_CORS_ORIGINS` | Allowed browser origins for ingestion/query (comma-separated). | | `COLLECTOR_TRUST_PROXY` | Trust `X-Forwarded-*` behind a reverse proxy: `true`, or a trusted IP/CIDR list. | | `ENABLE_RAW_SESSION_RETENTION` | Opt-in raw per-session event retention, required for replay. | | `COLLECTOR_RATE_LIMIT_MAX` / `_WINDOW_MS` | Default request budget per client (`600` per `60000` ms). A key’s own budget overrides it. | | `AUDIT_RETENTION_DAYS` | How long agent-audit rows are kept (default `30`; `0` keeps them forever). | | `AUDIT_DASHBOARD_REQUESTS` | Also audit the dashboard’s own requests (default off — see below). | | `COLLECTOR_MCP_HTTP` | Serve MCP over Streamable HTTP at `/mcp` (default **off** — see below). | | `COLLECTOR_MCP_MAX_SESSIONS` | Concurrent MCP sessions (default `50`); one too many is refused with `503`. | | `COLLECTOR_MCP_SESSION_TTL_MS` | Idle timeout before an MCP session is closed (default `1800000`, i.e. 30 minutes). | | `COLLECTOR_SUBSCRIPTIONS` | Run the conditional-subscription scheduler (default **on**; `0` keeps the API, runs no timers). | | `COLLECTOR_SUBSCRIPTIONS_MAX_CONCURRENT` | Subscription evaluations allowed at once (default `4`). | | `COLLECTOR_WEBHOOK_ALLOWED_HOSTS` | Hosts a subscription webhook may POST to. **Empty by default — no webhook egress at all.** | See [Privacy & configuration](/docs/deploy/privacy/) for the privacy-relevant settings. ## API keys and capabilities [Section titled “API keys and capabilities”](#api-keys-and-capabilities) An API key carries a **set of capabilities**, so an agent key can be scoped far more narrowly than “can read everything”: | Capability | Grants | | ----------- | --------------------------------------------------------------------------------------------------------------------------------- | | `query` | The aggregate analytics API, the scene registry, the live token exchange and the audit trail. | | `query:raw` | Raw **per-session** streams: the replay timeline (`/api/v1/sessions/:id/events`) and the live per-session follow. | | `annotate` | The project **metadata** write path (annotations, glossary, saved analyses, panel specs). 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. | **The first key is the operator’s own.** `uptimizr init` and `uptimizr new-project` mint a single key holding `query`, `query:raw` and `annotate`, labelled `owner` — everything the operator’s own surfaces need: the dashboard, session replay, the live per-session follow and scene regions. `query:raw` grants nothing on its own (the raw routes also need `ENABLE_RAW_SESSION_RETENTION`), so having it up front turns nothing on. It only means that switching retention on later does not make replay answer `403` until a second key is minted and swapped into the dashboard. **Every other key is narrow by default.** `uptimizr new-key` still issues `query` (read-only) unless you say otherwise — that is the key an agent or MCP client should hold: ```bash # Read-only (the default) — the right key for an agent or MCP client npx -p @uptimizr/collector-server uptimizr new-key --label "weekly-report" # An agent that may also write metadata, with its own request budget npx -p @uptimizr/collector-server uptimizr new-key \ --capabilities query,annotate \ --label "weekly-report-agent" \ --rate-limit-max 120 --rate-limit-window-ms 60000 # A key that may read raw session streams (replay, live-follow) npx -p @uptimizr/collector-server uptimizr new-key \ --capabilities query,query:raw --label "replay" ``` Breaking change: raw session access needs `query:raw` Reading a raw per-session stream now requires **both** `ENABLE_RAW_SESSION_RETENTION` on the collector **and** the `query:raw` capability on the key. Previously retention alone was enough and any `query` key could read it. Existing keys keep working for every aggregate endpoint and need no migration. A key that drives **session replay** or the **live per-session follow** must be re-minted with `query:raw` (or a second key issued alongside it) — otherwise those two endpoints answer `403`. Keys minted by `uptimizr init` / `uptimizr new-project` already carry `query:raw`, so a fresh self-host needs no second key: switch `ENABLE_RAW_SESSION_RETENTION` on and replay works. Only keys issued before this release, or narrowed deliberately with `new-key`, need re-minting. **Per-key rate limits.** `--rate-limit-max` / `--rate-limit-window-ms` give a key its own budget, bucketed on the key id instead of the client IP. Keys without one fall back to `COLLECTOR_RATE_LIMIT_*`. Ingestion is untouched: it is keyless and keeps its own `COLLECTOR_INGEST_RATE_LIMIT_*` budget. **`GET /api/v1/whoami`** returns the calling key’s `projectId`, `keyId`, `capabilities`, `label` and effective `rateLimit` — how an agent or MCP client discovers what it is allowed to do. The key itself is never echoed back. ## Subscriptions & webhooks [Section titled “Subscriptions & webhooks”](#subscriptions--webhooks) A **conditional subscription** is a standing question — “tell me when the lobby’s median FPS drops below 40” — that the collector evaluates in-process and delivers over SSE, a signed webhook, or both. It is how an agent stops polling (ADR 0051 §6). fps-drop.json ```json { "name": "FPS drop in lobby", "metric": "perf_summary", "filters": { "scene": "lobby" }, "evaluate": { "every": "5m", "window": "1h" }, "predicate": { "kind": "threshold", "column": "p50_fps", "op": "<", "value": 40 }, "cooldown": "1h", "delivery": [ { "kind": "sse" }, { "kind": "webhook", "url": "https://hooks.example/uptimizr", "secret": "…" } ] } ``` ```bash uptimizr subscriptions add --file fps-drop.json --project uptimizr subscriptions list uptimizr subscriptions test # evaluate once, print why; never delivers ``` or over HTTP with an `annotate` key: ```bash curl -X POST "$COLLECTOR/api/v1/subscriptions" \ -H "x-api-key: $UPTIMIZR_API_KEY" -H 'content-type: application/json' \ --data @fps-drop.json ``` ### Predicate kinds [Section titled “Predicate kinds”](#predicate-kinds) | Kind | Fires when | | ----------- | -------------------------------------------------------------------------------- | | `threshold` | the metric’s headline column crosses a level over the window (`column op value`) | | `anomaly` | the `anomalies` primitive reports a bucket inside the window as abnormal | | `movers` | the metric moved by more than `pct` % against the immediately preceding window | | `new_value` | a value of `dimension` appears that the previous window never saw | | `presence` | live concurrent sessions cross a level (read from the live bus, no store query) | `threshold.column` must be the metric’s registry **headline column** (`perf_summary` → `p50_fps`); anything else is a `400` that names the right one. `evaluate.window` is at least `1h`, because the portable per-bucket series the evaluator reads has hour and day grains only — a shorter window would have to be silently widened, and a subscription that measures something other than what it says is worse than one that refuses to be created. The window’s start is snapped down to a bucket boundary and its end is the moment of evaluation, so a `1h` window on an hourly grain covers the previous complete hour plus the current partial one. `evaluate.every` can be as low as `1m`. Every predicate but `presence` is gated by `minSample`: the window must carry at least that many observations before the comparison counts, defaulting to the metric’s own registry `comparable.minSample`. Without it, one straggling session at 3am fires every “FPS below 30” alert ever written. ### Configuration [Section titled “Configuration”](#configuration-1) The scheduler runs by default and costs one store read at boot when a project has no subscriptions. Set `COLLECTOR_SUBSCRIPTIONS=0` to keep the API while running no timers — the right setting when several collector instances share one database and only one should evaluate. `COLLECTOR_SUBSCRIPTIONS_MAX_CONCURRENT` (default `4`) caps how many evaluations run at once; each is one grouped store read, and at most one evaluation per subscription is ever in flight. A project may hold at most **100** subscriptions, and each keeps its **last 100 firings** (`GET /api/v1/subscriptions/:id/events`). ### Webhook egress is opt-in [Section titled “Webhook egress is opt-in”](#webhook-egress-is-opt-in) `COLLECTOR_WEBHOOK_ALLOWED_HOSTS` is **empty by default, and that disables webhook delivery entirely**. Until you name the hosts your collector may POST to, a firing is recorded and fanned out over SSE and nothing leaves the process. This is deliberate, and it is an SSRF boundary rather than a convenience. A subscription is created over HTTP by an `annotate`-capable key, so its URL is request-controlled input to an outbound request: without the allow-list, anyone holding such a key could aim your collector at `http://169.254.169.254/` or at anything else reachable from where it runs. Naming the hosts is your explicit consent to reach them. ```bash COLLECTOR_WEBHOOK_ALLOWED_HOSTS=hooks.slack.com,api.github.com ``` Matching is on the hostname, case-insensitively and independently of port. `*` allows every host and is an explicit opt-out for a collector on a closed network. Only `http` and `https` URLs are accepted at all, and redirects are not followed. ### Signing and verification [Section titled “Signing and verification”](#signing-and-verification) Every delivery is a `POST` carrying the firing and a bounded `format=summary` result of the metric, so the receiver can act without a second call: ```http POST /uptimizr HTTP/1.1 Content-Type: application/json X-Uptimizr-Signature: sha256= X-Uptimizr-Delivery: 0f1c… ``` ```json { "type": "subscription.firing", "firing": { "subscriptionId": "sub_…", "name": "FPS drop in lobby", "metric": "perf_summary", "predicate": "threshold", "at": 1767225600000, "window": { "since": 1767222000000, "until": 1767225600000 }, "value": 21.5, "expected": 40, "sampleSize": 32, "scene": "lobby", "reason": "perf_summary.p50_fps is 21.5 — < 40 over 1h" }, "summary": { "metric": "insight_baseline", "reading": "Metric baseline: median is 21.5. …" } } ``` `X-Uptimizr-Delivery` is unique per delivery attempt — dedupe on it. Verify the signature over the **raw body, before parsing it** (re-serialising changes bytes) and compare in constant time: ```js import { createHmac, timingSafeEqual } from "node:crypto"; export function verify(secret, rawBody, header) { const digest = createHmac("sha256", secret).update(rawBody, "utf8").digest("hex"); const expected = Buffer.from(`sha256=${digest}`); const received = Buffer.from(header ?? ""); return expected.length === received.length && timingSafeEqual(expected, received); } ``` The `secret` is **write-only**: you supply it when you create the subscription, and no read endpoint ever returns it again — every response carries a masked placeholder. Rotate it by replacing the subscription, which makes the change visible in the listing rather than silent. ### Retries and failures [Section titled “Retries and failures”](#retries-and-failures) A delivery is attempted up to **3 times** with exponential backoff (500 ms, then 1 s), and only for failures that can plausibly succeed on a retry: a network error, `408`, `429`, or any `5xx`. A different `4xx` is the receiver saying “not like that”, so it is not repeated. Every attempt carries the same `X-Uptimizr-Delivery` id. Each attempt times out after 10 s. Failures are recorded on the subscription as `failures` (consecutive, reset on success) and `lastError` (bounded and flattened), both visible in `GET /api/v1/subscriptions` and in the dashboard’s **Subscriptions** panel. `lastFiredAt` is stamped whether or not delivery succeeded: the condition fired, and a receiver that is down must not turn the cooldown off and let the subscription re-fire on every tick. To prove a receiver works without waiting for the condition: ```bash curl -X POST "$COLLECTOR/api/v1/subscriptions//test?deliver=true" -H "x-api-key: $KEY" ``` ### The SSE stream [Section titled “The SSE stream”](#the-sse-stream) ```bash TOKEN=$(curl -s -X POST "$COLLECTOR/api/v1/live/token" -H "x-api-key: $KEY" | jq -r .token) curl -N "$COLLECTOR/api/v1/subscriptions/stream?token=$TOKEN" ``` It uses the same short-lived live token as the live endpoints (an `EventSource` cannot send a header, and the raw key must never appear in a URL) and shares their connection budget (`LIVE_MAX_CONNECTIONS`). Add `&id=` to follow one subscription. Each message is an `event: subscription` frame whose data is `{ firing, summary }` — the same body a webhook receives, minus the `type` marker. ## 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, and served by `GET /api/v1/audit?since=&until=&limit=` to any `query` key: ```json { "keyId": "9c41…", "at": "2026-09-16T09:14:02.511Z", "surface": "http", "toolOrPath": "/api/v1/meshes/top", "params": "{\"scene\":\"lobby\",\"limit\":20}", "rowCount": 20, "durationMs": 7, "status": 200 } ``` * Refusals (`401`/`403`) are recorded too — they are exactly what you want to see. * `toolOrPath` is the route **pattern**, so rows group cleanly and never embed a path value. * `params` is bounded (512 bytes) and redacted: credential-shaped keys (`token`, `apiKey`, `secret`, `password`, `authorization`, …) are dropped, nested values summarized, long strings clipped. **A key never appears in a row** — the subject is the key’s id. * “The dashboard’s own session” is a request carrying `x-uptimizr-client: dashboard`, the header `@uptimizr/react`’s client sends by default, so panel refreshes do not drown the agent activity the log exists to surface. The in-browser assistant identifies as `assistant` and **is** recorded. This is a volume filter, not a security boundary — anyone holding the key could send the header, and anyone holding the key can already do everything the key allows. Set `AUDIT_DASHBOARD_REQUESTS=1` to record every authenticated request without exception. * Writes happen after the response is flushed: the audit log can never block or fail a request. * Rows older than `AUDIT_RETENTION_DAYS` (default `30`) are removed by a periodic, idempotent sweep; `0` keeps them indefinitely. ## Hosted MCP (Streamable HTTP) [Section titled “Hosted MCP (Streamable HTTP)”](#hosted-mcp-streamable-http) `COLLECTOR_MCP_HTTP=1` makes the collector serve the **Model Context Protocol** itself, over the Streamable HTTP transport at `/mcp`, so a remote AI client connects with a URL and an API key instead of running `npx @uptimizr/mcp` next to itself ([ADR 0051](https://github.com/RaananW/Uptimizr/blob/main/docs/adr/0051-ai-first-analytics-layer.md) §7). The client-side configuration lives in the [MCP guide](/docs/guides/mcp/#hosted-transport-streamable-http); this is what the deployment has to get right. **It is off by default.** Nothing is served at `/mcp` — the route is not even registered — until you set the variable. Turn it on only when you actually want remote agents reaching this collector. **Auth is per request.** `x-api-key` or `Authorization: Bearer `, resolving to a key with the `query` capability; `401` without one, `403` for a key that may not read, and a session id is refused if presented by a different key than opened it. Give agents their own labelled keys so the [audit log](#agent-audit-log) stays legible — hosted-MCP tool calls are tagged `surface: mcp-http`. **Behind a reverse proxy:** * **Terminate TLS upstream** and set `COLLECTOR_TRUST_PROXY` — the collector speaks plain HTTP, and the key travels in a header, so `/mcp` over plain HTTP across a network is a credential leak. * **Do not buffer the response.** The transport answers with `text/event-stream`; a buffering proxy makes a client hang until the stream closes. The collector already sends `Cache-Control: no-cache, no-transform`, but the proxy has to cooperate: * **nginx** — `proxy_buffering off; proxy_cache off; proxy_http_version 1.1;` on the `/mcp` location (the collector’s own `X-Accel-Buffering: no` on live SSE covers the `/api/v1/live/*` routes). * **Caddy** — `reverse_proxy` streams by default; add `flush_interval -1` if you have changed it. * **Cloud load balancers** — disable response buffering/compression for `/mcp` and raise the idle timeout above your polling interval, or long-lived streams are cut mid-session. * **Pin a session to one instance.** Sessions live in the collector process, so several instances behind a load balancer need sticky sessions (hash on `Mcp-Session-Id`) — or point MCP clients at a single instance. * **Bound it.** `COLLECTOR_MCP_MAX_SESSIONS` caps concurrent sessions the way `LIVE_MAX_CONNECTIONS` caps live SSE; `COLLECTOR_MCP_SESSION_TTL_MS` reclaims the slot of a client that vanished without sending `DELETE /mcp`. Request bodies stay bounded by `COLLECTOR_BODY_LIMIT`, and every tool call is charged to the caller’s rate-limit budget. * **Browser-based clients** additionally need their origin in `COLLECTOR_CORS_ORIGINS`; the collector then exposes `Mcp-Session-Id` and allows the MCP request headers and `DELETE`. ## Scheduled agent reports [Section titled “Scheduled agent reports”](#scheduled-agent-reports) `uptimizr agent report` runs a read-only analytics agent **once** and writes a Markdown report — a weekly scene-health digest without anyone opening a chat. It is an ordinary CLI process that talks to your collector over its HTTP query API with an ordinary project key, so: * **The collector runs no LLM loop of its own.** Nothing about this changes the server. * **Scheduling is yours** — cron, a systemd timer, a GitHub Action. The command runs once and exits with a meaningful code. * **Your provider configuration lives in the environment and is never persisted.** The key is read once into the provider adapter; it is never logged, echoed or written to a report. * **It only ever reads.** The tools it calls are `GET`s against the aggregate query API, so a `query`-only key is all it needs — and all it should be given. ```bash # The narrow key the report should hold npx -p @uptimizr/collector-server uptimizr new-key \ --capabilities query --label "weekly-report" export UPTIMIZR_COLLECTOR_URL=https://collect.example.com export UPTIMIZR_API_KEY=utk_… # the query-only key above export UPTIMIZR_AGENT_API_KEY=sk-ant-… # your own provider key npx -p @uptimizr/collector-server uptimizr agent report \ --skill weekly_scene_health --scene lobby --window 7d --out report.md ``` ### Skills [Section titled “Skills”](#skills) A **skill** is the investigation to run. `--list-skills` prints the ones this release ships, what each produces and which tools its method reads. Skills are not written into the CLI: each one is an Agent Skills file, `skills//SKILL.md`, packaged inside `@uptimizr/agent-core` and `@uptimizr/mcp`. The same files back the MCP server’s prompt templates and the in-browser assistant’s starter prompts, so a scheduled report and a chat session run the same method. Read one before you schedule it: ```bash cat node_modules/@uptimizr/agent-core/skills/weekly-scene-health/SKILL.md ``` `--skill` accepts either spelling of a name: `weekly_scene_health` or `weekly-scene-health`. `*` marks an argument the skill cannot run without — pass it with `--scene`; `range` is filled in from `--window` (or `--since`/`--until`). | Skill | Arguments | What it produces, and when to use it | Tools its method names | | ------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `attention_hotspots` | `scene*`, `range?` | Find where visitors look and click in a scene: view-direction concentration, gaze→mesh flow, the objects that draw the most interaction, and the ones nobody ever notices. USE FOR: deciding where to put a call to action, finding ignored or invisible content, explaining why an object gets no clicks, laying out a scene around what people actually look at. | `camera_heatmap`, `flow_links`, `click_rays`, `top_meshes`, `mesh_dwell`, `mesh_blind_spots`, `query` | | `conversion_investigation` | `scene?`, `range?` | Find out where a funnel loses people and whether the loss is real: step-by-step drop-off, the bounce that happens before the funnel even starts, scene-to-scene retention, variant performance, and the interaction failures (dead clicks, rage clicks, unreachable meshes) that explain a stalled step. USE FOR: a funnel that converts worse than expected, an A/B variant comparison, “where do people drop off”, diagnosing a step nobody completes. | `funnel`, `load_bounce_funnel`, `scene_retention`, `variant_leaderboard`, `dead_clicks`, `rage_clicks`, `mesh_reachability`, `flow_links`, `insight_significance`, `insight_movers`, `query` | | `performance_regression_triage` | `scene?`, `range?` | Triage a frame-rate or stability regression: confirm it moved, date it, locate it (which scene, device class, place in the scene), and name the mechanism — jank, shader compile stalls, memory pressure, a render-scale change or a rendering-technology shift. USE FOR: “the app got slower”, a FPS drop after a release, stutter reports, deciding whether a regression is real or noise. | `insight_movers`, `insight_anomalies`, `insight_significance`, `insight_baseline`, `perf_summary`, `perf_distribution`, `frame_time_percentiles`, `jank_rate`, `perf_by_device`, `perf_by_scene`, `perf_heatmap`, `compile_stalls`, `resource_percentiles`, `render_scale_truth`, `rendering_technology`, `query` | | `weekly_scene_health` | `scene?`, `range?` | A weekly health check for a scene (or the whole project): a weighted health score with every factor traced back to the metric behind it, what changed against last week, traffic, event mix, performance, and the most-interacted meshes. USE FOR: the recurring “how is the scene doing?” review, a scheduled weekly or monthly report, a first look at a project you do not know yet, deciding which scene to investigate next. | `insight_scene_health`, `insight_movers`, `insight_baseline`, `insight_significance`, `insight_anomalies`, `event_counts`, `timeseries`, `perf_summary`, `top_meshes`, `list_sessions`, `query` | | `xr_comfort_audit` | `scene?`, `range?` | Audit VR/AR comfort for a scene (or the whole project): rapid head rotation, locomotion style, tracking quality, guardian/boundary contacts, input-source mix, and the short sessions that mean someone took the headset off. USE FOR: motion-sickness complaints, immersive sessions that end early, choosing a locomotion scheme, checking whether a play space is big enough. | `xr_rotation`, `xr_locomotion`, `xr_abandonment`, `xr_sources`, `xr_tracking_quality`, `xr_boundary_contacts`, `boundary_heatmap_stats`, `insight_scene_health`, `insight_movers`, `query` | ### Flags [Section titled “Flags”](#flags) | Flag | Meaning | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `--skill ` | The investigation to run (required). See `--list-skills`. | | `--scene ` | Scope the report to one scene. | | `--window ` | Window counting back from now — `24h`, `7d` (default), `2w`. | | `--since`/`--until` | Explicit epoch-millisecond window, instead of `--window`. | | `--out ` | Where the Markdown goes. Default `-` (stdout). | | `--json ` | Also write the structured report: every tool call with its arguments, duration and outcome, plus token usage when the provider reports it. | | `--webhook ` | `POST` the report to an `http(s)` URL, signed (below). | | `--max-steps ` | Cap on provider turns (default `8`). | | `--dry-run` | Print the exact prompt and tool list and call **no** provider. | `--dry-run` is the cheap way to check a new cron line: it reads your collector (including the project context document) and prints the prompt it would send, without spending a token. ### Environment [Section titled “Environment”](#environment) | Variable | Required | Meaning | | ------------------------- | -------------------- | --------------------------------------------------------------------------------------------- | | `UPTIMIZR_COLLECTOR_URL` | yes | Base URL of your collector, e.g. `https://collect.example.com`. | | `UPTIMIZR_API_KEY` | yes | Project API key. A `query`-only key is enough. | | `UPTIMIZR_AGENT_PROVIDER` | no | `anthropic` (default), `openai` (any OpenAI-compatible endpoint), or `scripted`. | | `UPTIMIZR_AGENT_MODEL` | no | Model id. Defaults to `claude-sonnet-5` / `gpt-4o-mini`. | | `UPTIMIZR_AGENT_API_KEY` | for a model run | Your provider key. Falls back to `ANTHROPIC_API_KEY` / `OPENAI_API_KEY`. | | `UPTIMIZR_AGENT_ENDPOINT` | no | Provider base URL — point it at a gateway, a proxy or a self-hosted OpenAI-compatible server. | | `UPTIMIZR_WEBHOOK_SECRET` | for a signed webhook | HMAC-SHA-256 secret for the `X-Uptimizr-Signature` header. | ### Exit codes [Section titled “Exit codes”](#exit-codes) | Code | Meaning | | ---- | ------------------------------------------------------------------------------------------------- | | `0` | Report produced; every tool call succeeded. | | `1` | Usage or configuration error — nothing ran. | | `2` | The provider call, or the webhook delivery, failed. | | `3` | A report was produced but is **incomplete**: a tool call failed, or the model returned no answer. | ### Webhook delivery [Section titled “Webhook delivery”](#webhook-delivery) `--webhook ` posts one JSON body, `{ "markdown": "…", "report": { … } }`, with: | Header | Value | | ---------------------- | ------------------------------------------------------------------------------- | | `X-Uptimizr-Signature` | `sha256=` | | `X-Uptimizr-Delivery` | A unique id per delivery attempt, for receiver-side de-duplication. | Verify it over the **raw** body, before parsing, with a constant-time comparison: ```js import { createHmac, timingSafeEqual } from "node:crypto"; function verify(rawBody, header, secret) { const expected = `sha256=${createHmac("sha256", secret).update(rawBody, "utf8").digest("hex")}`; const a = Buffer.from(String(header ?? ""), "utf8"); const b = Buffer.from(expected, "utf8"); return a.length === b.length && timingSafeEqual(a, b); } ``` Without `UPTIMIZR_WEBHOOK_SECRET` the delivery still happens but is unsigned, and the command warns: a receiver that cannot verify the body has no way to tell your digest from a forged one. The webhook URL is an outbound request you control Only `http:` and `https:` URLs are accepted. Host reachability is deliberately **not** restricted — posting to `http://localhost:3000/hook` or to an internal relay is the normal case, and this command runs in your shell, not in a multi-tenant service. Treat the URL like any other argument to a process you scheduled: don’t build it from untrusted input, and if you ever expose report scheduling to users, add your own allow-list on top. ### Weekly report with GitHub Actions [Section titled “Weekly report with GitHub Actions”](#weekly-report-with-github-actions) Runs every Monday at 07:00 UTC, posts the report to a webhook, and uploads the Markdown as an artifact. Put `UPTIMIZR_API_KEY`, `UPTIMIZR_AGENT_API_KEY` and `UPTIMIZR_WEBHOOK_SECRET` in the repository’s **secrets**, and the collector URL in a variable. .github/workflows/uptimizr-weekly-report.yml ```yaml name: Uptimizr weekly report on: schedule: - cron: "0 7 * * 1" # Mondays, 07:00 UTC workflow_dispatch: jobs: report: runs-on: ubuntu-latest steps: - uses: actions/setup-node@v6 with: node-version: 22 - name: Generate the weekly scene-health report env: UPTIMIZR_COLLECTOR_URL: ${{ vars.UPTIMIZR_COLLECTOR_URL }} UPTIMIZR_API_KEY: ${{ secrets.UPTIMIZR_API_KEY }} UPTIMIZR_AGENT_PROVIDER: anthropic UPTIMIZR_AGENT_API_KEY: ${{ secrets.UPTIMIZR_AGENT_API_KEY }} UPTIMIZR_WEBHOOK_SECRET: ${{ secrets.UPTIMIZR_WEBHOOK_SECRET }} run: | npx -p @uptimizr/collector-server uptimizr agent report \ --skill weekly_scene_health \ --scene lobby \ --window 7d \ --out report.md \ --json report.json \ --webhook "${{ vars.UPTIMIZR_REPORT_WEBHOOK }}" - uses: actions/upload-artifact@v4 if: always() with: name: uptimizr-weekly-report path: | report.md report.json ``` The step fails the job on a non-zero exit, so a provider outage or a failed tool call shows up as a red run rather than a silently empty digest. Drop `--webhook` to keep the report as an artifact only. ### Weekly report from cron [Section titled “Weekly report from cron”](#weekly-report-from-cron) ```cron # m h dom mon dow — Mondays at 07:00, on the host that runs the collector 0 7 * * 1 UPTIMIZR_COLLECTOR_URL=http://localhost:4318 \ UPTIMIZR_API_KEY=utk_… \ UPTIMIZR_AGENT_API_KEY=sk-ant-… \ /usr/bin/npx -p @uptimizr/collector-server uptimizr agent report \ --skill weekly_scene_health --window 7d \ --out /var/log/uptimizr/weekly-$(date +\%Y-\%m-\%d).md \ >> /var/log/uptimizr/report.log 2>&1 ``` Keep the keys in a file cron sources (`. /etc/uptimizr/report.env`) rather than in the crontab itself, so they are not world-readable in `crontab -l`. ### What the report contains [Section titled “What the report contains”](#what-the-report-contains) Every report ends with a **Method** section listing each tool call, the arguments it was made with, how long it took and whether it succeeded — so a model-written document stays auditable: a reader can see exactly which aggregates a figure came from, re-run them, and spot a call that quietly failed. The `--json` report carries the same information in machine-readable form, plus the provider, the model, the step count and the token usage the provider reported. # Privacy & configuration > Uptimizr's privacy model and the configuration that controls retention, CORS, and opt-in capture. Uptimizr is privacy-first by architecture: the responsible default is the easy default. ## The privacy model [Section titled “The privacy model”](#the-privacy-model) * **Cookieless, no client IDs.** No cookies, no `localStorage` identifiers, no fingerprinting. Nothing persistent is written to the visitor’s device. The `sessionId` is in-memory only. * **Server-side rotating visitor hash.** Visitors are counted with a hash computed **on the server** that rotates **every day**, so individuals can’t be tracked across days. * **Derived, coarse browser/OS — never the raw User-Agent.** The request User-Agent is used only to compute the visitor hash and to derive a coarse `{ browser, os }` family pair (e.g. `Safari` / `iOS`) for the performance device segment. The raw User-Agent and version are never stored — only the two low-cardinality families. Derived, non-PII (ADR 0003 / ADR 0041). * **No PII by default.** Events carry spatial and performance signals, not personal data. Never put PII in `meta`, `track` props, or `user`. * **Opt-in user descriptor.** `user.id` must be pseudonymous or hashed — never an email, username, or raw account id. Omit it to stay fully anonymous (see [sdk-core](/docs/connectors/sdk-core/#anonymized-users-opt-in)). ## Retention is opt-in [Section titled “Retention is opt-in”](#retention-is-opt-in) Raw per-session event retention — the ordered stream that powers **replay** — is **opt-in** on the collector: ```bash ENABLE_RAW_SESSION_RETENTION=true ``` With it off, the collector keeps only aggregates; `/api/v1/sessions/:id/events` and `/api/v1/sessions/:id/narrative` return `403`. The aggregate endpoints never expose raw events. Retention is only **half** the gate. Reading a raw per-session stream — the replay timeline `/api/v1/sessions/:id/events` and the live per-session follow `/api/v1/live/sessions/:id` — also requires an API key holding the `query:raw` [capability](/docs/deploy/collector/#api-keys-and-capabilities). A plain `query` key reads aggregates only, whatever retention is set to, so “who may see raw sessions” is a deliberate, per-key decision rather than a collector-wide switch. Breaking change ### What a session narrative shows [Section titled “What a session narrative shows”](#what-a-session-narrative-shows) `GET /api/v1/sessions/:id/narrative` sits behind the same two gates and returns a **projection** of the raw stream, not the stream itself. It is an allow-list, not a redactor: it reads only the fields below, so a future event type cannot widen it by accident. It **shows** relative timestamps (milliseconds since the session’s first event, never a wall-clock time), scene ids, mesh/object names, interaction kinds and input sources, custom-event and input-action **names**, frame-rate dips, runtime-error messages (truncated), graphics-diagnostic category and severity, capability transitions, and the rendering engine / graphics API. It **never** carries: * the daily-rotating `visitorId`, or any identifier for a person; * the page URL, referrer, title, language or any other `pageMeta` field; * anything from the app-supplied `user` descriptor (`user.id`, traits); * any position, hit point, ray, UV or screen coordinate; * any `device` detail beyond the engine — no renderer or vendor string, OS, browser, memory or core count; * custom-event property **values** — only their keys, which are developer-chosen field names; * a runtime error’s `source` (a URL) or `stack`, or a graphics diagnostic’s message text. If your app attaches personal data to a custom-event property or to an error message, that is the one place it could reach a narrative — error messages are truncated but not filtered, which is the same trade-off as the raw stream and the dashboard’s error panels. This is a tightening: previously `ENABLE_RAW_SESSION_RETENTION` alone was enough and any `query` key could read the raw stream. Existing keys keep working for every aggregate endpoint; a key that drives replay or live-follow must be re-minted with `uptimizr new-key --capabilities query,query:raw`. ## Agent activity is audited [Section titled “Agent activity is audited”](#agent-activity-is-audited) Every authenticated request made with a key that is not the dashboard’s own session is written to an audit trail the project owner can read at `GET /api/v1/audit`: which key (by **id** — never the key), which endpoint, bounded and redacted parameters, rows returned, duration and status. Credential-shaped parameters are dropped before the row is written, and rows expire after `AUDIT_RETENTION_DAYS` (default 30). See [the collector guide](/docs/deploy/collector/#agent-audit-log). ## Project metadata is yours to write — and yours to keep clean [Section titled “Project metadata is yours to write — and yours to keep clean”](#project-metadata-is-yours-to-write--and-yours-to-keep-clean) Annotations, the glossary and saved analyses ([metadata endpoints](/docs/api/metadata/)) are the one place in the collector holding free text you wrote rather than data it captured. They are **project-scoped** — readable only with that project’s key, never shared across projects — bounded in both length and count, and every write is recorded in the agent audit trail above, with the key that made it. Writing any of them requires the `annotate` capability; a read-only key cannot. Because it is free text, it is also the one surface the collector cannot keep non-PII for you. Do not paste personal data into a note, a definition or a conclusion. Nothing on this path touches captured events: they remain read-only and aggregate-only. ## Opt-in capture channels [Section titled “Opt-in capture channels”](#opt-in-capture-channels) Several capture channels are off by default for privacy and cost, and must be enabled per scene in the connector (`capture.*` / options): | Channel | Event | Discloses | | ---------------- | ------------------------ | --------------------------------------------------- | | `meshVisibility` | `mesh_visibility` | Per-object dwell; with `boundingBox`, scene layout. | | `hoverDwell` | `hover_dwell` | Hover hesitation per object. | | `resourceSample` | `resource_sample` | GPU/memory footprint. | | `gaze` | `camera_sample.hitPoint` | Where users looked on the geometry. | | `captureErrors` | `runtime_error` | Error messages (not auto-redacted). | Enable only what you need. ## CORS & origins [Section titled “CORS & origins”](#cors--origins) Restrict which browser origins may post and query: ```bash COLLECTOR_CORS_ORIGINS=https://app.example.com,https://www.example.com ``` An empty dashboard or rejected ingestion is most often a CORS mismatch or a collector URL pointing at the wrong host. ## Tenant isolation [Section titled “Tenant isolation”](#tenant-isolation) Every read and write authenticates with a project API key; the project is resolved from the key server-side. There is no cross-project query — a caller can only ever access its own data. Do not add a `projectId` param to widen a query; it is ignored. # Building agents on Uptimizr > The AI-first analytics layer end to end — the semantic metric registry, capability discovery, OpenAPI, the MCP server, the in-browser assistant, agent-shaped result formats, scoped keys and the evaluation harness. Uptimizr treats an **agent as a first-class consumer of the data**, alongside the dashboard ([ADR 0051](https://github.com/RaananW/Uptimizr/blob/main/docs/adr/0051-ai-first-analytics-layer.md)). That is a concrete claim, not a slogan: every read the collector serves is declared once in a semantic registry, and the tool catalog, the OpenAPI document, the capability descriptor and the docs tables are all *generated* from it — so an agent sees the whole read surface, with units and caveats attached, and it cannot drift from what the collector actually computes. This page is the map. Each section links to the reference for that piece. Read the catalog Start at `uptimizr://capabilities` or `/api/v1/openapi.json` — both list every metric with its grain, units and caveats. Connect Run [`@uptimizr/mcp`](/docs/guides/mcp/) over stdio, let the collector [host it over HTTP](/docs/guides/mcp/#hosted-transport-streamable-http), or embed the [in-browser assistant](/docs/guides/assistant/). Ask for \`format=summary\` Bounded digests with shares, caveats and a templated reading — not a thousand raw rows. Tools default to the `table` envelope. Scope the key A labelled `query`-only key, its own rate budget, and every request in the audit log. ### These docs, for an agent [Section titled “These docs, for an agent”](#these-docs-for-an-agent) This site follows the [`llms.txt`](https://llmstxt.org) convention, so a coding agent can read the docs without crawling the HTML. [`/docs/llms.txt`](/docs/llms.txt) is the index; it links [`llms-small.txt`](/docs/llms-small.txt) (getting started, concepts and the core guides), [`llms-full.txt`](/docs/llms-full.txt) (every page) and one file per topic under `/docs/_llms-txt/` — `sdk-and-connectors`, `capturing-and-using-data`, `ai-and-agents`, `http-api` and `deploy-and-self-host` — so a task can load just the section it needs. ## 1. The semantic metric registry [Section titled “1. The semantic metric registry”](#1-the-semantic-metric-registry) `@uptimizr/metrics` is a dependency-light package (`zod` + `@uptimizr/schema`, no database driver) holding **78 metric definitions**, 76 of which the collector serves on a read endpoint. Each one declares, as data: | Field | What it gives an agent | | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `grain` | What **one row is** — a project, a scene, a session, a mesh, a bin, a voxel, a time bucket. | | `columns` | Per-column description and **unit** (`ms`, `fps`, `count`, `ratio`, `world-units`, …), plus which column is the measure and which names the row. | | `row` | The **JSON Schema** of a result row, so a client with no Zod can validate or shape it. | | `filters` / `dimensions` | The parameters it accepts and the dimensions its rows are keyed by. | | `limits` | `maxRows` and `maxSummaryRows` — nothing can ask for an unbounded payload. | | `interpretation` | How to read the result: what a high or low value actually means. | | `caveats` | Minimum sample size, sampling-rate sensitivity, which capture channel must be on. **Read these before quoting a number.** | | `sourceChannels` | The capture channels ([ADR 0012](https://github.com/RaananW/Uptimizr/blob/main/docs/adr/0012-sampling-and-fidelity.md)) that feed it — if a channel is off, the metric is empty **by design**, not by accident. | | `related` / `comparable` | Metrics worth reading alongside it, and which column’s change is “the” change. | Units and caveats are the part that makes an answer trustworthy. “Average FPS is 48” means something different when the metric’s caveat says the sample is under 30 events, or when its source channel was never enabled — and both facts travel with the metric rather than living in a human’s head. Everything downstream is derived from the registry and nothing restates it by hand: the tool catalog in `@uptimizr/agent-core`, the OpenAPI document, the `uptimizr://capabilities` resource, the [query reference](/docs/api/query/) on this site, and the packaged `AGENTS.md` / `llms.txt` tool lists. A repo script (`pnpm gen:docs`) renders them and a CI check fails if any drifted. ## 2. Discovery: context, capabilities and OpenAPI [Section titled “2. Discovery: context, capabilities and OpenAPI”](#2-discovery-context-capabilities-and-openapi) Three entry points, and the order matters. The first describes **this project**; the other two describe the API. * **`uptimizr://context`** / **`GET /api/v1/context`** — the [project context document](/docs/api/context/). Read it **first**, before anything else: it carries the project’s real scene ids and named region ids, the custom events the application emits with the props they carry, the top meshes and bound input actions, how fresh the data is, whether raw retention is on, and which metrics will return empty because every capture channel that feeds them is off. It is bounded (well under 16 KB) and cached per project, so it is cheap to re-read. Without it an agent guesses a scene id, filters on a custom-event name that does not exist, and reports a disabled channel’s zero as a finding. The other two describe the surface rather than the project: * **`uptimizr://capabilities`** — an MCP resource served by `@uptimizr/mcp` from the package itself, with no collector call. It carries the schema version, the canonical event types, the tool catalog, a parameter-semantics glossary, and `metrics`: the whole registry above. * **`GET /api/v1/openapi.json`** — an **OpenAPI 3.1** document served by the collector, no key required (it is documentation and contains no project data). One path per endpoint, every parameter with the schema that actually validates it, and a `200` response schema per metric. What OpenAPI has no vocabulary for rides along as vendor extensions — `x-uptimizr-grain`, `x-uptimizr-units`, `x-uptimizr-caveats`, `x-uptimizr-interpretation`, `x-uptimizr-source-channels`, `x-uptimizr-limits`, `x-uptimizr-dimensions`, `x-uptimizr-related`, `x-uptimizr-comparable`. That second one means you do not need MCP at all — generate a typed client, point an API explorer at it, or hand the document to any agent framework: ```bash npx openapi-typescript https://collect.example.com/api/v1/openapi.json -o collector.d.ts ``` `uptimizr://scenes` (and `GET /api/v1/scenes`) is a narrower view of what the context document already carries: just the scene ids with recent activity. Reach for it when that is all you need. ## 3. Ways to connect [Section titled “3. Ways to connect”](#3-ways-to-connect) ### MCP server — `@uptimizr/mcp` [Section titled “MCP server — @uptimizr/mcp”](#mcp-server--uptimizrmcp) An MCP server over your collector’s query API — read-only unless its key says otherwise — run over **stdio**: ```bash UPTIMIZR_COLLECTOR_URL="https://collect.example.com" \ UPTIMIZR_API_KEY="utk_…" \ npx @uptimizr/mcp ``` Every registry metric with an endpoint becomes a typed, `GET`-only tool carrying that metric’s interpretation and caveats in its description and an MCP `outputSchema` covering every envelope it can answer with — its rows, the `table` envelope around them, a `summary` digest. There is no ingestion tool and nothing that can alter an event; a `query:raw` key additionally gets `session_narrative` (§10) and an `annotate` key the metadata write tools (§8). It also registers a prompt per [packaged skill](#9-packaged-methodology-skills), each sequencing the tools for a common investigation. See the [MCP guide](/docs/guides/mcp/). ### Hosted MCP — `/mcp` on your collector [Section titled “Hosted MCP — /mcp on your collector”](#hosted-mcp--mcp-on-your-collector) The collector can serve that same MCP server itself, over Streamable HTTP, so a remote client needs no local process at all. Start it with `COLLECTOR_MCP_HTTP=1` (off by default) and point a client at `https://collect.example.com/mcp` with `x-api-key` or `Authorization: Bearer `. Same tools, same resources, same prompts; every request is authenticated, sessions are capped, and tool calls land in the audit log as `surface: mcp-http`. See [hosted transport](/docs/guides/mcp/#hosted-transport-streamable-http). ### In-browser assistant [Section titled “In-browser assistant”](#in-browser-assistant) The same tool catalog, in the dashboard, against a model the **user** supplies — WebLLM running locally in the browser, or a hosted backend with their own key. Nothing about it is Uptimizr-operated ([ADR 0050](https://github.com/RaananW/Uptimizr/blob/main/docs/adr/0050-in-browser-analytics-assistant.md)). The catalog lives in `@uptimizr/agent-core`, which is framework-agnostic and browser-safe, so the MCP server, the dashboard assistant and the demo assistant cannot drift apart. See the [assistant guide](/docs/guides/assistant/). ### Scheduled reports — `uptimizr agent report` [Section titled “Scheduled reports — uptimizr agent report”](#scheduled-reports--uptimizr-agent-report) The same catalog and the same curated investigations, run **headlessly on a schedule** instead of in a chat: ```bash UPTIMIZR_COLLECTOR_URL="https://collect.example.com" \ UPTIMIZR_API_KEY="utk_…" \ UPTIMIZR_AGENT_API_KEY="sk-ant-…" \ npx -p @uptimizr/collector-server uptimizr agent report \ --skill weekly_scene_health --scene lobby --window 7d --out report.md ``` It runs the headless loop once in **your** process against **your** collector with **your** provider key, writes Markdown to a file, stdout or a signed webhook, and exits with a code a cron wrapper can branch on. The collector gains no LLM loop, and no provider configuration is ever persisted. Every report ends with a **Method** section listing the tool calls and their arguments, so an unattended document stays auditable. `--dry-run` prints the exact prompt without calling a provider, and `UPTIMIZR_AGENT_PROVIDER=scripted` exercises the whole path with no model and no key at all. See [Scheduled agent reports](/docs/deploy/collector/#scheduled-agent-reports). ### Plain HTTP [Section titled “Plain HTTP”](#plain-http) Nothing stops an agent framework from calling the [query API](/docs/api/query/) directly with `x-api-key`. The OpenAPI document is there precisely for that. ## 4. Ask for agent-shaped results [Section titled “4. Ask for agent-shaped results”](#4-ask-for-agent-shaped-results) The single highest-leverage habit: **pass `format=summary`**. Every aggregate endpoint — and every generated aggregate tool — accepts `format=full | table | summary`. It filters nothing; it picks the envelope the rows arrive in. The HTTP endpoint defaults to `full` (what the dashboard uses); the generated **tools** default to `table`, which they send explicitly. | `format` | What comes back | | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `full` | The bare rows, unchanged. The HTTP endpoint default (what the dashboard uses). | | `table` | `{ meta, rows }` — the rows plus the metric, range, applied filters, sample size, row count, a truncation flag and the registry’s limits. **The tools’ default.** | | `summary` | A bounded digest: `ranked` top rows, a `series` trend, merged spatial `clusters` or a single `record`, with shares, a sample size, the metric’s caveats and a templated `reading` sentence. | `summary` is capped at the metric’s `maxSummaryRows`, so a 500-bin heatmap costs the same as a 5-bin one. The `reading` sentence and the caveats are templated from the registry’s column semantics by **pure code** — no model is involved — so the same rows always produce the same words, and shares appear only where the measure can honestly be summed. Full details and an example payload: [result formats](/docs/api/query/#result-formats). A tool that is asked for no `format` asks the collector for `table`, so a model always gets the `meta` context with its rows; `summary` is still the one to ask for when the result could be big, and all three validate against the tool’s advertised output schema. ## 5. One query, any metric — the query DSL [Section titled “5. One query, any metric — the query DSL”](#5-one-query-any-metric--the-query-dsl) Seventy-seven tools is a lot of surface for a model to hold, and the canned tool for a metric exposes only the filters that metric’s endpoint happens to take. The **query DSL** collapses that into one call: name the `metric`, bound it with a `range`, narrow it with the filters that metric declares, cap it, and pick the envelope. ```jsonc // POST /api/v1/query — or the `query` tool, whose input is this document { "v": 1, "metric": "mesh_sources", "range": { "since": 1757000000000, "until": 1757600000000 }, "filters": { "scene": "lobby", "cameraMode": "first-person" }, "limit": 20, "format": "summary", } ``` The grammar is **closed**: no SQL, no expression language, no free-form value. Metrics, dimensions and filters are exactly the registry’s vocabulary — the one an agent already reads from `uptimizr://capabilities` — and a query naming something outside it comes back as a `400` that says what *would* have worked, with the accepted values as data: ```jsonc { "code": "unsupported_filter", "path": "filters.scene", "message": "\"top_meshes\" does not accept the filter \"scene\". It accepts `session`, `bins`, `limit`.", "accepted": ["session", "bins", "limit"], } ``` That is the property worth designing for: a wrong guess is a correction, never an empty result the agent would report as “no interactions”. The per-metric tools stay — they are excellent for discovery, and a small local model does better with a narrow, obvious tool than with a schema it has to compose. Reach for `query` when a question needs a filter the canned tool does not expose, or when `format: "table"` (the DSL’s default, unlike the canned endpoints’) is what you want anyway. ### The three things a model otherwise gets wrong [Section titled “The three things a model otherwise gets wrong”](#the-three-things-a-model-otherwise-gets-wrong) ```jsonc { "v": 1, "metric": "top_meshes", "range": { "since": 1757600000000, "until": 1758204800000 }, "compare": { "range": { "since": 1756995200000, "until": 1757600000000 } }, "format": "summary", } ``` **What changed.** `compare` runs the query twice and joins the results on the dimension key, so each row is `{ key, label, current, previous, delta, deltaPct }` and a key present on one side only is still a row. `significance` is attached only where the measure is a count and both windows clear the metric’s own minimum — a p-value the data cannot support is worse than none. Left to itself a model will run two queries and subtract them in prose, which is where the arithmetic goes wrong. **Can you trust it.** `explain: true` answers with the plan rather than the rows: which compiler would run, the SQL with its parameters left unbound, how much data the window holds, and `warnings` for a capture channel that produced nothing (ADR 0012 — the most common reason an Uptimizr answer is empty *and wrong*), a sample below the metric’s minimum, or a result cut off by `limit`. **Narrow it down.** Every row of a `summary` carries `drillQuery`: the whole query, narrowed to that row, ready to send straight back. ## 6. Let the collector do the comparing [Section titled “6. Let the collector do the comparing”](#6-let-the-collector-do-the-comparing) Five questions come up on every investigation, and a model that answers them by pulling raw rows and doing arithmetic will answer them differently each time: **“is this number normal here?”**, **“what changed?”**, **“when did it go wrong?”**, **“is that change real?”** and **“which scene should I look at first?”** All five are metrics of their own. | Tool | Answers | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `insight_baseline` | What is normal for one metric in one scene: `median` and `mad`, the p10..p90 band, and the drift `slope`. | | `insight_movers` | What moved against the previous equal window, for every comparable metric, ranked by how unusual it is. | | `insight_anomalies` | Which individual buckets of one metric do not belong — and which mesh, channel or source holds the excess. | | `insight_significance` | Whether one metric’s difference between two windows could be chance: effect, 95% interval, p-value, and the test that produced them. | | `insight_scene_health` | One 0-100 score per scene over six weighted factors, each carrying the metric id, raw value and project baseline behind it. | Reach for `insight_movers` **first** on an open-ended “how are things?” question: it turns a survey of thirty endpoints into one call, and it ranks by a robust z-score — the change divided by how much that metric normally swings — so a noisy metric has to move much further than a steady one to reach the top. Then use `insight_baseline` on whatever moved, to say whether the new level is actually outside normal rather than merely different. Two fields decide whether a row is worth reporting at all: * **`direction`** is the registry’s opinion of what a *rise* means for that metric — good (`up`), bad (`down`) or neutral. It is **not** the direction of the move: read it with the sign of `delta`, so a rise in a `down` metric (errors, dead clicks, jank) is a regression. * **`aboveMinSample: false`** means the delta is real arithmetic but the denominator is too small to be evidence. Those rows are returned rather than dropped — “we cannot tell” and “nothing changed” are different answers — and they must never be reported as findings. Then reach for `insight_anomalies` to put a **date** on it. `movers` compares two windows you chose; `anomalies` walks the whole series and names the buckets that do not belong: a `spike` or a `drop` where one bucket sits more than `sensitivity` standard deviations from the ones before it, and a `shift` at the bucket where the level moved and *stayed* moved — the shape a release regression actually has, and the one a per-bucket threshold can never see. Quote `bucketStart` rather than saying “recently”, and read `contributor`: where the metric declares a dimension it can be split by, the row names the mesh, source, input action, event type or scene holding the largest share of the excess, with that share. A share above \~0.8 means the anomaly *is* that value and the investigation has one place to start; `null` means the metric has no column that could honestly explain it. Note that `anomalies` reports `z` in standard deviations while `movers` reports the same ratio unscaled, so do not compare the two columns directly. ### Say whether a change is real, and which scene to open first [Section titled “Say whether a change is real, and which scene to open first”](#say-whether-a-change-is-real-and-which-scene-to-open-first) `insight_movers` says *how unusual* a change is; `insight_significance` says whether it could be chance. Call it before you write the sentence “X went up” about anything a stakeholder will act on — it picks the test 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), so you never have to choose one. Read **`ci95` before `p`**. An interval that straddles 0 means you cannot tell yet, whatever the p-value says, and `powerNote` states the smallest difference these sample sizes could have detected at all — which is the difference between “no effect” and “no data”. Note that Welch counts **buckets**, not events: `n` is days or hours, because frame samples inside one day are not independent. And it compares two *windows*, not two segments: a variant-versus-variant contrast is a `400`, not a wrong answer. `insight_scene_health` is the one to open an *unscoped* investigation with. It scores each scene over six weighted factors and returns them least healthy first, so “where do I start” is one call. Open the lowest-scoring scene, then the factor whose own score is furthest below 50; that factor names the `metric` behind it, its `raw` value and the `baseline` it was compared with, so the next endpoint to call and the sentence to write are both already in the row. **50 is the project norm, not a pass mark** — the score compares a scene with the rest of the project’s recent past, so a project where everything is equally bad reads 50 everywhere. A factor with `score: null` was not counted, and its `note` says why. Statistics are computed in pure TypeScript over portable day/hour buckets, so the same data gives the same baseline, the same p-value and the same score on DuckDB, ClickHouse, Postgres and SQL Server. Full parameters and the excluded-metric rules: [insights](/docs/api/query/#insights--baselines-movers-anomalies-significance-and-health). ## 7. Give answers a vocabulary for *where* [Section titled “7. Give answers a vocabulary for where”](#7-give-answers-a-vocabulary-for-where) 3D analytics answers are spatial, and “the hot cell is at voxel (12, 0, −7)” is not an answer a person can act on. **Scene regions** are named, labelled boxes — “the entrance”, “the checkout counter” — registered per scene, so an agent can drill into a place by name (`?region=entrance`) and phrase the result in words. ```ts import { registerRegions } from "@uptimizr/sdk-core"; ``` …or register them offline with `uptimizr regions set --file regions.json`. Reading the vocabulary (`GET /api/v1/scene-regions`) takes a `query` key; **writing** it takes an `annotate` key, so a read-only agent key cannot redraw your map. See [scene regions](/docs/api/query/#scene-regions). Regions pay off the moment you also ask for `format=summary`. With a scene’s regions and its proxy registered, every spatial hotspot arrives already named — `region`, every containing `regions[]`, the `nearestMesh` it sits on and the `distance` to it — and the `reading` says it out loud: *“the densest spans 3x2x3 voxels on `checkout_button` in region `counter`”*. The cluster’s `drill.region` is the region **id**, so the obvious follow-up is a single parameter. Nothing registered means `null` plus a caveat naming what is missing, never a guess. See [labelled spatial clusters](/docs/api/query/#labelled-spatial-clusters). ## 8. Let the agent leave something behind [Section titled “8. Let the agent leave something behind”](#8-let-the-agent-leave-something-behind) An agent that can only read re-derives the same context every session: what that mesh name means, why last Tuesday spiked, whether this question has been asked before. The **metadata write path** gives it somewhere to put what it learned — and gives the next reader, human or agent, somewhere to find it. | Tool | What it keeps | | --------------- | ------------------------------------------------------------------------------ | | `annotate` | A note pinned to the project, a scene, a mesh, a region, a metric or a period. | | `define_term` | What a name means in this project. | | `save_analysis` | A titled question plus the conclusion drawn from it. | `list_annotations`, `list_glossary` and `list_analyses` read them back — worth calling *before* explaining a spike someone has already explained. Three properties make this safe to hand an agent: * **Events stay read-only.** Nothing on this path can write, alter or delete an analytics event. Metadata rows are a separate surface with their own tables. * **It is gated.** Every write needs a key holding `annotate`; `@uptimizr/mcp` asks `GET /api/v1/whoami` at start-up and registers the write tools only when the key has it, so a read-only key yields a read-only server. * **It is audited and bounded.** Every write lands in the agent audit log with the key that made it, every field is length-capped at the edge, and each project holds at most 500 annotations, 200 glossary terms and 200 saved analyses. Stored rows record whether a person or an agent wrote them — the collector decides that from the calling client, never from the payload. Full reference: [Metadata endpoints](/docs/api/metadata/). ### Leave the question, not just the answer [Section titled “Leave the question, not just the answer”](#leave-the-question-not-just-the-answer) A note explains something once. A **panel** keeps asking. | Tool | What it does | | ------------- | ------------------------------------------------------------- | | `pin_panel` | Puts a query, a chart and a reading on the project dashboard. | | `list_panels` | What is already pinned — read it before adding another. | | `unpin_panel` | Removes one, for the whole project. | ```json { "v": 1, "title": "Meshes people actually touch", "query": { "v": 1, "metric": "top_meshes", "range": "inherit", "limit": 10 }, "chart": "bar", "note": "The crate outsells everything else three to one." } ``` `range: "inherit"` is the part that matters: the pinned panel follows the dashboard’s own time filter, so it keeps answering against whatever window a person is looking at rather than the one the agent happened to ask in. **A panel is data, not code.** Uptimizr can load remote panel *modules* at runtime (ADR 0041), and those run with the dashboard’s full privileges — which is why they are off by default and guarded by an origin allowlist. A panel written by a model would be exactly that, so agent-authored panels do not work that way. A spec is a closed document — a metric id, a chart name, some column names — and the dashboard draws it with the panel components it already ships. Nothing is imported and nothing is evaluated. The collector checks the chart against the metric’s grain before storing it, and refuses a combination that would draw something misleading — a `line` over a ranking has no axis to walk along — naming the charts that would have worked. The table is in [Custom dashboard panels](/docs/guides/custom-panels/#declarative-spec-panels). In the in-browser assistant this is the **“Pin as panel”** button under any answer that came from a `query` call: the chart is chosen from the metric’s grain, the range becomes `"inherit"`, and the answer becomes the panel’s subtitle. ## 9. Packaged methodology skills [Section titled “9. Packaged methodology skills”](#9-packaged-methodology-skills) A prompt that names tools still leaves the method to the model. A **skill** carries the method: an Agent Skills file — `skills//SKILL.md` — whose frontmatter declares the tools it relies on, the capabilities a key needs and the arguments it takes, and whose body is the investigation written out as numbered steps: orient on the context document, start from the score, test the change before calling it real, put a date on it, and end with recommendations and something worth keeping. The files ship inside the `@uptimizr/agent-core` and `@uptimizr/mcp` tarballs and are the single source for every surface that offers one: * `@uptimizr/mcp` registers each as a **prompt template** (`prompts/list` → `prompts/get`) and lists the catalog on `uptimizr://skills`. * `uptimizr agent report --skill ` runs one headlessly on a schedule ([report CLI](/docs/deploy/collector/#scheduled-agent-reports)). * The [in-browser assistant](/docs/guides/assistant/) offers them as starter prompts. * The [evaluation harness](#11-how-the-catalog-is-kept-honest) asks every one of them, twice, so a reworded method is measured rather than assumed. `*` marks a required argument. | Skill | Arguments | What it produces, and when to use it | Tools its method names | | ------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `attention_hotspots` | `scene*`, `range?` | Find where visitors look and click in a scene: view-direction concentration, gaze→mesh flow, the objects that draw the most interaction, and the ones nobody ever notices. USE FOR: deciding where to put a call to action, finding ignored or invisible content, explaining why an object gets no clicks, laying out a scene around what people actually look at. | `camera_heatmap`, `flow_links`, `click_rays`, `top_meshes`, `mesh_dwell`, `mesh_blind_spots`, `query` | | `conversion_investigation` | `scene?`, `range?` | Find out where a funnel loses people and whether the loss is real: step-by-step drop-off, the bounce that happens before the funnel even starts, scene-to-scene retention, variant performance, and the interaction failures (dead clicks, rage clicks, unreachable meshes) that explain a stalled step. USE FOR: a funnel that converts worse than expected, an A/B variant comparison, “where do people drop off”, diagnosing a step nobody completes. | `funnel`, `load_bounce_funnel`, `scene_retention`, `variant_leaderboard`, `dead_clicks`, `rage_clicks`, `mesh_reachability`, `flow_links`, `insight_significance`, `insight_movers`, `query` | | `performance_regression_triage` | `scene?`, `range?` | Triage a frame-rate or stability regression: confirm it moved, date it, locate it (which scene, device class, place in the scene), and name the mechanism — jank, shader compile stalls, memory pressure, a render-scale change or a rendering-technology shift. USE FOR: “the app got slower”, a FPS drop after a release, stutter reports, deciding whether a regression is real or noise. | `insight_movers`, `insight_anomalies`, `insight_significance`, `insight_baseline`, `perf_summary`, `perf_distribution`, `frame_time_percentiles`, `jank_rate`, `perf_by_device`, `perf_by_scene`, `perf_heatmap`, `compile_stalls`, `resource_percentiles`, `render_scale_truth`, `rendering_technology`, `query` | | `weekly_scene_health` | `scene?`, `range?` | A weekly health check for a scene (or the whole project): a weighted health score with every factor traced back to the metric behind it, what changed against last week, traffic, event mix, performance, and the most-interacted meshes. USE FOR: the recurring “how is the scene doing?” review, a scheduled weekly or monthly report, a first look at a project you do not know yet, deciding which scene to investigate next. | `insight_scene_health`, `insight_movers`, `insight_baseline`, `insight_significance`, `insight_anomalies`, `event_counts`, `timeseries`, `perf_summary`, `top_meshes`, `list_sessions`, `query` | | `xr_comfort_audit` | `scene?`, `range?` | Audit VR/AR comfort for a scene (or the whole project): rapid head rotation, locomotion style, tracking quality, guardian/boundary contacts, input-source mix, and the short sessions that mean someone took the headset off. USE FOR: motion-sickness complaints, immersive sessions that end early, choosing a locomotion scheme, checking whether a play space is big enough. | `xr_rotation`, `xr_locomotion`, `xr_abandonment`, `xr_sources`, `xr_tracking_quality`, `xr_boundary_contacts`, `boundary_heatmap_stats`, `insight_scene_health`, `insight_movers`, `query` | Reading one is the fastest way to learn the whole surface, because a method names the tools in the order an experienced analyst would call them: ```bash cat node_modules/@uptimizr/agent-core/skills/performance-regression-triage/SKILL.md ``` Fork one into your own agent, or write your own against the same catalog — the skill format is plain Markdown and the tool ids come from the registry above. ## 10. Scope the key, then read the audit log [Section titled “10. Scope the key, then read the audit log”](#10-scope-the-key-then-read-the-audit-log) Give an agent its own key rather than the dashboard’s: ```bash npx -p @uptimizr/collector-server uptimizr new-key \ --capabilities query --label "weekly-report-agent" \ --rate-limit-max 120 --rate-limit-window-ms 60000 ``` * A key carries a **capability set** — `query`, `query:raw`, `annotate`, `ingest` — and a key that is authenticated but lacks the capability a route needs is refused with `403`. * `query` alone is enough for every aggregate read, and for the MCP server. **`query:raw` is deliberately separate**: raw per-session streams (replay, live-follow) and the [session narrative](/docs/guides/sessions/#session-narrative) need it *and* `ENABLE_RAW_SESSION_RETENTION` on the collector. * A `query:raw` key also unlocks one extra **tool**: `session_narrative`, the ordered account of what a single session did. It is not in the default catalog, because a tool that always answers `403` costs a model a turn and invites a retry. `createMcpServer(client, { capabilities })` registers it only when the capability is really there, and the `uptimizr-mcp` binary discovers that for you by calling `/api/v1/whoami` at start-up. * `--rate-limit-*` gives the key its own budget, bucketed on the key id, so one busy agent cannot spend another’s allowance. * `GET /api/v1/whoami` tells an agent what its key holds, so it can register only the tools it may actually call. * Every authenticated non-dashboard request lands in the **agent audit log** — `GET /api/v1/audit` — with the route pattern, bounded and credential-redacted params, row count, duration and status. Refusals are recorded too. That is what makes an agent’s reads reviewable after the fact. Full reference: [API keys and capabilities](/docs/api/overview/#api-keys-and-capabilities). ## 11. How the catalog is kept honest [Section titled “11. How the catalog is kept honest”](#11-how-the-catalog-is-kept-honest) “AI-first” is a claim until something measures it. The repository carries an **evaluation harness** (`oss/packages/agent-eval`, private to the repo — it is an instrument, not a shipped package): a bank of **48** real analytics questions, asked of an agent through the *real* collector over a deterministic fixture set, and scored on **tool selection**, **argument correctness** and **answer accuracy**. A run seeds the parity fixtures into a fresh in-memory DuckDB store, boots the actual collector in-process, drives `runAgent` with the generated tool catalog, scores every case and compares the result to a committed baseline — exiting non-zero on a regression. Every registry metric the collector serves has at least one question an agent is measured on, so a tool description that makes a model choose badly shows up as a failed case rather than as a quiet wrong answer in production. CI gates the deterministic run on every change to the catalog. ## Push, not pull [Section titled “Push, not pull”](#push-not-pull) Everything above is an agent asking a question. A **conditional subscription** (ADR 0051 §6) is the collector answering one it was not asked — the inversion that turns “I checked and the lobby was fine” into “the lobby stopped being fine at 14:02”. ```bash uptimizr subscriptions add --file fps-drop.json ``` A subscription names a registry metric, a window and a predicate — a threshold crossing, an `anomalies` finding, a mover beyond a percentage, a never-before-seen scene or custom event, a live-presence level — and where a firing goes: an SSE stream for a connected agent, a signed webhook for automation (Slack, a GitHub Action, your own agent), or both. The payload carries the firing **and** a bounded `format=summary` result of the metric, so the receiver can act without a second call. Two things worth knowing before you write one: * **Nothing leaves the process until you say so.** Webhook delivery is disabled until `COLLECTOR_WEBHOOK_ALLOWED_HOSTS` names the hosts your collector may reach. An `sse`-only subscription has no egress at all. * **`list_subscriptions` is a read tool**, so an agent can see what is already being watched before proposing another one. Creating and deleting are not tools yet — an agent does it over plain HTTP with an `annotate` key. The full vocabulary, the signature scheme and the retry policy are in [Subscriptions & webhooks](/docs/deploy/collector/#subscriptions--webhooks). ## What’s next [Section titled “What’s next”](#whats-next) The AI-first layer shipped in stages, and everything on this page is available today — the last of them being the packaged methodology skills above and the declarative panel specs behind [“Pin as panel”](/docs/guides/custom-panels/#declarative-spec-panels). The epic is [#294](https://github.com/RaananW/Uptimizr/issues/294). # Configuration reference > Every option accepted by trackScene / babylonCollector — camera, sampling, idle suppression, channel toggles, delivery, privacy. Every option on this page is accepted by both `trackScene(scene, options)` and the lower-level `babylonCollector(options)`. The same names apply across connectors (the engine-specific arguments — `camera`, `renderer`, `canvas` — differ; see each [connector page](/docs/connectors/overview/)). ```ts trackScene(scene, { projectId: "your-project-id", endpoint: "https://collect.example.com", // ...everything below is optional }); ``` ## Identity & context [Section titled “Identity & context”](#identity--context) | Option | Type | Default | Effect | | ------------------ | ------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `projectId` | string | — | **Required.** Your project id (routes data to the right project). | | `endpoint` | string | — | **Required.** Base URL of your collector. | | `meta` | object | — | Extra `session_start` context: `sceneId`, `url`, `pageMeta`. | | `sceneDescription` | string | — | Free-text description of the scene, surfaced on the session. | | `user` | object | — | **Opt-in**, anonymized user descriptor (`user.id` pseudonymous/hashed; `user.traits` non-identifying). See [sessions](/docs/guides/sessions/#anonymized-user). | ## Camera [Section titled “Camera”](#camera) | Option | Default | Effect | | -------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `camera` | active camera | Which camera the pose/view-direction timeline records. **Set explicitly for multi-camera scenes** (insets, split-screen, render targets) — otherwise the “active” camera is ambiguous. | ## Capture fidelity (`sampling`) — preferred [Section titled “Capture fidelity (sampling) — preferred”](#capture-fidelity-sampling--preferred) A per-channel rate dial for the **continuous** channels. Each rate is a positive **number** (Hz), `"frame"` (every render tick), or `0` (off). ```ts sampling: { camera: 10, // 10 Hz camera pose pointerMove: 60, // 60 Hz pointer movement perf: 0.5, // a perf sample every 2 s // perSource: { leftController: 30, rightHand: 30 }, // XR // nodes / bones — scene actors, see Mesh & object tracking } ``` Omitted channels keep conservative defaults (≈1 Hz camera, ≈4 Hz pointer, ≈0.5 Hz perf). There is no enforced ceiling — higher fidelity just costs more storage. **Discrete** events (clicks, button up/down, mesh interactions, scene changes, session start/end, custom, input actions, and the lifecycle events) are always captured at 100% and cannot be rate-limited. ### Legacy millisecond knobs [Section titled “Legacy millisecond knobs”](#legacy-millisecond-knobs) A matching `sampling` channel overrides these: | Option | Default | Effect | | ----------------------- | ------- | ------------------------------------------- | | `sampleCameraMs` | `1000` | Camera-pose sampling interval. | | `samplePerfMs` | `2000` | Perf (FPS) sampling interval. | | `pointerMoveThrottleMs` | `250` | Minimum gap between `pointer_move` samples. | ## Idle suppression & dedupe [Section titled “Idle suppression & dedupe”](#idle-suppression--dedupe) | Option | Default | Effect | | ------------------------- | ------- | ------------------------------------------------------------------------------------------ | | `suppressIdleSamples` | `true` | Skip timer-based **camera** samples while the pose is unchanged (first is always emitted). | | `cameraEpsilon` | `1e-3` | Max per-axis pose change treated as “unchanged”. | | `suppressIdlePerfSamples` | `false` | Dedupe `frame_perf` while FPS is steady. Off because a stable FPS is meaningful telemetry. | | `perfFpsThreshold` | `1` | Max FPS change treated as “unchanged” (only when `suppressIdlePerfSamples` is on). | ## Channel toggles (`capture`) [Section titled “Channel toggles (capture)”](#channel-toggles-capture) | Option | Default | Effect | | ------------------------ | ------- | --------------------------------------------------------------------------------------------------------------------- | | `capture.camera` | `true` | Camera-pose / view-direction samples. | | `capture.pointerMove` | `true` | Continuous pointer movement (`pointer_move`). | | `capture.clicks` | `true` | Click / tap events (`pointer_click`). | | `capture.buttons` | `true` | Pointer down/up (`pointer_down` / `pointer_up`). | | `capture.meshPicks` | `true` | Mesh interactions (`mesh_interaction`). | | `capture.perf` | `true` | Frame performance (`frame_perf`). | | `capture.contextLoss` | `true` | WebGL/WebGPU `context_lost` / `context_restored`. | | `capture.compileStall` | `true` | Shader compile stalls (`compile_stall`, Babylon only). | | `capture.hoverDwell` | `false` | Hover-hesitation capture (`hover_dwell`). See [mesh tracking](/docs/guides/mesh-tracking/#hover-hesitation). | | `capture.resourceSample` | `false` | GPU/memory footprint (`resource_sample`). See [performance](/docs/guides/performance/#gpu--memory-footprint). | | `capture.gaze` | `false` | World-space gaze (`camera_sample.hitPoint`/`hitMesh`). See [performance](/docs/guides/performance/#world-space-gaze). | `meshVisibility`, `hoverDwell`, `resourceSample`, and `gaze` also take an options **object** to tune them — documented on the [mesh tracking](/docs/guides/mesh-tracking/) and [performance](/docs/guides/performance/) pages. ## Lifecycle, delivery & misc [Section titled “Lifecycle, delivery & misc”](#lifecycle-delivery--misc) | Option | Default | Effect | | ---------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `captureLifecycle` | `true` | Emit `viewport_resize` / `focus_change` / `visibility_change`. | | `resizeDebounceMs` | `250` | Debounce window for `viewport_resize`. | | `captureErrors` | `false` | Opt-in `runtime_error` capture; **not** auto-redacted. | | `captureGraphicsDiagnostics` | `false` | Opt-in engine `graphics_diagnostic` capture (GPU health); **not** auto-redacted. Captures WebGPU `device.lost`, WebGPU `uncapturederror` (rate-limited rollup), WebGL/WebGPU context-creation failure, shader compile/link failures, and sampled WebGL `gl.getError()` (Babylon + three). `context_lost` / `context_restored` stay always-on. | | `captureShaderSource` | `false` | Sub-opt-in to `captureGraphicsDiagnostics`: include raw shader source in shader-compile diagnostics. Off by default — shader source is application IP; even with diagnostics on, source is stripped unless this is set. Length-capped + `beforeSend`. | | `jankFrameMs` | `50` | A rendered frame slower than this counts toward `frame_perf.longFrames`. | | `batchSize` | `20` | Events buffered before an early network flush. | | `flushIntervalMs` | `5000` | Max time between network flushes. `0` disables the timer. | | `transport` | beacon → fetch | Custom delivery function (e.g. to observe sends). | | `disabled` | `false` | Collect nothing — e.g. to honor Do-Not-Track. | | `debug` | `false` | Console debug logs. | **How often events are sent.** Every connector batches all event types into one request and flushes on whichever comes first: the queue reaching `batchSize`, or `flushIntervalMs` elapsing (plus an immediate flush on page unload). Raise `flushIntervalMs` (or `batchSize`) to send fewer, larger requests; lower it for fresher data (e.g. live dashboards). These are forwarded to `@uptimizr/sdk-core` by `trackScene`, so they work identically across the Babylon, three.js, and PlayCanvas connectors. ## Advanced: custom client & `beforeSend` [Section titled “Advanced: custom client & beforeSend”](#advanced-custom-client--beforesend) `trackScene` is the one-call path. For a custom transport, a `beforeSend` hook (inspect / modify / **drop** each event — return `null` to drop), or registering multiple collectors on one session, build the `UptimizrClient` yourself: ```ts import { UptimizrClient } from "@uptimizr/sdk-core"; import { babylonCollector, readDeviceCaps, readSceneMeta } from "@uptimizr/babylon"; const client = new UptimizrClient({ projectId: "your-project-id", endpoint: "https://collect.example.com", beforeSend: (event) => (event.type === "pointer_move" ? null : event), // drop a channel, redact a field, sample }); client.use(babylonCollector({ scene })); client.start({ device: readDeviceCaps(scene), scene: readSceneMeta(scene) }); ``` `beforeSend` runs on every event after the envelope is filled in — it’s the right place to redact [error](/docs/guides/sessions/#error-capture) fields or sample a noisy channel. It is **not** exposed through `trackScene`; reach for the custom-client path when you need it. ## Privacy defaults [Section titled “Privacy defaults”](#privacy-defaults) No cookies, no persistent client id; the `sessionId` is in-memory only. Never put PII in `meta`, `track` props, or `user` (`user.id` must be pseudonymous/hashed). Per-session raw retention (needed for [replay](/docs/guides/replay/)) is opt-in on the collector via `ENABLE_RAW_SESSION_RETENTION=true`, **and** reading a raw stream additionally requires an API key holding the `query:raw` [capability](/docs/deploy/collector/#api-keys-and-capabilities) — a plain `query` key reads aggregates only. ## Collector-side keys, limits and auditing [Section titled “Collector-side keys, limits and auditing”](#collector-side-keys-limits-and-auditing) Beyond the SDK options above, the collector scopes what a key may do: [capabilities](/docs/deploy/collector/#api-keys-and-capabilities) (`query`, `query:raw`, `annotate`, `ingest`), an optional per-key request budget, `GET /api/v1/whoami` for a key to discover its own scope, and an [agent audit log](/docs/deploy/collector/#agent-audit-log) with `AUDIT_RETENTION_DAYS`. See the [self-hosting guide](/docs/deploy/collector/) for the environment variables and the `uptimizr new-key` CLI. # Custom events & input > Record your own domain events, capture keyboard/gamepad input actions, report capability fallbacks, and switch scenes within a session. The built-in channels cover pointer, camera, mesh, and performance. These APIs let your app contribute the rest of the picture — domain events, non-pointer input, capability fallbacks, and scene changes. All of them are methods on the client returned by `trackScene(...)`. ## Custom events [Section titled “Custom events”](#custom-events) Record your own discrete domain events. They’re always captured at 100% — never rate-limited. ```ts const client = trackScene(scene, { projectId, endpoint }); client.track("add_to_cart", { sku: "ABC-123", price: 49 }); ``` Keep the property map free of PII. Read per-type counts from `GET /api/v1/event-counts`. ## Input actions (keyboard, gamepad, XR) [Section titled “Input actions (keyboard, gamepad, XR)”](#input-actions-keyboard-gamepad-xr) Mouse and touch are captured automatically as pointer events. Discrete **input actions** from other devices — keyboard shortcuts, gamepad buttons, XR controller buttons — are recorded as `input_action` events. Each carries a semantic `action` label (what the input *did*) plus the originating `source` and the raw `code`/`button` token. Emit one explicitly whenever you handle a binding: ```ts // In your own keydown / gamepad handler: client.trackInput("next-camera", { source: "keyboard", code: "KeyN", pressed: true }); client.trackInput("jump", { source: "gamepad", button: 0 }); ``` `source` defaults to `"keyboard"`. These events are discrete and always captured at 100%. ### `keyBindings` allowlist [Section titled “keyBindings allowlist”](#keybindings-allowlist) The Babylon, three.js, and PlayCanvas connectors can capture bound keys for you. Pass `keyBindings` mapping a physical `KeyboardEvent.code` to an action label — **only** the listed keys are recorded (privacy-first), arbitrary typing is never captured, and auto-repeat is suppressed: ```ts trackScene(scene, { projectId, endpoint, keyBindings: { KeyW: "move-forward", KeyS: "move-back", Space: "jump" }, }); ``` three.js / PlayCanvas (and react-three-fiber via `@uptimizr/r3f`) accept the same option. They have no keyboard observable, so they listen on `window` — handy for pointer-lock / FPS scenes where the canvas rarely holds focus. ## Capability changes (fallbacks & recovery) [Section titled “Capability changes (fallbacks & recovery)”](#capability-changes-fallbacks--recovery) Rendering capability isn’t constant: some visitors run WebGPU, others fall back to WebGL2; weaker devices auto-downgrade quality/LOD; a lost GPU device may re-initialise at a different capability. These transitions otherwise look like unexplained noise in aggregate metrics. Engines decide their backend at init and expose no reliable runtime hook, so connectors do **not** auto-capture this — report it from your app whenever you perform a fallback or recovery: ```ts // after a WebGPU init fails and you fall back: client.reportCapabilityChange({ kind: "graphics-backend", from: "webgpu", to: "webgl2" }); // or a runtime quality/LOD auto-downgrade: client.reportCapabilityChange({ kind: "quality", from: "high", to: "low", reason: "low-fps" }); // or a completed XR tracking-degradation episode (ADR 0048): client.reportCapabilityChange({ kind: "tracking", from: "hand", to: "lost", reason: "signal-lost", source: "hand", handedness: "left", durationMs: 1200, }); ``` `kind` is one of `graphics-backend` / `quality` / `device-recovery` / `tracking` / `feature` / `other`; `from` / `to` / `reason` are optional, low-cardinality, app-defined tokens (never raw device strings or PII). The `tracking` kind additionally carries the input `source` / `handedness` that degraded and an optional `durationMs` (the completed degraded-episode length — one event per episode, emitted on recovery), which powers the tracking-quality timeline (`GET /api/v1/xr/tracking`). The Babylon connector reports coarse XR tracking loss/recovery automatically when a hand or controller drops out of the input registry mid-session (toggle via the XR capture `tracking` option, default on). This pairs with the raw [`context_lost` / `context_restored`](/docs/guides/sessions/#engine--browser-lifecycle-events) events — it’s the higher-level “what we ran as” signal. Read the rollup from `GET /api/v1/capabilities`. ## Rendering technology (always-on) [Section titled “Rendering technology (always-on)”](#rendering-technology-always-on) Each session’s `session_start.graphics` block records the rendering API surface (`api`), the real backend beneath it (`backend`, e.g. WebGPU → Metal), the API/driver version (`apiVersion`), and the `shadingLanguage` — captured once per session as non-PII, low-cardinality metadata (ADR 0021 / ADR 0046). It is **always-on**: every connector reports it, so no opt-in is needed. The dashboard’s **Rendering technology** panel aggregates the mix across the selected window — counts by API, backend, and shading language — backed by `GET /api/v1/rendering-technology`, the always-on sibling of the opt-in Engine diagnostics panel. Blank fields surface as “unknown”. ## Engine diagnostics (opt-in GPU health) [Section titled “Engine diagnostics (opt-in GPU health)”](#engine-diagnostics-opt-in-gpu-health) `graphics_diagnostic` carries engine-authored GPU-health signals — GPU errors/warnings, shader-compile/link failures, richer context-loss reasons, WebGPU `uncapturederror`, and sampled `gl.getError()` — in one engine-agnostic shape (`severity`, `category`, optional `backend`, length-capped `message`/`code`, and a `count` rollup-or-marker discriminator). It is **off by default** and gated by the `captureGraphicsDiagnostics` [option](/docs/guides/configuration/) — like `runtime_error`, the text can leak application IP, so you opt in and redact via `beforeSend`. The default emission is a rate-limited per-session rollup so an error storm can’t flood ingestion. `context_lost` / `context_restored` are exempt and stay always-on; engine-driven backend fallback stays in `capability_change` above. Once captured, these incidents surface in the dashboard’s **Engine diagnostics** panel — counts by severity, category, and backend — backed by `GET /api/v1/graphics-diagnostics`, which folds discrete markers and per-session rollups into one honest total. With capture off, the panel shows an explicit opt-in empty state rather than reading as broken. > **Wired today** in the Babylon (`@uptimizr/babylon`) and three (`@uptimizr/three`) connectors: WebGPU `device.lost` → `category: device-lost` (`info` for a requested loss, `reason: "destroyed"`; `fatal` otherwise; WebGL is a no-op — its interruption is the always-on `context_lost`); WebGPU `uncapturederror` → a rate-limited rollup (`category: validation` / `out-of-memory`, `count` + first `message`); WebGL/WebGPU **context-creation failure** → `category: context-loss` (`severity: fatal`, `backend: unknown` when undetermined; fires once at connector init and queues before the first flush); shader compile/link **failures** → `category: shader-compile` (`error`; WebGL info logs on failure, WebGPU shader-module compilation info); and sampled WebGL `gl.getError()` → `category: validation` (a low-rate rollup, never per-frame — it forces a sync GPU stall; no-op on WebGPU). Shader source can hide in the error log, so raw source is stripped unless the separate `captureShaderSource` sub-opt-in is set (off by default — application IP). All length-capped text runs through `beforeSend`. ## Spatial error heatmap (where errors happen) [Section titled “Spatial error heatmap (where errors happen)”](#spatial-error-heatmap-where-errors-happen) Both `runtime_error` and `graphics_diagnostic` carry an **optional** `position` — the best-effort camera position (`[x, y, z]`) at the moment the error or diagnostic fired. Connectors stamp it automatically: `@uptimizr/babylon` reads the tracked camera’s `globalPosition`; connectors that can’t resolve a camera simply omit it, and errors on pages without a 3D connector never carry one. Nothing is required of your app, and the field is additive — older events just omit it. With positions attached, the dashboard’s **Error heatmap (3D)** panel voxel-bins them into the same world-space grid as the pointer/gaze heatmaps, so you can see *where* in the scene things break — errors clustering around specific geometry, a shader-heavy area, or a level region — instead of only *when*. It’s backed by `GET /api/v1/heatmaps/errors`, which accepts optional `severity`/`category` filters (narrowing to engine diagnostics) and `errorKind` (narrowing to JS errors). Position reuses the existing promoted column shared with `camera_sample`, so it inherits the same privacy posture — no new PII surface. ## Guardian / boundary-touch heatmap (room-scale VR comfort) [Section titled “Guardian / boundary-touch heatmap (room-scale VR comfort)”](#guardian--boundary-touch-heatmap-room-scale-vr-comfort) In room-scale WebXR, visitors move inside a physical **guardian / play-space boundary**. When the headset comes within a short **near threshold** of that boundary, `@uptimizr/babylon`’s opt-in `babylonBoundaryCollector` emits an `xr_boundary_proximity` event — **one per approach** — carrying only a coarse voxel-binned `position` (the HMD position at the closest approach) and `durationMs` (how long the pose stayed inside the near zone). Count is implied by frequency, not a running counter. ```ts import { trackScene, babylonBoundaryCollector } from "@uptimizr/babylon"; const client = trackScene(scene, { projectId, endpoint }); // After you create the WebXR experience (bounded-floor reference space): client.use(babylonBoundaryCollector({ experience: xr, nearMeters: 0.5 })); ``` The recommended `nearMeters` default is **0.5 m** (with \~0.1 m exit hysteresis and \~100 ms sampling) — enough to catch a real reach-for-the-wall moment without firing on normal room-centre movement. > **Privacy (ADR 0003 / ADR 0048):** the boundary polygon and room geometry are **never transmitted**. The bounds check runs entirely on-device; only the outcome (position + duration) leaves the headset. `position` reuses the promoted world-space column, so it inherits the same privacy posture as the pointer/gaze/error heatmaps — no new PII surface. The dashboard’s **Boundary-touch heatmap (3D)** panel voxel-bins these positions into the same world-space grid as the pointer/gaze/error heatmaps (backed by `GET /api/v1/heatmaps/boundary`), and a **Guardian boundary contacts** panel lists per-session approach counts + near-zone time (backed by `GET /api/v1/xr/boundary-contacts`) as a comfort signal alongside the VR locomotion dashboard. ## Changing scenes / levels (`setScene`) [Section titled “Changing scenes / levels (setScene)”](#changing-scenes--levels-setscene) A single session can span multiple scenes, areas, or levels — game levels, a viewer swapping models, or a multi-room walkthrough. You do **not** stop and restart tracking when the visitor moves between them; you keep one client alive and mark the transition: ```ts const client = trackScene(scene, { projectId, endpoint, meta: { sceneId: "level-1" }, // initial scene/area }); client.setScene("level-2"); // when the next scene loads ``` See the [multi-scene experiences guide](/docs/guides/multi-scene/) for the full patterns (levels, viewers, rooms), per-scene querying, and how replay crosses scene changes. # MCP server (AI agents) > Let an AI agent answer natural-language questions about your 3D analytics with the @uptimizr/mcp server — events read-only, metadata writes behind the annotate capability. `@uptimizr/mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server over your collector’s query API — **read-only** unless the key you give it says otherwise. It lets an AI agent answer natural-language questions about your 3D analytics (“what was the most-clicked mesh this week?”) by querying **your own** collector — nothing is sent to any third party. It’s a thin wrapper: each analytics tool maps one-to-one to a documented [query endpoint](/docs/api/query/) and performs `GET` requests only. There is **no ingestion tool**, and nothing in the server can write, alter or delete an analytics event — **events are read-only**. The one exception is deliberate and gated: when the configured key holds the `annotate` capability, the server also registers the **project-metadata** tools `annotate`, `define_term` and `save_analysis` (plus `list_annotations`, `list_glossary`, `list_analyses`), which write notes, definitions and saved analyses through the [metadata endpoints](/docs/api/metadata/). The server asks `GET /api/v1/whoami` once at start-up, so a read-only key yields a read-only server — and every metadata write is recorded in the project’s agent audit log. The tool catalog itself lives in the framework-agnostic, browser-safe **`@uptimizr/agent-core`** package, which `@uptimizr/mcp` imports. That means the agent tool surface is defined **once** and shared by every consumer (the MCP server, the dashboard assistant, and the demo assistant), so they can never drift apart on capabilities. See [ADR 0050](https://github.com/RaananW/Uptimizr/blob/main/docs/adr/0050-in-browser-analytics-assistant.md). ## How it connects [Section titled “How it connects”](#how-it-connects) The MCP server talks **only to your collector’s HTTP query API** — it never opens the database (DuckDB/ClickHouse/Postgres) directly. The collector stays the single gateway to your data, so the same auth, scoping, and privacy rules apply whether a human uses the dashboard or an agent uses MCP: ```text AI agent ──stdio──▶ @uptimizr/mcp ──HTTPS GET + x-api-key──▶ collector ──▶ store (DuckDB / ClickHouse) ``` Because the collector resolves the project from the API key, an agent can only ever read **its own project’s** aggregated data — no cross-project access, no raw events, no PII (ADR 0003 / ADR 0017). The collector can also **host this same server itself** over Streamable HTTP, so a remote agent connects with a URL and a key instead of running the package locally — see [hosted transport](#hosted-transport-streamable-http). ## Get an API key [Section titled “Get an API key”](#get-an-api-key) The MCP server needs a project API key (`utk_…`) holding the **`query`** capability — and nothing else. Mint a **dedicated** one with the collector CLI; `query` is `new-key`’s default, so the `--capabilities` flag is not strictly required, but naming it keeps the intent in the shell history: ```bash npx -p @uptimizr/collector-server uptimizr new-key \ --capabilities query --label "mcp-agent" ``` Do **not** reuse the key `uptimizr init` printed. That one is the operator’s [owner key](/docs/deploy/collector/#api-keys-and-capabilities) — it also holds `query:raw` (raw per-session streams) and `annotate` (metadata writes), neither of which an MCP client needs. The key is printed **once** — store it where you keep secrets, not in a repo. Giving the agent its own labelled key is also what makes its activity legible: the [audit log](/docs/api/overview/#agent-audit-log) records activity per key id, so a labelled key is what makes “what did the agent ask for?” answerable, and a per-key budget (`--rate-limit-max 120 --rate-limit-window-ms 60000`) keeps an agent from spending the dashboard’s allowance. Confirm what a key holds before wiring it in: ```bash curl -H "x-api-key: utk_…" https://collect.example.com/api/v1/whoami ``` ```jsonc { "projectId": "3f2a…", "keyId": "9c41…", "capabilities": ["query"], "label": "mcp-agent", "rateLimit": { "max": 600, "windowMs": 60000 }, "rateLimitSource": "default", } ``` `query` is all this server needs: every tool in the default catalog is an aggregate read, so **`query:raw` is optional and off by default**. A key that does hold it gains exactly one more tool — `session_narrative`, the compacted account of one session — and only on a collector running with `ENABLE_RAW_SESSION_RETENTION`; the server has no replay or live-follow tool either way. An `ingest`-only key is refused with `403`. See [API keys and capabilities](/docs/api/overview/#api-keys-and-capabilities) for the full capability set. ## Run [Section titled “Run”](#run) ```bash UPTIMIZR_COLLECTOR_URL="https://collect.example.com" \ UPTIMIZR_API_KEY="utk_…" \ npx @uptimizr/mcp ``` No build or clone required — `npx` fetches the published package. Set the two environment variables to point at **your** collector; nothing is sent anywhere else. | Environment variable | Required | Notes | | ------------------------ | -------- | -------------------------------------------------- | | `UPTIMIZR_COLLECTOR_URL` | yes | Base URL of **your** collector. | | `UPTIMIZR_API_KEY` | yes | Your project API key (`x-api-key`), read-only use. | ## Configure an MCP client [Section titled “Configure an MCP client”](#configure-an-mcp-client) Most MCP clients launch the server over stdio with the same shape — a `command`, `args`, and the two `env` vars. Point `UPTIMIZR_COLLECTOR_URL` at a local collector (`http://localhost:4318`) for development or your deployed collector in production. ### Claude Desktop / VS Code / Cursor [Section titled “Claude Desktop / VS Code / Cursor”](#claude-desktop--vs-code--cursor) ```jsonc { "mcpServers": { "uptimizr": { "command": "npx", "args": ["-y", "@uptimizr/mcp"], "env": { "UPTIMIZR_COLLECTOR_URL": "https://collect.example.com", "UPTIMIZR_API_KEY": "utk_…", }, }, }, } ``` ### GitHub Copilot CLI [Section titled “GitHub Copilot CLI”](#github-copilot-cli) Add the same server to `~/.copilot/mcp-config.json` (create the file if it doesn’t exist), then restart the CLI so it loads the tools: ```jsonc { "mcpServers": { "uptimizr": { "type": "local", "command": "npx", "args": ["-y", "@uptimizr/mcp"], "env": { "UPTIMIZR_COLLECTOR_URL": "http://localhost:4318", "UPTIMIZR_API_KEY": "utk_…", }, "tools": ["*"], }, }, } ``` Once connected, ask in natural language: *“Using uptimizr, what were the most-clicked meshes this week and how’s the average FPS?”* — the agent picks the right tools and answers from your data. ## Tools [Section titled “Tools”](#tools) The catalog is **generated from the semantic metric registry** in `@uptimizr/metrics` ([ADR 0051](https://github.com/RaananW/Uptimizr/blob/main/docs/adr/0051-ai-first-analytics-layer.md)): every metric the collector serves on a read endpoint is a tool — **76** of them, of which a plain `query` key sees 75 (`session_narrative` needs `query:raw`) — so an agent sees the whole read surface rather than a hand-picked subset. Each tool’s description carries the metric’s interpretation notes and caveats (sample-size warnings, which capture channel has to be enabled), and each declares an MCP **output schema** covering every envelope the tool can answer with — the rows, the `table` envelope around them, or a `summary` digest — so a client can parse a result without guessing and validate it without the `format` it asked for being rejected. Most tools accept an optional time range (`since` / `until`, epoch ms) plus the filters their endpoint supports (`scene`, `session`, `source`, `bins`, `cellSize`, `limit`, `cameraMode`, `region`, …). `session_meta`, `session_trajectory` and `scene_representation` take a required id. The catalog is also **evaluated**, not just generated: a bank of \~48 real analytics questions is run against a deterministic fixture set through this exact tool surface on every change to it, and each answer is scored on tool selection, argument correctness and accuracy. That is what keeps the tool descriptions honest — every metric the collector serves has at least one question an agent is measured on. The harness lives in the repository at [`oss/packages/agent-eval`](https://github.com/RaananW/Uptimizr/tree/main/oss/packages/agent-eval). ### Result formats — the tools default to `table` [Section titled “Result formats — the tools default to table”](#result-formats--the-tools-default-to-table) For a large result — a 500-bin heatmap, a voxel cloud, a thousand-row list — bare rows are token-expensive and hard for a model to read, so **every generated aggregate tool accepts a `format` argument** that picks the envelope the rows arrive in ([result formats](/docs/api/query/#result-formats)): | `format` | What the tool returns | | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `table` | **The tools’ default.** `{ meta, rows }` — the same rows plus the metric, range, applied filters, sample size, row count, whether the cap truncated them, and the registry’s limits. | | `summary` | A bounded digest: top rows, a trend or merged spatial clusters, with shares, a sample size, the metric’s caveats and a templated `reading` sentence. | | `full` | The bare rows, and nothing else. | Omit `format` and you get `table`: the same rows you always got, plus the context to judge them — which metric answered, over what range, with which filters, and whether the row cap truncated the result. (The HTTP endpoints still default to `full`, so the dashboard is unaffected; the default is the *tool’s*, and it travels as an explicit `format=table` on the wire.) **Reach for `format=summary` whenever the result could be large.** It is capped at the metric’s `maxSummaryRows`, so a 500-bin heatmap costs the same as a 5-bin one, and the `reading` sentence and `caveats` come from the metric registry by pure code — no model is involved, so the same rows always produce the same words. Ask for `full` when you are post-processing the rows yourself and already know the sample is adequate. Narrowing with `limit`, `scene` and a tight `since`/`until` still helps on top of any format. All three validate against the tool’s advertised output schema, so a client that checks `structuredContent` against `tools/list` accepts whichever one you asked for. The two single-record reads (`session_meta`, `scene_representation`) are stored resources rather than aggregations, so they take no `format` and always answer with their `{ rows }` envelope. ### The `query` tool [Section titled “The query tool”](#the-query-tool) One tool is **not** per-metric: `query`, whose input is the [query DSL](/docs/api/query/#one-query-endpoint-the-query-dsl). It runs any metric below with any filter that metric declares, in one call: ```jsonc { "v": 1, "metric": "mesh_sources", "range": { "since": 1757000000000, "until": 1757600000000 }, "filters": { "scene": "lobby", "cameraMode": "first-person" }, "limit": 20, "format": "summary", } ``` `range` is required (both ends, epoch ms) and `format` defaults to `table` here rather than `full`. The grammar is closed — metrics, dimensions and filters are exactly the vocabulary `uptimizr://capabilities` lists — and naming something outside it returns an error whose `issues[].accepted` says what *would* have worked, so a wrong guess is a correction rather than an empty result. Three things it does that no per-metric tool can, and that a model will otherwise do badly in prose: * **`compare`** — give it another `{ range }` or `{ segment }` and the result comes back already joined on the dimension key as `{ current, previous, delta, deltaPct }`, with a significance test where the measure is a count and both windows are large enough. An agent should never run two queries and subtract them itself. * **`explain: true`** — the compiled plan instead of the rows, with `warnings` naming the reasons an answer might mislead: a capture channel that produced nothing in the window, a sample below the metric’s own minimum, a result cut off by `limit`. One call before reporting a zero. * **`drillQuery`** — every row of a `summary` carries the whole query narrowed to that row, ready to send straight back. `dimensions` can be any subset a metric declares when its measure is a portable count; a spatial heatmap or a percentile is computed at one fixed grain and refuses anything else by name. See the [query reference](/docs/api/query/#two-compilation-tiers) for which metrics are which. Reach for the per-metric tools for discovery, and for `query` when a question needs a filter the canned tool does not expose — and for anything that compares, explains or drills. ### Tool catalog [Section titled “Tool catalog”](#tool-catalog) One tool per registry metric that the collector serves on a read endpoint, grouped by the registry’s own categories. `uptimizr://capabilities` enumerates the same list at runtime with each tool’s grain, column units, limits and caveats. #### Sessions & scenes [Section titled “Sessions & scenes”](#sessions--scenes) | Tool | Endpoint | Returns | | ---------------------- | ---------------------------------------- | ---------------------- | | `list_sessions` | `/api/v1/sessions` | Recent sessions | | `session_meta` | `/api/v1/sessions/:id/meta` | Session descriptor | | `session_narrative` | `/api/v1/sessions/:id/narrative` | Session narrative | | `scene_representation` | `/api/v1/scenes/:sceneId/representation` | Scene representation | | `list_scenes` | `/api/v1/scenes` | Active scenes | | `timeseries` | `/api/v1/timeseries` | Event volume over time | | `event_counts` | `/api/v1/event-counts` | Counts per event type | #### Attention & heatmaps [Section titled “Attention & heatmaps”](#attention--heatmaps) | Tool | Endpoint | Returns | | ------------------------- | --------------------------------- | ----------------------------------- | | `pointer_heatmap` | `/api/v1/heatmaps/pointer` | 2D pointer heatmap | | `mesh_uv_heatmap` | `/api/v1/heatmaps/mesh-uv` | Per-mesh UV (texture-space) heatmap | | `world_heatmap` | `/api/v1/heatmaps/world` | 3D world-space pointer heatmap | | `world_heatmap_stats` | `/api/v1/heatmaps/world/stats` | World heatmap totals | | `gaze_heatmap` | `/api/v1/heatmaps/gaze` | World-space gaze heatmap | | `gaze_heatmap_stats` | `/api/v1/heatmaps/gaze/stats` | Gaze heatmap totals | | `camera_heatmap` | `/api/v1/heatmaps/camera` | View-direction heatmap | | `view_coverage_histogram` | `/api/v1/coverage/view-histogram` | 360° view-coverage histogram | | `mesh_dwell` | `/api/v1/meshes/dwell` | Per-object dwell / attention | | `mesh_blind_spots` | `/api/v1/meshes/blind-spots` | Blind spots / never-noticed meshes | | `hover_dwell` | `/api/v1/hover/dwell` | Hover hesitation per object | #### Navigation & coverage [Section titled “Navigation & coverage”](#navigation--coverage) | Tool | Endpoint | Returns | | -------------------- | ---------------------------------------- | ----------------------------------- | | `position_heatmap` | `/api/v1/heatmaps/position` | Floor-plan camera-position heatmap | | `session_trajectory` | `/api/v1/sessions/:sessionId/trajectory` | Session walked path | | `aggregate_paths` | `/api/v1/paths` | Aggregate desire-line paths | | `scene_coverage` | `/api/v1/coverage` | Scene coverage / dead zones | | `camera_distance` | `/api/v1/camera/distance` | Camera distance / zoom distribution | | `camera_gestures` | `/api/v1/camera-gestures` | Camera navigation gestures | | `navigation_stats` | `/api/v1/navigation` | Navigation effort per session | | `backtrack_ratio` | `/api/v1/backtrack` | Path retrace / backtracking | #### Meshes & interactions [Section titled “Meshes & interactions”](#meshes--interactions) | Tool | Endpoint | Returns | | ------------------------- | ---------------------------------- | ---------------------------------- | | `click_rays` | `/api/v1/heatmaps/click-rays` | View-gated click rays | | `flow_links` | `/api/v1/heatmaps/flow` | Gaze → mesh flow links | | `top_meshes` | `/api/v1/meshes/top` | Most-interacted meshes | | `mesh_sources` | `/api/v1/meshes/sources` | Mesh interactions by input source | | `mesh_trend` | `/api/v1/meshes/trend` | Per-mesh interaction trend | | `mesh_interaction_kinds` | `/api/v1/meshes/kinds` | Interaction kinds per mesh | | `mesh_reachability` | `/api/v1/meshes/reachability` | Mesh reachability by distance | | `dead_clicks` | `/api/v1/clicks/dead` | Dead-click rate | | `rage_clicks` | `/api/v1/clicks/rage` | Rage-click clusters | | `interaction_sources` | `/api/v1/interactions/sources` | Interactions by input source | | `top_input_actions` | `/api/v1/input-actions/top` | Most-used shortcuts and actions | | `custom_event_vocabulary` | `/api/v1/vocabulary/custom-events` | Discovered custom-event vocabulary | #### Performance & stability [Section titled “Performance & stability”](#performance--stability) | Tool | Endpoint | Returns | | ------------------------ | ----------------------------------- | ---------------------------------- | | `perf_summary` | `/api/v1/perf` | Rendering performance summary | | `render_scale_truth` | `/api/v1/perf/render-scale` | Render-scale truth | | `perf_distribution` | `/api/v1/perf/distribution` | FPS distribution (per-session) | | `fps_histogram` | `/api/v1/perf/fps-histogram` | Per-session median-FPS histogram | | `frame_time_percentiles` | `/api/v1/perf/frame-time` | Frame-time percentiles | | `jank_rate` | `/api/v1/perf/jank` | Jank rate | | `perf_churn` | `/api/v1/perf/churn` | Perf-correlated churn | | `perf_by_device` | `/api/v1/perf/by-device` | FPS by device class | | `perf_by_scene` | `/api/v1/perf/by-scene` | FPS by scene | | `perf_heatmap` | `/api/v1/heatmaps/perf` | Spatial FPS heatmap | | `compile_stalls` | `/api/v1/perf/compile-stalls` | Shader / pipeline compile stalls | | `resource_summary` | `/api/v1/perf/resources` | GPU / memory footprint summary | | `resource_percentiles` | `/api/v1/perf/resource-percentiles` | GPU / memory footprint percentiles | | `rendering_technology` | `/api/v1/rendering-technology` | Rendering-technology mix | #### Errors & diagnostics [Section titled “Errors & diagnostics”](#errors--diagnostics) | Tool | Endpoint | Returns | | ---------------------- | ------------------------------ | --------------------------------- | | `stability_counts` | `/api/v1/perf/stability` | Stability incidents | | `graphics_diagnostics` | `/api/v1/graphics-diagnostics` | Engine diagnostic counts | | `error_heatmap` | `/api/v1/heatmaps/errors` | Spatial error heatmap | | `capability_changes` | `/api/v1/capabilities` | Capability / fidelity transitions | #### WebXR [Section titled “WebXR”](#webxr) | Tool | Endpoint | Returns | | ------------------------ | --------------------------------- | --------------------------------- | | `xr_rotation` | `/api/v1/xr/rotation` | XR head-rotation rate | | `xr_sources` | `/api/v1/xr/sources` | XR input-source usage | | `xr_abandonment` | `/api/v1/xr/abandonment` | XR session abandonment | | `xr_locomotion` | `/api/v1/xr/locomotion` | XR locomotion & comfort | | `xr_tracking_quality` | `/api/v1/xr/tracking` | XR tracking quality | | `boundary_heatmap` | `/api/v1/heatmaps/boundary` | Guardian / boundary-touch heatmap | | `boundary_heatmap_stats` | `/api/v1/heatmaps/boundary/stats` | Boundary heatmap totals | | `xr_boundary_contacts` | `/api/v1/xr/boundary-contacts` | Boundary contacts per session | #### WebXR AR placement [Section titled “WebXR AR placement”](#webxr-ar-placement) | Tool | Endpoint | Returns | | ---------------------------- | ------------------------------------ | ----------------------------- | | `ar_placement_time_to_place` | `/api/v1/ar/placement/time-to-place` | AR time-to-place distribution | | `ar_placement_attempts` | `/api/v1/ar/placement/attempts` | AR re-placement distribution | | `ar_placement_surfaces` | `/api/v1/ar/placement/surfaces` | AR placement surfaces | #### Funnels & conversion [Section titled “Funnels & conversion”](#funnels--conversion) | Tool | Endpoint | Returns | | --------------------- | ----------------------------- | -------------------------------- | | `funnel` | `/api/v1/funnel` | Conversion funnel | | `scene_retention` | `/api/v1/scene-retention` | Scene-to-scene retention | | `load_bounce_funnel` | `/api/v1/load-bounce` | Load → bounce funnel | | `variant_leaderboard` | `/api/v1/variant-leaderboard` | Variant → conversion leaderboard | #### Insights & anomalies [Section titled “Insights & anomalies”](#insights--anomalies) | Tool | Endpoint | Returns | | ---------------------- | ------------------------------- | ------------------------ | | `insight_baseline` | `/api/v1/insights/baseline` | Metric baseline | | `insight_movers` | `/api/v1/insights/movers` | What changed | | `insight_anomalies` | `/api/v1/insights/anomalies` | Anomalous buckets | | `insight_significance` | `/api/v1/insights/significance` | Statistical significance | | `insight_scene_health` | `/api/v1/insights/scene-health` | Scene health score | ## Resources [Section titled “Resources”](#resources) The server also exposes read-only [MCP resources](https://modelcontextprotocol.io/docs/concepts/resources) so an agent can **self-discover** what it can ask instead of guessing: | Resource URI | Type | Contents | | ------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `uptimizr://capabilities` | `application/json` | A machine-readable descriptor: schema version, the canonical **event types**, the **tool catalog**, the **parameter semantics** glossary, and `metrics` — the collector’s whole [semantic metric registry](#the-metric-registry). No collector call. | | `uptimizr://context` | `application/json` | **Read this first.** The live [project context document](/docs/api/context/): the scenes and their named regions, the custom events this application emits and the props they carry, data freshness and retention flags, the store engine, and which metrics are empty because their capture channel is off. | | `uptimizr://scenes` | `application/json` | The **live** list of scene ids with recent activity — the valid values for the `scene` parameter. Fetched via the read-only query API. | | `uptimizr://skills` | `application/json` | The packaged **methodology skills** (below): what each investigation produces, when to use it, the tools its method names and the arguments it takes. The method itself comes from `prompts/get`. No collector call. | Point an agent at `uptimizr://context` first: it is the only one that describes **this** project — the real scene ids, region ids and custom-event names it must use, and which metrics cannot have data. `uptimizr://capabilities` is the companion: it enumerates every tool, its parameters, and what each parameter means, so the agent can plan a query without trial and error. Every packaged skill below opens by telling the agent to read the context. ### The metric registry [Section titled “The metric registry”](#the-metric-registry) `uptimizr://capabilities` carries a `metrics` array — the collector’s **semantic metric registry** serialised for agents. For every metric it gives: | Field | What it tells an agent | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `grain` | What **one row** is: a project, a scene, a session, a mesh, a bin, a voxel, a bucket. | | `columns` | Per-column description and **unit** (`ms`, `fps`, `count`, `ratio`, `world-units`, …), plus which column is the measure to rank by and which names the row. | | `row` | The **JSON Schema** of a result row, so a client with no Zod can still validate or shape it. | | `filters` / `dimensions` | The parameters it accepts and the dimensions its rows are keyed by. | | `limits` | The registry-declared row caps, so nothing asks for an unbounded payload. | | `interpretation` | How to read the result — what a high or low value actually means. | | `caveats` | Small-sample, sampling-rate and capture-gating warnings. **Read these before quoting a number.** | | `sourceChannels` | The capture channels ([ADR 0012](https://github.com/RaananW/Uptimizr/blob/main/docs/adr/0012-sampling-and-fidelity.md)) that feed it — if a channel is off, the metric is empty by design, not by accident. | | `related` / `comparable` | Metrics worth reading alongside it, and which column’s change is “the” change. | ## OpenAPI [Section titled “OpenAPI”](#openapi) The collector serves an **OpenAPI 3.1** description of its read API — no key required, because it is documentation and contains no project data: ```bash curl https://collect.example.com/api/v1/openapi.json ``` It is generated from the same metric registry, so it lists exactly the aggregations that collector can compute: one path per endpoint, every parameter with the schema that actually validates it, and a `200` response schema per metric. The semantics OpenAPI has no vocabulary for ride along as vendor extensions on each operation — `x-uptimizr-grain`, `x-uptimizr-units`, `x-uptimizr-caveats`, `x-uptimizr-interpretation`, `x-uptimizr-source-channels`, `x-uptimizr-limits`, `x-uptimizr-dimensions`, `x-uptimizr-related` and `x-uptimizr-comparable`. That makes the collector consumable by anything that speaks OpenAPI without MCP at all — generate a typed client, point an API explorer at it, or hand the document to an agent framework: ```bash npx openapi-typescript https://collect.example.com/api/v1/openapi.json -o collector.d.ts ``` Authenticate ordinary calls with the `apiKey` security scheme the document declares: the `x-api-key` header, using a key with the `query` capability. ## Prompts — the packaged methodology skills [Section titled “Prompts — the packaged methodology skills”](#prompts--the-packaged-methodology-skills) Curated [MCP prompts](https://modelcontextprotocol.io/docs/concepts/prompts) package common investigations as one-click templates. Each renders a message that steers the agent to call the right read-only tools in a sensible order — the agent runs the tools; the prompt frames the task and supplies the **method**. They are not written here. Each one is a packaged **skill** — an Agent Skills file, `skills//SKILL.md`, shipped inside both the `@uptimizr/mcp` and `@uptimizr/agent-core` tarballs and compiled into the server at build time. The same files back `uptimizr agent report --skill` on your collector and the starter prompts in the [in-browser assistant](/docs/guides/assistant/), so a scheduled report and a chat session run the same investigation. Open one to read (or fork) the method: ```bash cat node_modules/@uptimizr/mcp/skills/weekly-scene-health/SKILL.md ``` `*` marks a required argument. | Skill | Arguments | What it produces, and when to use it | Tools its method names | | ------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `attention_hotspots` | `scene*`, `range?` | Find where visitors look and click in a scene: view-direction concentration, gaze→mesh flow, the objects that draw the most interaction, and the ones nobody ever notices. USE FOR: deciding where to put a call to action, finding ignored or invisible content, explaining why an object gets no clicks, laying out a scene around what people actually look at. | `camera_heatmap`, `flow_links`, `click_rays`, `top_meshes`, `mesh_dwell`, `mesh_blind_spots`, `query` | | `conversion_investigation` | `scene?`, `range?` | Find out where a funnel loses people and whether the loss is real: step-by-step drop-off, the bounce that happens before the funnel even starts, scene-to-scene retention, variant performance, and the interaction failures (dead clicks, rage clicks, unreachable meshes) that explain a stalled step. USE FOR: a funnel that converts worse than expected, an A/B variant comparison, “where do people drop off”, diagnosing a step nobody completes. | `funnel`, `load_bounce_funnel`, `scene_retention`, `variant_leaderboard`, `dead_clicks`, `rage_clicks`, `mesh_reachability`, `flow_links`, `insight_significance`, `insight_movers`, `query` | | `performance_regression_triage` | `scene?`, `range?` | Triage a frame-rate or stability regression: confirm it moved, date it, locate it (which scene, device class, place in the scene), and name the mechanism — jank, shader compile stalls, memory pressure, a render-scale change or a rendering-technology shift. USE FOR: “the app got slower”, a FPS drop after a release, stutter reports, deciding whether a regression is real or noise. | `insight_movers`, `insight_anomalies`, `insight_significance`, `insight_baseline`, `perf_summary`, `perf_distribution`, `frame_time_percentiles`, `jank_rate`, `perf_by_device`, `perf_by_scene`, `perf_heatmap`, `compile_stalls`, `resource_percentiles`, `render_scale_truth`, `rendering_technology`, `query` | | `weekly_scene_health` | `scene?`, `range?` | A weekly health check for a scene (or the whole project): a weighted health score with every factor traced back to the metric behind it, what changed against last week, traffic, event mix, performance, and the most-interacted meshes. USE FOR: the recurring “how is the scene doing?” review, a scheduled weekly or monthly report, a first look at a project you do not know yet, deciding which scene to investigate next. | `insight_scene_health`, `insight_movers`, `insight_baseline`, `insight_significance`, `insight_anomalies`, `event_counts`, `timeseries`, `perf_summary`, `top_meshes`, `list_sessions`, `query` | | `xr_comfort_audit` | `scene?`, `range?` | Audit VR/AR comfort for a scene (or the whole project): rapid head rotation, locomotion style, tracking quality, guardian/boundary contacts, input-source mix, and the short sessions that mean someone took the headset off. USE FOR: motion-sickness complaints, immersive sessions that end early, choosing a locomotion scheme, checking whether a play space is big enough. | `xr_rotation`, `xr_locomotion`, `xr_abandonment`, `xr_sources`, `xr_tracking_quality`, `xr_boundary_contacts`, `boundary_heatmap_stats`, `insight_scene_health`, `insight_movers`, `query` | The catalog is also readable as the `uptimizr://skills` resource above, for a client whose UI has no prompt picker. ## Leaving something behind (the `annotate` tools) [Section titled “Leaving something behind (the annotate tools)”](#leaving-something-behind-the-annotate-tools) Read tools answer a question; these keep the answer. They appear in `tools/list` only when the key you configured holds the `annotate` capability. | Tool | What it does | | ------------------ | ------------------------------------------------------------------------------------------------------ | | `annotate` | Pin a note to the project, a scene, a mesh, a region, a metric or a period of time. | | `define_term` | Record what a name means in this project. Idempotent — defining it again replaces the meaning. | | `save_analysis` | Store a titled question plus the conclusion drawn from it. | | `list_annotations` | Read the notes already left — worth doing **before** explaining a spike someone has already explained. | | `list_glossary` | Read the project’s vocabulary before interpreting mesh names, scene ids or custom events. | | `list_analyses` | Read questions this project has asked before, and what they concluded. | Mint the key with the capability: ```bash uptimizr new-key --capabilities query,annotate --label "weekly-report-agent" ``` Without it the server starts read-only and never offers the tools; with it, every write is bounded at the collector’s edge and recorded in the agent audit log. They write **metadata only** — see [Metadata endpoints](/docs/api/metadata/) for the shapes, the bounds and the privacy note. ## Hosted transport (Streamable HTTP) [Section titled “Hosted transport (Streamable HTTP)”](#hosted-transport-streamable-http) Everything above runs the MCP server **next to the client**, over stdio. The collector can also **host** the very same server itself, over the MCP [Streamable HTTP](https://modelcontextprotocol.io/specification/basic/transports) transport at `/mcp` — so a remote or containerised agent connects with a **URL and a key**, with no `npx` step and nothing installed on the client machine ([ADR 0051](https://github.com/RaananW/Uptimizr/blob/main/docs/adr/0051-ai-first-analytics-layer.md) §7, which resolves the transport ADR 0050 §7 deferred pending auth). ```text AI agent ──HTTPS POST/GET /mcp + x-api-key──▶ collector ─(in-process)─▶ query API ──▶ store ``` Both transports serve an **identical** surface — the same tools, the same resources, the same prompts — because both are built by the same factory in `@uptimizr/mcp`. Pick stdio for a laptop pointed at a local collector, and the hosted transport when the agent is not on the same machine as the client, or when you would rather not distribute a key into a desktop config. ### Turn it on [Section titled “Turn it on”](#turn-it-on) It is **off by default**: an extra authenticated, long-lived surface is something an operator opts into. Set one environment variable on the collector and restart it: ```bash COLLECTOR_MCP_HTTP=1 ``` | Environment variable | Default | Notes | | ------------------------------ | --------- | ------------------------------------------------------------------------ | | `COLLECTOR_MCP_HTTP` | off | `1`/`true` serves MCP at `/mcp`. Unset → the route does not exist. | | `COLLECTOR_MCP_MAX_SESSIONS` | `50` | Concurrent MCP sessions. One too many is refused with `503`. | | `COLLECTOR_MCP_SESSION_TTL_MS` | `1800000` | Idle timeout before a session is closed and its slot reclaimed (30 min). | See [deploying the collector](/docs/deploy/collector/#hosted-mcp-streamable-http) for the reverse-proxy requirements — chiefly that the proxy must **not buffer** the response. ### Authenticate [Section titled “Authenticate”](#authenticate) Every request is authenticated; the session id is never a credential on its own. Send the same project API key the stdio server uses, as either header: * `x-api-key: utk_…` — the collector’s own header, or * `Authorization: Bearer utk_…` — the form MCP clients send, accepted as an alias on `/mcp` only. The key must hold `query`. A missing or unknown key is `401`, a key without `query` (an `ingest`-only key, say) is `403`, and a session may only ever be driven by the key that opened it — so a leaked session id buys nothing on its own. Mint a dedicated, labelled key exactly as for stdio: ```bash npx -p @uptimizr/collector-server uptimizr new-key \ --capabilities query --label "mcp-remote" ``` ### Configure an MCP client [Section titled “Configure an MCP client”](#configure-an-mcp-client-1) Clients that support remote servers take a URL and a header map. Claude Desktop, VS Code and Cursor all accept this shape: ```jsonc { "mcpServers": { "uptimizr": { "type": "http", "url": "https://collect.example.com/mcp", "headers": { "Authorization": "Bearer utk_…", }, }, }, } ``` Some client versions spell the transport `"transport": "http"` (or `"streamable-http"`) rather than `"type"`, and a client that cannot send a custom `Authorization` header can use `"x-api-key"` in the same `headers` map instead. Clients with no remote support keep using the stdio block [above](#configure-an-mcp-client) — the same tools either way. Verify from a shell before wiring a client in; a successful `initialize` returns the session id in the `Mcp-Session-Id` response header: ```bash curl -sS -D- -o/dev/null https://collect.example.com/mcp \ -H "x-api-key: utk_…" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' ``` ### What the collector does with a session [Section titled “What the collector does with a session”](#what-the-collector-does-with-a-session) * **One MCP server per session**, built with the capability set the key resolved to, so a session’s surface can only narrow to what its key may actually do. * **Tool calls run the ordinary read path.** The collector answers a tool call by dispatching to its own query route **in process** — no loopback socket, no second TLS hop — so a tool call and the equivalent `curl` are answered by the same handler, with the same validation, the same project scoping and the same result envelope. * **Rate limits apply per key**, exactly as for HTTP reads, including a key’s own `--rate-limit-max` budget. The inner read is not charged a second time. * **Audit rows are tagged `mcp-http`**, so [the audit log](/docs/api/overview/#agent-audit-log) tells a hosted-MCP tool call apart from a plain HTTP read. The stdio server is an ordinary HTTP client of the collector, so its calls are recorded as `http`: the surface records how a request reached the collector, not which program made it. * **`DELETE /mcp`** ends a session, and an idle one is reclaimed after `COLLECTOR_MCP_SESSION_TTL_MS`. Sessions live in the collector process, so if you run several collector instances behind a load balancer, pin MCP traffic to one instance (sticky sessions) or point the client at a single instance’s URL. ## Programmatic use [Section titled “Programmatic use”](#programmatic-use) The package also exports its building blocks for embedding in your own server: ```ts import { createCollectorClient, createMcpServer, readMcpConfig } from "@uptimizr/mcp"; ``` The tool catalogs and the collector client come from the framework-agnostic [`@uptimizr/agent-core`](https://www.npmjs.com/package/@uptimizr/agent-core) package (re-exported here for convenience). If you’re building a non-MCP agent — a browser assistant, a Node service, a CLI, a bot — depend on `@uptimizr/agent-core` directly: it also ships a headless LLM provider-adapter interface and tool-calling loop over the same catalog. # Mesh & object tracking > Capture which meshes users interact with, look at, and hesitate over — plus scene actors that move on their own. Uptimizr can attribute interaction and attention to the **objects** in your scene, not just to screen coordinates. There are four distinct mechanisms, from always-on to fully opt-in. ## Mesh interactions (`mesh_interaction`) — on by default [Section titled “Mesh interactions (mesh_interaction) — on by default”](#mesh-interactions-mesh_interaction--on-by-default) Every click/tap that hits geometry emits a `mesh_interaction` carrying the mesh name and the world hit point. This powers the **most-interacted meshes** view. It’s on by default; disable with `capture.meshPicks: false`. Read it back via `GET /api/v1/meshes/top`: ```http GET /api/v1/meshes/top?since=...&limit=20 x-api-key: your-project-api-key ``` Caution Mesh attribution relies on your meshes having **stable names**. Procedurally-generated or clone-suffixed names (`Box.001`, `Box.002`, …) fragment the ranking. Give interactive meshes deliberate, stable names. ## Texture-space attention (`uv`) — automatic [Section titled “Texture-space attention (uv) — automatic”](#texture-space-attention-uv--automatic) Which *region of a single mesh* draws the eye? When a pointer/gaze ray hits geometry, the connector reads the hit’s **UV (texture) coordinate** from the raycast result (Babylon `PickingInfo.getTextureCoordinates()`) and attaches it as an optional `uv: [u, v]` on `pointer_click`, `mesh_interaction`, and `hover_dwell`. It’s the per-mesh, texture-space companion to the world-space heatmap — think “which part of the product label do people click”. There’s nothing to configure: `uv` rides the existing `meshPicks` / `clicks` / `hoverDwell` channels and is simply omitted when the engine can’t resolve a coordinate (a mesh with no UVs, a miss, or a connector that doesn’t expose it yet). Values are **not clamped** to `[0, 1]` — they follow the engine under texture wrapping/tiling. The field rides in the event `payload`, so there’s no schema migration. Read a per-mesh UV grid back via `GET /api/v1/heatmaps/mesh-uv` (the `mesh` param is required): ```http GET /api/v1/heatmaps/mesh-uv?mesh=product-hero&bins=40&since=... x-api-key: your-project-api-key ``` It returns the same `{ gx, gy, count }` bins as the 2D pointer heatmap, and the dashboard’s **Mesh UV heatmap** panel renders it with an interactive mesh picker (defaulting to the most-interacted mesh). ## Object dwell (`meshVisibility`) — opt-in [Section titled “Object dwell (meshVisibility) — opt-in”](#object-dwell-meshvisibility--opt-in) How long was an object actually **on screen**, and how long did the viewer **look at** it? Off by default for privacy. When enabled, the connector emits one **bucketed** `mesh_visibility` summary per tracked object per window (never per frame): ```ts trackScene(scene, { // ... meshVisibility: { windowMs: 5000, // one summary per object every 5 s (default) meshes: ["product-hero"], // allowlist; omit to track all visible meshes maxMeshes: 50, // cap when no allowlist is given (default) centeredAngleDeg: 12, // "looking at it" half-angle (default) boundingBox: true, // ride each object's world AABB along (off by default) }, }); ``` Each summary carries `visibleMs` (time in view), `centeredMs` (time within `centeredAngleDeg` of the camera-forward axis), and `maxScreenFraction` (peak apparent size, 0–1). With `boundingBox: true`, a summary may also carry `bounds` — the object’s world-space AABB `[minX, minY, minZ, maxX, maxY, maxZ]`. It’s sent **once per object** and re-sent only when it moves/resizes, so the dashboard can render a coarse “ghost” of the scene (one box per object) and lay dwell heat on it **without** your real geometry. Off by default: it discloses scene layout. ## Hover hesitation (`hover_dwell`) — opt-in [Section titled “Hover hesitation (hover_dwell) — opt-in”](#hover-hesitation-hover_dwell--opt-in) Where do pointer users **hover and hesitate** without clicking? High dwell with few interactions is the “this looks interactive but isn’t (obviously) clickable” signal. Off by default: ```ts trackScene(scene, { // ... capture: { hoverDwell: true }, hoverDwell: { minDwellMs: 500, // ignore pass-overs shorter than this (default) meshes: ["product-hero"], // allowlist; omit to track every hovered mesh }, }); ``` One **bucketed** `hover_dwell` summary is emitted per hover *episode* (never per frame). An episode ends when the pointer moves to a different object (or off all geometry), and is **suppressed if the object was clicked** during the hover — a click is an action, not hesitation. Each event carries `mesh`, `dwellMs`, and the originating input `source`. ## Blind spots — never-noticed meshes [Section titled “Blind spots — never-noticed meshes”](#blind-spots--never-noticed-meshes) Which objects **render but nobody notices**? The blind-spots report cross-references what was *seen* (`mesh_visibility`) against what was *engaged with* (`mesh_interaction` + `hover_dwell`), surfacing meshes with lots of on-screen time but little or no interaction — the inverse of the most-interacted / part-popularity leaderboards. A retail engraving nobody spots, or a game prop/room that renders but nobody investigates (wasted art budget, or a missed secret area). It needs **no extra capture** beyond the two opt-in signals above: enable object dwell (`meshVisibility`) so meshes are recorded as visible, and optionally hover hesitation (`hoverDwell`) so hovers count as engagement. `mesh_interaction` is on by default. Read it back via `GET /api/v1/meshes/blind-spots`: ```http GET /api/v1/meshes/blind-spots?since=...&limit=25 x-api-key: your-project-api-key ``` Each row carries `visible_ms` + `vis_samples` (on-screen time), `interactions` (active picks/hovers), and `hover_ms` + `hover_episodes` (hesitation). Rows are ranked engagement-ascending then visibility-descending, so the most-seen-yet-least-touched meshes come first. Only meshes that were actually visible (`visible_ms > 0`) appear. The dashboard renders this as the **Blind spots** panel. ## Scene actors (`node_transform`) — opt-in [Section titled “Scene actors (node_transform) — opt-in”](#scene-actors-node_transform--opt-in) Replay re-drives the visitor’s own inputs, but scenes often contain objects that move on their **own** — an ambient NPC, a sliding door, an elevator, a vehicle, a rigged character’s wave. Those are driven by your animation/AI/physics loop, so by default the session has no memory of where they were. Opt in to record them as `node_transform` events; [replay](/docs/guides/replay/) **re-applies** (does not re-simulate) their motion. Capture is **off by default** and **allowlisted** — there is no “track everything” switch. Declare a stable `nodeId` → engine-node mapping once via `actors`, then dial each actor under `sampling`: ```ts trackScene(scene, { // ... actors: { "npc-guard": () => scene.getMeshByName("Guard_root"), // resolver (preferred — robust to load order/clones) elevator: "Elevator.001", // engine name/id string "showroom-door": doorMeshRef, // direct engine ref }, sampling: { // Tier 1 — node/root transform (world frame): locomotion + heading. nodes: { "npc-guard": 10, // Hz elevator: "frame", }, // Tier 2 — skeleton bones (opt-in, skeleton-local; Babylon, three, PlayCanvas). bones: { "npc-guard": { include: ["mixamorig:RightHand", "mixamorig:LeftHand"], hz: 30 }, // include: "*" => full rig (explicitly expensive); omit => no bone capture }, }, }); ``` `sampling.nodes` / `sampling.bones` keys MUST reference ids declared in `actors`; an unknown id is a no-op with a dev-mode warning. Tier-1 transforms are sampled in the canonical **world** frame; Tier-2 bone transforms are **skeleton-local** (the only frame portable across differing world placements of the same rig). Idle suppression applies — a static actor or unmoving bone emits nothing. ### Engine support [Section titled “Engine support”](#engine-support) `actors` is engine-typed: the resolver returns that engine’s node type — Babylon `AbstractMesh | TransformNode | null`, three `Object3D | null`, PlayCanvas `Entity | null`. Tier-2 bone capture works on Babylon, three (`SkinnedMesh.skeleton.bones`), and PlayCanvas (`skinInstance.bones`). The `babylon-lite` connector supports **Tier 1 only** (no named-bone API). ### What may be tracked [Section titled “What may be tracked”](#what-may-be-tracked) The mechanism is “any node that exposes a world transform,” but the trackable set is a closed, normative list: | Category | Status | Notes | | ------------------------------------------ | -------------------------------- | -------------------------------------------------------------- | | Meshes / skinned-mesh root | In scope (Tier 1) | The common case; root transform = locomotion/heading. | | Transform-only nodes / groups / pivots | In scope (Tier 1) | Often preferred — one stream drives a whole parented assembly. | | Skeleton bones | In scope (Tier 2, opt-in) | Per-bone allowlist; skeleton-local; needs matching rig. | | Moving lights | Allowed, default **OFF** | Replay only matches if the target scene has the same light. | | Non-active cameras | Allowed, default **OFF** (niche) | Track its parent transform; rarely worth it. | | The active / visitor camera | **Excluded** | Already captured as `camera_sample`; connectors refuse it. | | Particle systems | Out of scope | GPU/simulation-driven, no per-node transform. | | Morph targets / blend shapes | Out of scope | Driven by weight scalars, not a transform. | | Instanced meshes / thin-instances / crowds | Out of scope (v1) | N transforms in one node; needs an `instanceId` dimension. | The active/visitor camera and particle/morph/instance targets are rejected with a dev-mode warning. ## See also [Section titled “See also”](#see-also) * [Performance & diagnostics](/docs/guides/performance/#world-space-gaze) — world-space **gaze** attributes attention to whatever geometry the camera-forward ray hits. * [Session replay](/docs/guides/replay/) — re-drive recorded actors (`nodes` map / `onNodeTransform`). # Multi-scene experiences > Track game levels, multi-room walkthroughs, and viewers that swap models — all within a single, replay-complete session. Real 3D apps rarely show one fixed scene. A game moves the player between **levels**; a configurator or product viewer **swaps models** in and out; an architectural walkthrough crosses **rooms or floors**. Uptimizr treats all of these as *scenes within one session* — you keep a single tracker running and tell it when the active scene changes. ## Why one session, not many [Section titled “Why one session, not many”](#why-one-session-not-many) Stopping and restarting tracking on every level/model change would fragment the visit into disconnected sessions and **break replay continuity** (the timeline must stay ordered and `sessionId`-keyed). Instead, keep one `trackScene(...)` client alive for the whole visit and mark the transitions. Each scene becomes a **segment** of the same session, so you can: * compare heatmaps, dwell, and performance **per level / per model**, and * still replay the entire visit end to end as one ordered stream. ## The two calls [Section titled “The two calls”](#the-two-calls) Set the **initial** scene id when you start, then call `setScene(id)` every time the active scene changes. ```ts const client = trackScene(scene, { projectId, endpoint, meta: { sceneId: "level-1" }, // initial scene / area / model }); // Later, when the next scene loads: client.setScene("level-2"); ``` `setScene` emits an ordered `scene_change` marker and stamps the new `sceneId` onto **every subsequent event**, so gaze/click heatmaps, mesh interactions, performance, and custom events are all attributable to the scene that was active when they happened. Filter any read endpoint by the `scene` query param to get per-scene results. ## Patterns [Section titled “Patterns”](#patterns) * Game levels Call `setScene` from your level-loaded hook, after the new level is live: ```ts async function loadLevel(id: string) { await engine.loadLevel(id); client.setScene(id); // e.g. "level-3", "boss-arena" } ``` Use stable, low-cardinality ids (the level’s slug), not a per-playthrough guid — that keeps aggregates meaningful across visitors. * Viewer / configurator When the visitor switches the displayed model, treat each model as a scene: ```ts function showModel(sku: string) { viewer.load(sku); client.setScene(sku); // e.g. "sku-chair-walnut" } ``` Now each model has its own view-direction and click heatmaps even though they share one canvas and one session. * Rooms / floors For a continuous walkthrough, fire `setScene` on the trigger that marks entering a new area (portal volume, teleport, floor change): ```ts onRoomEnter((room) => client.setScene(room.id)); // "lobby", "gallery-2", "roof" ``` ## Querying per scene [Section titled “Querying per scene”](#querying-per-scene) Most read endpoints accept a `scene` filter, so you can scope an aggregate to a single level/model/area. Reads are scoped to the project behind your `x-api-key` — there is no `projectId` query param: ```bash curl "$ENDPOINT/api/v1/meshes/dwell?scene=level-2" \ -H "x-api-key: $KEY" ``` Omit the filter to see the whole session across all scenes, and use `/api/v1/scenes` to list the distinct scenes that have activity. See the [query endpoints](/docs/api/query/) for the full parameter list. ## Naming places inside a scene: regions [Section titled “Naming places inside a scene: regions”](#naming-places-inside-a-scene-regions) A `sceneId` names a whole area; a **region** names a place *inside* one — “the entrance”, “the checkout counter”. A region is a labelled axis-aligned box in the scene’s world space, stored next to the scene proxy, and it does two things: it gives dashboards, summaries and agents a shared vocabulary instead of raw coordinates, and it lets any spatial endpoint be drilled into that place by name with `?region=`. Declare a scene’s regions once (they replace the stored set, so leaving one out removes it): ```ts import { registerRegions } from "@uptimizr/sdk-core"; await registerRegions( "lobby", [ { id: "entrance", label: "Entrance", bounds: [-5, 0, -5, 5, 3, 0] }, { id: "counter", label: "Checkout counter", bounds: [-1, 0, 1, 1, 2, 3] }, ], { endpoint: ENDPOINT, apiKey: process.env.UPTIMIZR_API_KEY! }, ); ``` Then read any spatial aggregate for just that place: ```bash curl "$ENDPOINT/api/v1/heatmaps/world?scene=lobby®ion=counter" \ -H "x-api-key: $KEY" ``` Regions may overlap (an enclosing hall and a counter within it), so a point can belong to several. Where they do, membership is *every* containing region and the **smallest by volume** is the one reported — a click on the till is “in the counter”, not “in the hall”. ### Why it is worth the five minutes [Section titled “Why it is worth the five minutes”](#why-it-is-worth-the-five-minutes) Regions are the vocabulary an agent answers in. Ask for a summarised spatial query (`format=summary`) on a scene that has regions and a proxy, and every hotspot comes back labelled with the place it is in and the object it sits on, instead of a voxel index: ```jsonc // GET /api/v1/heatmaps/world?scene=lobby&format=summary — one cluster { "centroid": [7, 2, 11], "region": "counter", "regions": ["counter", "shop-floor"], "nearestMesh": "checkout_button", "distance": 0, "drill": { "region": "counter" }, } ``` The `reading` sentence uses them too — *“the densest spans 3x2x3 voxels on `checkout_button` in region `counter`”* — and `drill.region` hands back the region **id**, so the follow-up query (“show me just that place”) is one parameter away. Without regions the same answer is a coordinate; without a proxy `nearestMesh` is `null` and the summary says so in its caveats. The dashboard’s 3D panels use the same pair to name a voxel on hover. See [Labelled spatial clusters](/docs/api/query/#labelled-spatial-clusters). Registering regions is an **authenticated write** — like the scene-proxy upload it uses your project API key, which must hold the `annotate` capability (`uptimizr new-key --capabilities annotate`), so do it from a build script, a server-side route, or a developer-only path, never from a public bundle. There is also a CLI, which writes straight to the store and needs no key: `uptimizr regions set lobby --file regions.json`. See [Scene regions](/docs/api/query/#scene-regions). ## In the dashboard [Section titled “In the dashboard”](#in-the-dashboard) The 3D panels (world heatmap, gaze heatmap, click rays) draw your scene geometry as a backdrop. By default — with the **Scene** filter on “All scenes” — the dashboard shows the **whole building**: it merges every active section’s scoped proxy into one backdrop, so every level and far-flung area is present at once. Pick the **Scene** dropdown in the filter bar to focus a single section — that anchors the backdrop to just that area’s geometry and scopes the heatmap data to it. Showing the whole building is **deterministic**: the full multi-level world renders immediately, rather than sections appearing one at a time as a live visitor crosses boundaries. When the **live** layer is on, crossing into a newly-active section simply refreshes the merge so the new area joins the backdrop promptly; the heatmap data stays across all scenes, so the aggregate isn’t re-scoped underneath you. [Session replay](/docs/guides/replay/) follows the same rule — it renders the whole building for the session’s time window so the entire walkthrough plays against full geometry. A mesh that bridges two areas (a ramp or stairway) belongs to one section’s proxy but is always visible in the merged whole-building view. ## Replay across scenes [Section titled “Replay across scenes”](#replay-across-scenes) Because the transitions are recorded as ordered `scene_change` markers in the same stream, [session replay](/docs/guides/replay/) re-drives the visit through every scene change in order — your replay target can switch levels/models in lockstep by reacting to the `scene_change` events. ## Related [Section titled “Related”](#related) * [Custom events & input](/docs/guides/events/) — the rest of the client API (`track`, `trackInput`). * [Sessions & lifecycle](/docs/guides/sessions/) — how a session starts, flushes, and ends. * [Query endpoints](/docs/api/query/) — filtering aggregates by `sceneId`. # In-scene heatmap overlays > Paint world heatmaps and gaze domes into your own running scene with @uptimizr/heatmap. Beyond the dashboard viewers, you can paint analytics **into your own running scene** (a dev-integrated overlay) with `@uptimizr/heatmap`. The core is engine-agnostic; the Babylon adapter draws everything as a single thin-instanced mesh. ```bash npm install @uptimizr/heatmap ``` ```ts import { showWorldHeatmap, showGazeDome, showGazeSkydome } from "@uptimizr/heatmap/babylon"; // World-space pointer heatmap (GET /api/v1/heatmaps/world) as voxel blocks. const world = await showWorldHeatmap({ scene, endpoint: "https://collect.example.com", apiKey: "your-project-api-key", cellSize: 0.5, // must match how the grid is binned style: { opacity: 0.85, maxVoxels: 2000 }, }); // Gaze dome (GET /api/v1/heatmaps/camera): view-direction distribution as // markers on a sphere, optionally centered on the live camera. const gaze = await showGazeDome({ scene, endpoint: "https://collect.example.com", apiKey: "your-project-api-key", bins: 36, // grid resolution per axis followCamera: scene.activeCamera ?? undefined, style: { radius: 8, opacity: 0.9 }, // radius is in the host scene's units }); // Gaze skydome (same camera query, continuous form): splats the bins into an // equirectangular heat texture on an inward-facing dome centered on the camera, // so you can stand inside the field — especially natural in WebXR. const sky = await showGazeSkydome({ scene, endpoint: "https://collect.example.com", apiKey: "your-project-api-key", bins: 36, followCamera: scene.activeCamera ?? undefined, radius: 50, // larger than your scene; you view it from inside texture: { width: 256, blurBins: 1.5, opacity: 0.95 }, }); world.setVisible(false); // toggle any overlay gaze.dispose(); // remove from the scene when done sky.dispose(); ``` Both helpers return an overlay handle (`render` / `setVisible` / `dispose`). There is no scene registry at this tier, so `cellSize` and the gaze `radius` have **no inherent units** — supply values that fit your scene (or expose them as controls). The gaze data has two in-scene forms: `showGazeDome` drops discrete markers on a sphere, while `showGazeSkydome` paints the **continuous** equirectangular heat field of the same bins (its engine-free builder, `buildGazeEquirect`, is exported for non-Babylon hosts). The dashboard’s view-direction dome panel offers the same **Markers / Skydome** toggle. For a no-bundler page, the package also ships an ESM build you can load from a `