Skip to content

Performance & diagnostics

Beyond heatmaps, Uptimizr captures how the scene performs on real devices and where its rendering cost and attention actually land.

The perf channel samples on a slow timer (default ≈0.5 Hz; tune via sampling.perf). Beyond fps / frameTimeMs / drawCalls, each sample reports percentiles and resolution over the window:

Field Meaning
frameTimeP95Ms, frameTimeP99Ms 95th/99th-percentile frame time over the window (jank tail).
longFrames Count of frames slower than jankFrameMs (default 50) in the window.
dpr Device pixel ratio.
renderScale Engine hardware-scaling factor (1 = native, <1 = downscaled).
position Optional [x, y, z] camera world-position at the sample. Powers the spatial FPS heatmap below.

A steady FPS is meaningful telemetry, so the perf channel reports continuously by default. To dedupe a stable frame rate, set suppressIdlePerfSamples: true (and tune perfFpsThreshold). Read the summary from GET /api/v1/perf. asset_load events additionally carry an optional ttiMs alongside loadMs / ttffMs.

frame_perf samples optionally carry the camera position at the moment they’re taken, which lets the collector answer where your scene runs slow — not just when. The Babylon connector fills position automatically from the tracked camera; other connectors can set it on the event they emit. It’s fully backward-compatible: it reuses the same promoted position column as the other spatial events (no migration), and older SDKs that never send it simply don’t appear in the map.

Read the binned result from GET /api/v1/heatmaps/perf — it reuses the world heatmap’s voxel grid and cellSize params and returns vx,vy,vz,samples,avg_fps,min_fps per cell, ordered avg_fps ASC so the jankiest voxels survive truncation. The dashboard renders it as the Performance heatmap (3D) panel: hot = slow (each voxel’s colour/size scales with slowness so your worst spots stand out), and hovering a cell shows its average/min FPS and sample count.

GET /api/v1/perf/by-device segments per-session median FPS by the session_start.device block — graphics engine, isMobile, and GPU renderer — plus a coarse browser and os pair. The browser/OS families are derived server-side from the request User-Agent at ingestion (e.g. Chrome / Safari on Windows / iOS); the raw User-Agent is never stored and no version or device model is retained (derived, non-PII — ADR 0003 / ADR 0041). This needs no SDK or client change — it reuses a header the collector already receives. The dashboard renders it as the “FPS by device” panel with Backend / Browser / OS / Mobile / GPU columns.

GPU / memory footprint (resource_sample) — opt-in

Section titled “GPU / memory footprint (resource_sample) — opt-in”

Off by default. When capture.resourceSample is enabled, the connector samples the actual cost the scene asks of the device on a slow timer (default every 15 s), separate from per-frame frame_perf:

trackScene(scene, {
// ...
capture: { resourceSample: true },
resourceSample: { intervalMs: 15000 }, // one footprint sample per window (default)
});

Each resource_sample carries whatever the engine can cheaply report, all optional: textureBytes, geometryBytes (resident GPU memory), triangles, vertices (submitted last frame), and jsHeapBytes (JS heap). Pair it with the device caps on session_start to spot scenes that overspend their target hardware.

Coverage differs by engine (the SDK never mutates the engine): Babylon reports triangles (active indices ÷ 3) and vertices; three.js reports triangles (renderer.info.render.triangles). jsHeapBytes comes from performance.memoryChromium-only, omitted elsewhere rather than zeroed. Resident textureBytes / geometryBytes aren’t on either engine’s public surface, so they’re left unset; the read API’s averages ignore unreported metrics (an absent metric never reads as 0).

Babylon-only, on by default via capture.compileStall. Times Babylon’s main-thread shader/pipeline compilation span — the #1 source of first-interaction hitches — reporting durationMs and phase. three.js has no equivalent engine hook.

Perf-driven churn (GET /api/v1/perf/churn)

Section titled “Perf-driven churn (GET /api/v1/perf/churn)”

Perf distribution tells you how the scene performs; this tells you whether a stutter actually cost you the session. It correlates perf dips against early session end (#144): of the sessions that ended in range (sessions), it reports how many ended within a window of an FPS dip or a compile stall (churn_sessions), and attributes the cause into fps_churn_sessions / stall_churn_sessions. A session hit by both a dip and a stall is counted under each cause but only once in the headline total, so the cause counts can sum to more than churn_sessions.

It’s buildable from existing telemetry — derived from frame_perf, compile_stall and session_end, with no schema change and aggregate counts only (no per-session identifiers leave the query, ADR 0003). Three query params tune the correlation (all optional):

Param Default Meaning
windowMs 30000 How long before a session’s end a perf dip still counts as correlated.
fpsThreshold 30 A frame_perf sample below this FPS counts as an FPS dip.
stallMs 100 A compile_stall of at least this many ms counts as a stall.

The dashboard renders it as the “Perf-driven churn” panel next to the performance-distribution panel: a headline perf-correlated churn rate plus the FPS-dip vs. compile-stall cause split, with the window and thresholds exposed as viewer-tunable sliders.

Scene health score (GET /api/v1/insights/scene-health)

Section titled “Scene health score (GET /api/v1/insights/scene-health)”

Every panel above answers a question about one signal. This one answers the question you actually start from: which scene should I look at first? It scores each scene 0-100 over six weighted factors — three of them the perf signals on this page — and returns them least healthy first.

Factor Metric Raw value Good is Weight
perf_stability perf_summary 5th-percentile FPS higher 0.25
error_rate error_heatmap errors + diagnostics per session lower 0.25
jank_rate jank_rate long frames per sampled perf window lower 0.20
dead_click_rate dead_clicks share of clicks that hit nothing lower 0.15
coverage scene_coverage positioned camera samples per session higher 0.10
xr_abandonment xr_abandonment interactions per XR session higher 0.05

The perf factor is the 5th percentile, not the average, on purpose: a scene whose median is 60 and whose p05 is 12 is a stuttering scene, and an average hides exactly that. Each factor is normalised against the project’s own baseline over the preceding equal window, so 50 means “as well as the rest of this project was doing last week” rather than an absolute grade — there is no universal FPS at which a marketing configurator and a six-player VR game are both healthy.

The dashboard renders it as the “Scene health score” tile on the overview: one score per scene with a bar per factor. Hovering a bar names the metric behind it, the raw value it produced and the project baseline it was compared with — so the tile is a routing decision, not a dead end. A bar that is missing could not be measured in the window and was left out of the mean rather than averaged in as a zero.

Weights are declared in the metric registry (so they show up in capabilities and in the agent tool catalog) and can be overridden per request with a weights JSON object — for example weights={"perf_stability":0.5} for a project where frame rate is the whole product.

Fallbacks (WebGPU→WebGL2), quality/LOD auto-downgrades, and device recovery are app-reported via client.reportCapabilityChange(...) — see custom events & input. They explain perf and visual-fidelity variance across your user base; read the rollup from GET /api/v1/capabilities.

Off by default (privacy + cost). When capture.gaze is enabled, the connector raycasts the camera-forward ray into the scene on each frame that already emits a camera_sample, and attaches the surface hit to that sample as hitPoint (world-space point) + hitMesh (hit object’s name). This answers “where did the audience’s gaze rest on the actual geometry” for every camera style (orbit, first-person, XR) — distinct from the click-only world heatmap and the abstract view-direction sphere.

trackScene(scene, {
// ...
capture: { gaze: true },
gaze: {
maxDistance: 1000, // ignore hits farther than this along the ray (default)
meshes: ["product-hero"], // allowlist; omit to hit any mesh
predicate: (mesh) => mesh.name !== "ground", // exclude skybox/helpers (sync connectors)
},
});

Gaze is cheap by design: one pick per emitted pose. It rides the existing, idle-suppressed camera cadence — it never adds a timer or picks at frame rate, and a static (pose-deduped) frame costs nothing. The hit is normalized to the canonical coordinate frame at the emission boundary, so the gaze heatmap aligns with the pointer world heatmap across engines.

Connector parity (same capture.gaze flag + GazeOptions):

  • @uptimizr/babylonscene.pickWithRay() from camera.getForwardRay() (sync); predicate supported.
  • @uptimizr/three — single reused THREE.Raycaster from NDC centre (sync); predicate over Object3D.
  • @uptimizr/playcanvas — single reused pc.Ray vs mesh-instance AABBs (sync, physics-free); predicate over GraphNode.
  • @uptimizr/babylon-lite — async GPU picker at the centre pixel; the hit rides the next sample (≤ 1 sample latency); no predicate (name allowlist + maxDistance only).
  • @uptimizr/r3f — inherits three’s options; pass capture.gaze + gaze through useUptimizr / <Uptimizr>.
  • @uptimizr/aframe — flat HTML schema exposes a boolean toggle only: <a-scene uptimizr="gaze: true">.

Read the result via GET /api/v1/heatmaps/gaze — it reuses the world heatmap’s voxel grid, params, and 3D renderer. You can also paint it into your own scene.