Run the collector
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)”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:
npm create uptimizr@latestFollow the prompts, then start it:
cd <your-folder>npm start # runs `uptimizr serve` under the hoodThe 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/dashboardand annpm run dashboardscript (analytics UI onhttp://localhost:3000). - Demo — writes a self-contained Babylon scene + tiny static server and an
npm run demoscript (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 <duckdb|postgres|clickhouse|mssql>:
npm create uptimizr@latest my-analytics -- --fullnpm create uptimizr@latest my-analytics -- --store postgresWith the full suite, run the three processes together and watch events flow from the demo through the collector into the dashboard:
npm start # collector — ingestion + query APInpm run demo # demo scene — paste the projectId, then interactnpm run dashboard # dashboard — point it at the collector with your API keyUse the uptimizr CLI shipped with @uptimizr/collector-server directly:
-
Initialise. Generates a
VISITOR_HASH_SECRET, creates and migrates the store (DuckDB unlessCOLLECTOR_STOREselects 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.Terminal window npx -p @uptimizr/collector-server uptimizr init -
Serve. Runs the ingestion + query API (migrations run automatically). Defaults to
COLLECTOR_STORE=duckdbon port4318.Terminal window npx -p @uptimizr/collector-server uptimizr serve -
Send events. Point your connector’s
endpointat the collector — e.g.http://localhost:4318— then query them back via the HTTP API.trackScene(scene, {projectId: "<printed-project-id>",endpoint: "http://localhost:4318",});
Need more projects/keys later? npx -p @uptimizr/collector-server uptimizr new-project "<name>",
or uptimizr new-key <projectId> to add a key to an existing project — see
API keys and capabilities below.
To name places inside a scene (so spatial queries can be drilled with ?region=<id>), use
uptimizr regions set <sceneId> --file regions.json --project "<projectId>" — see
Scene regions.
Inspect & back up
Section titled “Inspect & back up”-
Inspect the store directly with the DuckDB CLI (
DUCKDB_PATHdefaults to./data/uptimizr.duckdb):Terminal window duckdb ./data/uptimizr.duckdb "SELECT event_type, count(*) FROM events GROUP BY 1" -
Back up = copy the
.duckdbfile. Reset = delete it and re-runuptimizr init.
Serve the dashboard too
Section titled “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 for the all-in-one and standalone options.
Relational path: Postgres
Section titled “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:
export COLLECTOR_STORE=postgresexport POSTGRES_URL=postgresql://uptimizr:uptimizr@localhost:5432/uptimizrnpx -p @uptimizr/collector-server uptimizr init # schema + first project + key, in Postgresnpx -p @uptimizr/collector-server uptimizr serveOr 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_dailyare 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 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”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:
export COLLECTOR_STORE=mssqlexport 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 + keynpx -p @uptimizr/collector-server uptimizr serveOr 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 PostgresLATERALform. - Percentiles are computed by a helper function. T-SQL has no aggregate
PERCENTILE_CONT, sop50/p95reads pack each group’s values and interpolate them indbo.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 or 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”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:
export COLLECTOR_STORE=clickhouseexport CLICKHOUSE_URL=http://localhost:8123export CLICKHOUSE_DATABASE=uptimizrexport CLICKHOUSE_USER=defaultexport CLICKHOUSE_PASSWORD=npx -p @uptimizr/collector-server uptimizr init # database + tables + first project + keynpx -p @uptimizr/collector-server uptimizr serveOr 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.
Hosted / managed ClickHouse
Section titled “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:
export COLLECTOR_STORE=clickhouseexport CLICKHOUSE_URL=https://your-instance.clickhouse.cloud:8443export CLICKHOUSE_USER=defaultexport CLICKHOUSE_PASSWORD="$YOUR_PASSWORD"export CLICKHOUSE_DATABASE=uptimizrThe 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”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 for the privacy-relevant settings.
API keys and capabilities
Section titled “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:
# Read-only (the default) — the right key for an agent or MCP clientnpx -p @uptimizr/collector-server uptimizr new-key <projectId> --label "weekly-report"
# An agent that may also write metadata, with its own request budgetnpx -p @uptimizr/collector-server uptimizr new-key <projectId> \ --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 <projectId> \ --capabilities query,query:raw --label "replay"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”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).
{ "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": "…" } ]}uptimizr subscriptions add --file fps-drop.json --project <projectId>uptimizr subscriptions listuptimizr subscriptions test <id> # evaluate once, print why; never deliversor over HTTP with an annotate key:
curl -X POST "$COLLECTOR/api/v1/subscriptions" \ -H "x-api-key: $UPTIMIZR_API_KEY" -H 'content-type: application/json' \ --data @fps-drop.jsonPredicate kinds
Section titled “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”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”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.
COLLECTOR_WEBHOOK_ALLOWED_HOSTS=hooks.slack.com,api.github.comMatching 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”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:
POST /uptimizr HTTP/1.1Content-Type: application/jsonX-Uptimizr-Signature: sha256=<hex HMAC-SHA-256 of the raw body>X-Uptimizr-Delivery: 0f1c…{ "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:
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”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:
curl -X POST "$COLLECTOR/api/v1/subscriptions/<id>/test?deliver=true" -H "x-api-key: $KEY"The SSE stream
Section titled “The SSE stream”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=<subscriptionId> 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”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:
{ "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. toolOrPathis the route pattern, so rows group cleanly and never embed a path value.paramsis 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 asassistantand 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. SetAUDIT_DASHBOARD_REQUESTS=1to 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(default30) are removed by a periodic, idempotent sweep;0keeps them indefinitely.
Hosted MCP (Streamable HTTP)
Section titled “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 §7).
The client-side configuration lives in the MCP guide;
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 <key>, 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 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/mcpover 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 sendsCache-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/mcplocation (the collector’s ownX-Accel-Buffering: noon live SSE covers the/api/v1/live/*routes). - Caddy —
reverse_proxystreams by default; addflush_interval -1if you have changed it. - Cloud load balancers — disable response buffering/compression for
/mcpand raise the idle timeout above your polling interval, or long-lived streams are cut mid-session.
- nginx —
- 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_SESSIONScaps concurrent sessions the wayLIVE_MAX_CONNECTIONScaps live SSE;COLLECTOR_MCP_SESSION_TTL_MSreclaims the slot of a client that vanished without sendingDELETE /mcp. Request bodies stay bounded byCOLLECTOR_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 exposesMcp-Session-Idand allows the MCP request headers andDELETE.
Scheduled agent reports
Section titled “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
GETs against the aggregate query API, so aquery-only key is all it needs — and all it should be given.
# The narrow key the report should holdnpx -p @uptimizr/collector-server uptimizr new-key <projectId> \ --capabilities query --label "weekly-report"
export UPTIMIZR_COLLECTOR_URL=https://collect.example.comexport UPTIMIZR_API_KEY=utk_… # the query-only key aboveexport 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.mdSkills
Section titled “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/<name>/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:
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 |
| Flag | Meaning |
|---|---|
--skill <name> |
The investigation to run (required). See --list-skills. |
--scene <id> |
Scope the report to one scene. |
--window <NdNhNw> |
Window counting back from now — 24h, 7d (default), 2w. |
--since/--until |
Explicit epoch-millisecond window, instead of --window. |
--out <file|-> |
Where the Markdown goes. Default - (stdout). |
--json <file|-> |
Also write the structured report: every tool call with its arguments, duration and outcome, plus token usage when the provider reports it. |
--webhook <url> |
POST the report to an http(s) URL, signed (below). |
--max-steps <n> |
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”| 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”| 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 <url> posts one JSON body, { "markdown": "…", "report": { … } }, with:
| Header | Value |
|---|---|
X-Uptimizr-Signature |
sha256=<hex HMAC-SHA-256 of the raw body, keyed with UPTIMIZR_WEBHOOK_SECRET> |
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:
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.
Weekly report with GitHub Actions
Section titled “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.
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.jsonThe 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”# m h dom mon dow — Mondays at 07:00, on the host that runs the collector0 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>&1Keep 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”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.