Skip to content

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.

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:

Terminal window
npm create uptimizr@latest

Follow the prompts, then start it:

Terminal window
cd <your-folder>
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 <duckdb|postgres|clickhouse|mssql>:

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

Terminal window
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
  • Inspect the store directly with the DuckDB CLI (DUCKDB_PATH defaults to ./data/uptimizr.duckdb):

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

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.

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:

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

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:

Terminal window
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 or ClickHouse otherwise; the monorepo’s infra/docker ships a mcr.microsoft.com/mssql/server:2022-latest service (pnpm stack:up).

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:

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

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:

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

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.

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:

Terminal window
# Read-only (the default) — the right key for an agent or MCP client
npx -p @uptimizr/collector-server uptimizr new-key <projectId> --label "weekly-report"
# An agent that may also write metadata, with its own request budget
npx -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.

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
{
"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": "" }
]
}
Terminal window
uptimizr subscriptions add --file fps-drop.json --project <projectId>
uptimizr subscriptions list
uptimizr subscriptions test <id> # evaluate once, print why; never delivers

or over HTTP with an annotate key:

Terminal window
curl -X POST "$COLLECTOR/api/v1/subscriptions" \
-H "x-api-key: $UPTIMIZR_API_KEY" -H 'content-type: application/json' \
--data @fps-drop.json
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_summaryp50_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.

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

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.

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

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.1
Content-Type: application/json
X-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.

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:

Terminal window
curl -X POST "$COLLECTOR/api/v1/subscriptions/<id>/test?deliver=true" -H "x-api-key: $KEY"
Terminal window
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.

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

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 /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:
    • nginxproxy_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).
    • Caddyreverse_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.

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 a query-only key is all it needs — and all it should be given.
Terminal window
# The narrow key the report should hold
npx -p @uptimizr/collector-server uptimizr new-key <projectId> \
--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

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:

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

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

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

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

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.