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 at MAX_LONG_TASKS = 60 entries 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 to 60), _refreshSamples (gap buffer), _refreshDone (one-shot flag), _refreshLastT (previous rAF now).
  • Memoized _gpuInfo: GpuInfo | null and _device: DeviceContext | null snapshots.
  • Battery-refresh one-shot flag _batteryRefreshing so the async Battery API is kicked off at most once.
  • The GpuInfo shape: vendor string, renderer string, webgl2 boolean.
  • The DeviceContext shape: cores, memGB | null, network string, battery | null, charging | null.

READS FROM

  • PerformanceObserver global, guarded for unsupported browsers via typeof check and try/catch around observe.
  • PerformanceEntry.duration and PerformanceEntry.name from the browser-supplied longtask entry type.
  • performance.now() for the long-task run clock and rAF gap math.
  • rAF now argument passed in to sampleRefreshRate.
  • document.createElement('canvas') and a webgl2 (falling back to webgl) context, then WEBGL_debug_renderer_info for vendor/renderer strings via UNMASKED_VENDOR_WEBGL (0x9245) and UNMASKED_RENDERER_WEBGL (0x9246).
  • navigator.hardwareConcurrency, (navigator as any).deviceMemory, (navigator as any).connection.effectiveType || .type, and (navigator as any).getBattery() for level and charging.

PUSHES TO

  • _longTasks ring inside the PerformanceObserver callback (rounded t to centisecond precision, rounded ms to one decimal).
  • _refreshHz after the 30th valid sample is collected (median gap converted to Hz).
  • _gpuInfo and _device on first access via the corresponding getter.
  • _device.battery and _device.charging from the async getBattery() promise resolution.
  • Returns drained long-task array to callers of drainLongTasks().
  • Returns combined { refreshHz, gpu, device } snapshot to callers of getRichDeviceSummary(), consumed by sender.ts at run-finalize time as part of the perf_flags payload.

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/catch that no-ops on failure.
  • Does not re-install the long-task observer if already installed (_longTaskObserver truthy check).
  • Does not collect more than MAX_LONG_TASKS = 60 long-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 — sampleRefreshRate becomes a no-op once _refreshDone flips true.
  • Does not re-query GPU info after first capture — the _gpuInfo snapshot is sticky for the session.
  • Does not block the main thread waiting for getBattery(); the first getDeviceContext() call returns battery: null and 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 _runStartMs are 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), default 60.
  • GPU info: { vendor, renderer, webgl2 } — both strings empty if WEBGL_debug_renderer_info is unavailable.
  • Device context: { cores, memGB, network, battery, charging }memGB is the deviceMemory tier in GB, network is the effective network type string, battery is integer percent (0-100), charging is boolean.
  • Rich device summary: { refreshHz, gpu, device } — the single object the telemetry sender attaches to each run’s perf_flags.

Entry points

  • installLongTaskObserver(): void — idempotent install of the browser long-task observer.
  • resetLongTaskRunClock(): void — anchors _runStartMs to current performance.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: installLongTaskObserver runs once at game-loop startup (called from bridge.ts alongside resetSmoothedFps), while resetLongTaskRunClock is called at every run start so timestamps align with the per-run render_perf rows.
  • The long-task ring is fixed-cap with a hard break (not eviction) — once MAX_LONG_TASKS is 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_info is gated by browsers (Firefox returns empty strings unless a pref is flipped, Safari masks). The || 0x9245 / || 0x9246 fallbacks keep the call working even when ext exposes the constants as zero/undefined.
  • getBattery() is async-by-design: the first getDeviceContext() call returns a context with battery: null, fires the promise, and lets the resolution mutate _device in place. Subsequent calls see the populated values. Callers must accept that the very first snapshot may have null battery info.
  • All navigator.* and document.* access is typeof-guarded so the module is safe to import under SSR or test environments where those globals are absent.
  • The name field on long-task entries is cast through any because the official PerformanceEntry type does not surface name on all browsers, but the runtime value is present and useful for attribution.