PURPOSE
Passive perf observers that characterize the device and capture main-thread stalls so analytics can join individual runs to hardware context. Cheap to install at startup with near-zero per-frame cost: long-task entries are pushed by the browser into a fixed-cap ring, refresh-rate sampling stops after 30 rAF callbacks, and device/GPU snapshots are computed once and memoized. Lets perf queries answer questions like “is this 60fps run a 60Hz device hitting the ceiling, or a 120Hz device dropping every other frame?” without guesswork.
OWNS
- Module-private long-task ring
_longTasks(array of{ t, ms, name }) capped atMAX_LONG_TASKS = 60entries per run. - Module-private
_longTaskObserver: PerformanceObserver | null— installed lazily, idempotent. - Module-private
_runStartMs— wall clock anchor used to compute run-relative long-task timestamps. - Refresh-rate detection state:
_refreshHz(defaults to60),_refreshSamples(gap buffer),_refreshDone(one-shot flag),_refreshLastT(previous rAFnow). - Memoized
_gpuInfo: GpuInfo | nulland_device: DeviceContext | nullsnapshots. - Battery-refresh one-shot flag
_batteryRefreshingso the async Battery API is kicked off at most once. - The
GpuInfoshape:vendorstring,rendererstring,webgl2boolean. - The
DeviceContextshape:cores,memGB | null,networkstring,battery | null,charging | null.
READS FROM
PerformanceObserverglobal, guarded for unsupported browsers viatypeofcheck andtry/catcharoundobserve.PerformanceEntry.durationandPerformanceEntry.namefrom the browser-suppliedlongtaskentry type.performance.now()for the long-task run clock and rAF gap math.- rAF
nowargument passed in tosampleRefreshRate. document.createElement('canvas')and awebgl2(falling back towebgl) context, thenWEBGL_debug_renderer_infofor vendor/renderer strings viaUNMASKED_VENDOR_WEBGL(0x9245) andUNMASKED_RENDERER_WEBGL(0x9246).navigator.hardwareConcurrency,(navigator as any).deviceMemory,(navigator as any).connection.effectiveType || .type, and(navigator as any).getBattery()forlevelandcharging.
PUSHES TO
_longTasksring inside the PerformanceObserver callback (roundedtto centisecond precision, roundedmsto one decimal)._refreshHzafter the 30th valid sample is collected (median gap converted to Hz)._gpuInfoand_deviceon first access via the corresponding getter._device.batteryand_device.chargingfrom the asyncgetBattery()promise resolution.- Returns drained long-task array to callers of
drainLongTasks(). - Returns combined
{ refreshHz, gpu, device }snapshot to callers ofgetRichDeviceSummary(), consumed bysender.tsat run-finalize time as part of theperf_flagspayload.
DOES NOT
- Does not send anything anywhere — purely an observer. Network I/O is the sender’s job.
- Does not throw under any condition; every browser-API touch is wrapped in
try/catchthat no-ops on failure. - Does not re-install the long-task observer if already installed (
_longTaskObservertruthy check). - Does not collect more than
MAX_LONG_TASKS = 60long-task entries per run; the ring rejects new entries past the cap. - Does not include long-task gaps below 4ms or above 100ms in the refresh-rate calculation; those samples are dropped as outliers.
- Does not re-sample refresh rate after the first 30 valid samples —
sampleRefreshRatebecomes a no-op once_refreshDoneflips true. - Does not re-query GPU info after first capture — the
_gpuInfosnapshot is sticky for the session. - Does not block the main thread waiting for
getBattery(); the firstgetDeviceContext()call returnsbattery: nulland the promise back-fills the field on resolve. - Does not reset GPU info, device context, or refresh-rate state on
resetLongTaskRunClock()— only the long-task ring and_runStartMsare reset. - Does not validate that the long-task entry has a meaningful
name; falls back to the string'longtask'.
Signals
- Long-task entry:
{ t: seconds-since-run-start (2 decimals), ms: duration (1 decimal), name: string }. - Refresh rate: integer Hz, rounded from
1000 / median(gap), default60. - GPU info:
{ vendor, renderer, webgl2 }— both strings empty ifWEBGL_debug_renderer_infois unavailable. - Device context:
{ cores, memGB, network, battery, charging }—memGBis thedeviceMemorytier in GB,networkis the effective network type string,batteryis integer percent (0-100),chargingis boolean. - Rich device summary:
{ refreshHz, gpu, device }— the single object the telemetry sender attaches to each run’sperf_flags.
Entry points
installLongTaskObserver(): void— idempotent install of the browser long-task observer.resetLongTaskRunClock(): void— anchors_runStartMsto currentperformance.now()and empties the ring.drainLongTasks(): Array<{ t, ms, name }>— returns a copy of the captured entries and clears the ring.sampleRefreshRate(now: number): void— call from every rAF callback while sampling; no-op after 30 samples.getRefreshHz(): number— returns the inferred refresh rate (60 until enough samples land).getGpuInfo(): GpuInfo— memoized GPU capture.getDeviceContext(): DeviceContext— memoized device capture.getRichDeviceSummary(): { refreshHz, gpu, device }— single combined snapshot used by the telemetry sender.
Pattern notes
- Lifecycle is split deliberately:
installLongTaskObserverruns once at game-loop startup (called frombridge.tsalongsideresetSmoothedFps), whileresetLongTaskRunClockis called at every run start so timestamps align with the per-runrender_perfrows. - The long-task ring is fixed-cap with a hard
break(not eviction) — onceMAX_LONG_TASKSis reached, additional long tasks for that run are silently dropped. Sixty 50ms+ stalls in a single run is already a catastrophic session; the cap exists to bound memory, not to be hit normally. - Refresh-rate sampling uses median rather than mean to reject outlier frames (GC pause, tab backgrounding). The 4–100ms gap window excludes both back-to-back rAF callbacks and obvious tab-throttled gaps.
WEBGL_debug_renderer_infois gated by browsers (Firefox returns empty strings unless a pref is flipped, Safari masks). The|| 0x9245/|| 0x9246fallbacks keep the call working even whenextexposes the constants as zero/undefined.getBattery()is async-by-design: the firstgetDeviceContext()call returns a context withbattery: null, fires the promise, and lets the resolution mutate_devicein place. Subsequent calls see the populated values. Callers must accept that the very first snapshot may have null battery info.- All
navigator.*anddocument.*access istypeof-guarded so the module is safe to import under SSR or test environments where those globals are absent. - The
namefield on long-task entries is cast throughanybecause the officialPerformanceEntrytype does not surfacenameon all browsers, but the runtime value is present and useful for attribution.