Performance & diagnostics
Beyond heatmaps, Uptimizr captures how the scene performs on real devices and where its rendering cost and attention actually land.
Frame performance (frame_perf)
Section titled “Frame performance (frame_perf)”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.
Spatial FPS heatmap — where FPS drops
Section titled “Spatial FPS heatmap — where FPS drops”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.
FPS by device, browser & OS
Section titled “FPS by device, browser & OS”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.memory — Chromium-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).
Shader compile stalls (compile_stall)
Section titled “Shader compile stalls (compile_stall)”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.
Capability changes
Section titled “Capability changes”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.
World-space gaze (gaze) — opt-in
Section titled “World-space gaze (gaze) — opt-in”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/babylon—scene.pickWithRay()fromcamera.getForwardRay()(sync);predicatesupported.@uptimizr/three— single reusedTHREE.Raycasterfrom NDC centre (sync);predicateoverObject3D.@uptimizr/playcanvas— single reusedpc.Rayvs mesh-instance AABBs (sync, physics-free);predicateoverGraphNode.@uptimizr/babylon-lite— async GPU picker at the centre pixel; the hit rides the next sample (≤ 1 sample latency); nopredicate(name allowlist +maxDistanceonly).@uptimizr/r3f— inherits three’s options; passcapture.gaze+gazethroughuseUptimizr/<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.