@mpgd/runtime-diagnostics 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,252 @@
1
+ # `@mpgd/runtime-diagnostics`
2
+
3
+ Engine-independent frame performance diagnostics: record per-frame timing
4
+ samples, estimate why hitches happened using optional browser observations,
5
+ and read bounded aggregates — all without importing an engine, DOM, or
6
+ network stack.
7
+
8
+ The package is deliberately headless. It ships:
9
+
10
+ - sample types for frames, long tasks, long animation frames, and resource
11
+ loads;
12
+ - `FrameHitchRecorder<TSample>`, which aggregates frames, keeps a bounded
13
+ history, and estimates hitch causes;
14
+ - `createLongAnimationFrameScriptSamples()`, the sanitizing boundary for
15
+ observer script attributions;
16
+ - `readUsedHeapBytes()`, a structural reader for the Chromium heap extension.
17
+
18
+ Games extend `FrameHitchSample` with their own numeric context (for example
19
+ entity counts or phase labels) through the generic parameter; the recorder
20
+ never inspects or clones those fields.
21
+
22
+ ## What stays with the consumer
23
+
24
+ The recorder never touches the platform. Installing observers
25
+ (`PerformanceObserver` for long tasks and long animation frames,
26
+ `PerformanceObserver`-backed resource timing), reading
27
+ `document.visibilityState`, timing engine update/render callbacks, producing
28
+ heap deltas, rendering debug overlays, and uploading or persisting reports are
29
+ all consumer responsibilities. Importing this package causes no observer,
30
+ timer, DOM, or network side effects, which is why the tests run in plain Node.
31
+
32
+ ## Time and attribution contract
33
+
34
+ - Every `atMs` / `startAtMs` timestamp and duration is in milliseconds on one
35
+ monotonic, non-decreasing clock chosen by the consumer (for example
36
+ `performance.now()`). The recorder never reads a clock, so tests drive it
37
+ with synthetic timestamps.
38
+ - A frame sample describes one inter-frame gap. `frameDeltaMs` is that gap;
39
+ `previousRenderWorkMs` is the **previous** frame's render callback, which
40
+ executed just before this frame became current and is therefore attributed
41
+ to this gap; `updateWorkMs` is this frame's own update callback ending at
42
+ `atMs`. Misattributing the previous render to the previous sample's gap —
43
+ or this frame's update to the previous gap — would double-count work, so
44
+ the recorder keeps the two windows distinct.
45
+ - A **foreground frame** is one that was not hidden, did not cross a
46
+ visibility transition, and was not estimated as a scheduler interruption.
47
+ Only foreground frames feed the averages, foreground worsts, and hitch
48
+ diagnoses.
49
+ - A **visibility interruption** (`hidden: true` or
50
+ `visibilityInterrupted: true`) is an observed fact supplied by the consumer.
51
+ A **scheduler interruption** is an estimate: a gap of at least
52
+ `SCHEDULER_INTERRUPTION_THRESHOLD_MS` (1 s) whose diagnosis found no
53
+ explanatory update, render, or observation work. Both are counted
54
+ (`interruptionCount`, `schedulerInterruptionCount`) and their excluded time
55
+ is reported (`interruptedFrameMs`, `worstInterruptedFrameMs`) so averages
56
+ never silently drop a stall.
57
+ - Cumulative counters (`frameCount`, `hitchCount`, interruption and
58
+ observation counts, `memoryReclamationHitchCount`, worsts, and the totals
59
+ behind averages) are never trimmed by history eviction. Retained histories
60
+ (`hitches`, long tasks, long animation frames, resource loads) each hold at
61
+ most `historyLimit` samples, and `hitchCauseCounts` is computed over the
62
+ retained hitches only — after eviction it shrinks while `hitchCount` does
63
+ not.
64
+ - Diagnoses are recomputed at `snapshot()` time from the observations still
65
+ retained, so an observation arriving after a **retained hitch** can relabel
66
+ that hitch's cause until either sample is evicted. Estimated scheduler
67
+ interruptions are the exception: the estimate is decided at record time from
68
+ the evidence available then and is final — the excluded gap stays visible
69
+ through `interruptionCount`, `schedulerInterruptionCount`, and
70
+ `interruptedFrameMs` instead of being silently reclassified. Out-of-order
71
+ observations are accepted: counts and worsts are order-independent, `last*`
72
+ fields keep the newest sample by `atMs`, and eviction drops the oldest by
73
+ event time so a late buffered sample cannot evict newer evidence.
74
+ - `reset()` clears every aggregate and every retained sample — pre-reset
75
+ observations cannot leak into the next window — except `resetCount`, which
76
+ increments monotonically so consumers can observe an accepted reset without
77
+ timing guesses.
78
+
79
+ ## Causes are estimates
80
+
81
+ `diagnose()` orders heuristic evidence; it never confirms a root cause:
82
+
83
+ - `game-update` / `phaser-render`: update or previous-render work is at least
84
+ `workThresholdMs` (20 ms) and at least 35% of the gap. `phaser-render`
85
+ names the consuming engine's render callback (the kit's primary engine is
86
+ Phaser); the classification itself is engine-independent.
87
+ - `memory-reclamation`: a 100–1000 ms gap with a heap drop of at least 10 MiB
88
+ (`HEAP_RECLAMATION_THRESHOLD_BYTES`). A heap drop alone does not prove a GC
89
+ pause caused the hitch; multi-second gaps are excluded because collectors
90
+ also run while the renderer is suspended.
91
+ - `main-thread-long-task` / `browser-rendering`: a retained observation
92
+ overlapping the frame window with tolerance. Overlap does not prove the
93
+ observation blocked this frame.
94
+ - `resource-load`: an overlapping resource timing entry. Resource timing
95
+ measures network/decode work, not main-thread blocking.
96
+ - `scheduler-or-compositor`: the residual estimate when nothing else explains
97
+ the gap — including long animation frames whose reported blocking, script,
98
+ and style/layout work are under 10% of a multi-second gap (renderer
99
+ suspension). The absence of observations never proves the game performed
100
+ well.
101
+
102
+ ## Input validation
103
+
104
+ All constructor options and samples are validated before any aggregate
105
+ changes, so a rejected input never leaves partially updated state. The
106
+ recorder throws `Error` with a field-qualified message when it receives:
107
+
108
+ - non-finite, negative, or NaN/Infinity durations and timestamps;
109
+ - thresholds that are not finite positive numbers;
110
+ - a `historyLimit` that is not an integer in
111
+ `[0, MAX_FRAME_HITCH_HISTORY_LIMIT]` (1 000); `0` is valid and retains no
112
+ samples while every counter keeps working;
113
+ - observation intervals where `atMs` precedes `startAtMs`;
114
+ - script attribution arrays longer than
115
+ `MAX_LONG_ANIMATION_FRAME_SCRIPT_SAMPLES` (8), or raw timing arrays longer
116
+ than `MAX_LONG_ANIMATION_FRAME_SCRIPT_TIMINGS` (1 024).
117
+
118
+ `heapDeltaBytes` only requires a finite number: negative deltas are normal
119
+ collection signals and are not validated like durations.
120
+
121
+ ## Example
122
+
123
+ A toy game wires its own clocks and observers, then records:
124
+
125
+ ```ts
126
+ import {
127
+ FrameHitchRecorder,
128
+ createLongAnimationFrameScriptSamples,
129
+ readUsedHeapBytes,
130
+ type FrameHitchSample,
131
+ } from '@mpgd/runtime-diagnostics';
132
+
133
+ interface ToyFrameSample extends FrameHitchSample {
134
+ readonly enemies: number;
135
+ readonly phase: string;
136
+ }
137
+
138
+ // The game owns clock access, observer installation, and visibility state.
139
+ const recorder = new FrameHitchRecorder<ToyFrameSample>({
140
+ hitchThresholdMs: 50,
141
+ workThresholdMs: 20,
142
+ historyLimit: 12,
143
+ });
144
+
145
+ let previousHeapBytes = readUsedHeapBytes(performance);
146
+
147
+ // The game times its own callbacks and reads its own visibility state.
148
+ function onFrame(frame: {
149
+ atMs: number;
150
+ frameDeltaMs: number;
151
+ updateWorkMs: number;
152
+ previousRenderWorkMs: number;
153
+ visibilityInterrupted: boolean;
154
+ }): void {
155
+ const heapBytes = readUsedHeapBytes(performance);
156
+ recorder.record({
157
+ atMs: frame.atMs,
158
+ enemies: countEnemies(),
159
+ frameDeltaMs: frame.frameDeltaMs,
160
+ hidden: document.visibilityState === 'hidden',
161
+ heapBytes,
162
+ heapDeltaBytes: heapBytes !== undefined && previousHeapBytes !== undefined
163
+ ? heapBytes - previousHeapBytes
164
+ : undefined,
165
+ phase: currentPhase(),
166
+ previousRenderWorkMs: frame.previousRenderWorkMs,
167
+ updateWorkMs: frame.updateWorkMs,
168
+ visibilityInterrupted: frame.visibilityInterrupted,
169
+ });
170
+ previousHeapBytes = heapBytes;
171
+ }
172
+
173
+ // The game maps PerformanceObserver entries to samples itself; the shapes
174
+ // below are already the recorder's input types, not browser types.
175
+ function onLongAnimationFrame(observed: {
176
+ startTime: number;
177
+ duration: number;
178
+ blockingDuration: number;
179
+ renderDuration: number;
180
+ scriptDuration: number;
181
+ scriptInvocations: number;
182
+ styleAndLayoutDuration: number;
183
+ scripts: readonly {
184
+ duration?: number;
185
+ forcedStyleAndLayoutDuration?: number;
186
+ invoker?: string;
187
+ invokerType?: string;
188
+ pauseDuration?: number;
189
+ sourceFunctionName?: string;
190
+ sourceURL?: string;
191
+ }[];
192
+ hidden: boolean;
193
+ }): void {
194
+ recorder.recordLongAnimationFrame({
195
+ atMs: observed.startTime + observed.duration,
196
+ blockingDurationMs: observed.blockingDuration,
197
+ durationMs: observed.duration,
198
+ hidden: observed.hidden,
199
+ renderDurationMs: observed.renderDuration,
200
+ scriptDurationMs: observed.scriptDuration,
201
+ scriptInvocations: observed.scriptInvocations,
202
+ scripts: createLongAnimationFrameScriptSamples(
203
+ observed.scripts,
204
+ (sourceUrl) => sanitizeSourceUrl(sourceUrl),
205
+ ),
206
+ startAtMs: observed.startTime,
207
+ styleAndLayoutDurationMs: observed.styleAndLayoutDuration,
208
+ });
209
+ }
210
+
211
+ // Read a snapshot for a debug overlay or a bug report attachment.
212
+ const snapshot = recorder.snapshot();
213
+ console.log(snapshot.averageFrameMs, snapshot.hitchCauseCounts, snapshot.hitches.at(-1));
214
+
215
+ // Start a fresh window; only `resetCount` survives.
216
+ recorder.reset();
217
+ ```
218
+
219
+ The example's `onFrame` / `onLongAnimationFrame` signatures are illustrative:
220
+ the consumer decides how engine callbacks and `PerformanceObserver` entries
221
+ map onto the recorder's input types. This package deliberately defines no
222
+ browser types beyond the structural `HeapMemorySource`.
223
+
224
+ ## Ownership of samples
225
+
226
+ Recorded samples and snapshot results are shared by reference at the
227
+ container level: `snapshot()` returns fresh arrays and shallow copies of
228
+ samples, so mutating the array, the cause counts, or a sample's numeric base
229
+ fields cannot corrupt the recorder. Nested game-context objects inside a
230
+ sample are **not** deep-cloned; treat every sample you hand to `record()` and
231
+ every object inside a snapshot as read-only from that point on.
232
+
233
+ ## Memory bounds
234
+
235
+ Every retained list is capped at `historyLimit` (default 12, at most
236
+ `MAX_FRAME_HITCH_HISTORY_LIMIT` = 1 000). Worst values and averages are
237
+ running accumulators, not lists. `snapshot()` correlates each retained hitch
238
+ against each retained observation, so its cost grows with
239
+ `hitches × observations`; the 1 000 cap bounds the worst case to a few
240
+ million comparisons while the default configuration stays trivial. Long
241
+ animation frame samples carry at most eight sanitized script attributions,
242
+ and raw observer attribution objects are never stored — `sourceLabel` is the
243
+ explicit sanitization boundary that keeps URLs, query strings, and tokens out
244
+ of diagnostic reports.
245
+
246
+ ## Package status
247
+
248
+ Published to npm as `@mpgd/runtime-diagnostics@0.1.0` via an initial local
249
+ registration under the maintainer's auth, with a `.sampo/config.toml`
250
+ release-group entry for automated releases. Subsequent versions ship through
251
+ Sampo changesets once the package's npm Trusted Publishing/OIDC entry for
252
+ `.github/workflows/release.yml` is registered on npmjs.com.
@@ -0,0 +1,399 @@
1
+ /**
2
+ * Engine-independent frame performance diagnostics.
3
+ *
4
+ * This module records game-supplied frame samples, estimates causes for frame
5
+ * hitches using optional browser observations, and aggregates bounded
6
+ * snapshots. It installs no observers, reads no platform globals, starts no
7
+ * timers, and performs no I/O: wiring `performance`, `PerformanceObserver`,
8
+ * `document.visibilityState`, engine events, domain fields, and report sinks
9
+ * is the consumer's responsibility.
10
+ *
11
+ * Time contract: every timestamp (`atMs`, `startAtMs`) and duration is in
12
+ * milliseconds on one monotonic, non-decreasing clock supplied by the consumer
13
+ * (for example `performance.now()`). The recorder never reads a clock itself,
14
+ * so tests can drive it with synthetic timestamps.
15
+ */
16
+ export interface FrameHitchSample {
17
+ /**
18
+ * Completion time of this frame's update work, in milliseconds on the
19
+ * consumer's monotonic clock. Must not be negative.
20
+ */
21
+ readonly atMs: number;
22
+ /**
23
+ * Gap since the previous frame at the moment this frame ran. This gap is
24
+ * the frame this sample describes: it contains the previous frame's render
25
+ * callback, browser presentational work, idle time, and this frame's update
26
+ * callback.
27
+ */
28
+ readonly frameDeltaMs: number;
29
+ /** True when the page was hidden while this frame was sampled. */
30
+ readonly hidden: boolean;
31
+ /** Optional total used-JS-heap reading taken at this frame, in bytes. */
32
+ readonly heapBytes?: number;
33
+ /**
34
+ * Optional change in used JS heap since the previous reading, in bytes.
35
+ * Negative values are normal (allocation churn reversal and collection) and
36
+ * are deliberately not validated like durations.
37
+ */
38
+ readonly heapDeltaBytes?: number;
39
+ /**
40
+ * Duration of the previous frame's render callback. It is attributed to
41
+ * this sample's `frameDeltaMs` gap because that render executed just
42
+ * before this frame became current — not to the previous sample's gap.
43
+ */
44
+ readonly previousRenderWorkMs: number;
45
+ /** Duration of this frame's update callback, ending at `atMs`. */
46
+ readonly updateWorkMs: number;
47
+ /** True when this sample's gap crossed a document visibility transition. */
48
+ readonly visibilityInterrupted: boolean;
49
+ }
50
+ export declare const FRAME_HITCH_CAUSES: readonly ['browser-rendering', 'game-update', 'main-thread-long-task', 'memory-reclamation', 'phaser-render', 'resource-load', 'scheduler-or-compositor'];
51
+ export type FrameHitchCause = typeof FRAME_HITCH_CAUSES[number];
52
+ /**
53
+ * Estimated explanation for one hitch. Every cause is a heuristic verdict
54
+ * computed from the recorded evidence, never a confirmed root cause: see each
55
+ * threshold constant below for what a cause does and does not prove.
56
+ */
57
+ export interface FrameHitchDiagnosis {
58
+ readonly atMs: number;
59
+ readonly cause: FrameHitchCause;
60
+ readonly frameDeltaMs: number;
61
+ readonly relatedLongAnimationFrameMs?: number;
62
+ readonly relatedLongTaskMs?: number;
63
+ readonly relatedResourceLoadMs?: number;
64
+ }
65
+ /** Frames whose gap reaches this threshold are hitches. */
66
+ export declare const FRAME_HITCH_THRESHOLD_MS = 50;
67
+ /**
68
+ * Update or render work reaching this threshold marks a hitch even when the
69
+ * frame gap itself stayed below `FRAME_HITCH_THRESHOLD_MS`.
70
+ */
71
+ export declare const FRAME_WORK_THRESHOLD_MS = 20;
72
+ /**
73
+ * Heap-reclamation estimation only applies to gaps of at least this length;
74
+ * shorter gaps with heap drops stay attributed to whatever other evidence
75
+ * exists, because incidental minor collections are common in healthy frames.
76
+ */
77
+ export declare const HEAP_RECLAMATION_FRAME_THRESHOLD_MS = 100;
78
+ /**
79
+ * A heap drop at or below this value (that is, a large collection) is
80
+ * evidence for `memory-reclamation` — it does not prove a major GC pause was
81
+ * the hitch's root cause.
82
+ */
83
+ export declare const HEAP_RECLAMATION_THRESHOLD_BYTES: number;
84
+ /**
85
+ * Gaps of at least this length with no explanatory game, render, or
86
+ * observation work are estimated as `scheduler-or-compositor` interruptions.
87
+ */
88
+ export declare const SCHEDULER_INTERRUPTION_THRESHOLD_MS = 1000;
89
+ /** Default retained history length per sample list. */
90
+ export declare const FRAME_HITCH_HISTORY_LIMIT = 12;
91
+ /** Upper bound accepted for `historyLimit`. `snapshot()` cost grows with retained hitches times retained observations, so the bound caps the worst case rather than the defaults. */
92
+ export declare const MAX_FRAME_HITCH_HISTORY_LIMIT = 1000;
93
+ export interface LongTaskSample {
94
+ /** Completion time of the task, in milliseconds. Must not precede `startAtMs`. */
95
+ readonly atMs: number;
96
+ /** Task duration in milliseconds. */
97
+ readonly durationMs: number;
98
+ /** True when the task executed while the page was hidden. Hidden tasks are ignored. */
99
+ readonly hidden: boolean;
100
+ /** Consumer-sanitized diagnostic label for the task source. */
101
+ readonly name: string;
102
+ /** Start time of the task, in milliseconds. */
103
+ readonly startAtMs: number;
104
+ }
105
+ export interface LongAnimationFrameSample {
106
+ /** Completion time of the animation frame, in milliseconds. */
107
+ readonly atMs: number;
108
+ /** Blocking duration in milliseconds, as reported by the observer. */
109
+ readonly blockingDurationMs: number;
110
+ /** Total animation frame duration in milliseconds. */
111
+ readonly durationMs: number;
112
+ /** True when the frame executed while the page was hidden. Hidden frames are ignored. */
113
+ readonly hidden: boolean;
114
+ /** Render duration in milliseconds, as reported by the observer. */
115
+ readonly renderDurationMs: number;
116
+ /** Script duration in milliseconds, as reported by the observer. */
117
+ readonly scriptDurationMs: number;
118
+ /** Number of script invocations, as reported by the observer. */
119
+ readonly scriptInvocations: number;
120
+ /**
121
+ * Bounded, already-sanitized script attributions. Retain at most
122
+ * `MAX_LONG_ANIMATION_FRAME_SCRIPT_SAMPLES` entries; raw observer
123
+ * attribution objects must never be stored here.
124
+ */
125
+ readonly scripts?: readonly LongAnimationFrameScriptSample[];
126
+ /** Start time of the animation frame, in milliseconds. */
127
+ readonly startAtMs: number;
128
+ /** Style and layout duration in milliseconds, as reported by the observer. */
129
+ readonly styleAndLayoutDurationMs: number;
130
+ }
131
+ export interface LongAnimationFrameScriptSample {
132
+ readonly durationMs: number;
133
+ readonly forcedStyleAndLayoutDurationMs: number;
134
+ readonly invoker: string;
135
+ readonly invokerType: string;
136
+ readonly pauseDurationMs: number;
137
+ readonly sourceFunctionName: string;
138
+ /** Sanitized label produced by the consumer's `sourceLabel` callback. */
139
+ readonly sourceUrl: string;
140
+ }
141
+ /** Structural subset of a `PerformanceScriptTiming` entry as reported by observers. */
142
+ export interface LongAnimationFrameScriptTiming {
143
+ readonly duration?: number;
144
+ readonly forcedStyleAndLayoutDuration?: number;
145
+ readonly invoker?: string;
146
+ readonly invokerType?: string;
147
+ readonly pauseDuration?: number;
148
+ readonly sourceFunctionName?: string;
149
+ readonly sourceURL?: string;
150
+ }
151
+ /** Maximum script attributions retained per long animation frame sample. */
152
+ export declare const MAX_LONG_ANIMATION_FRAME_SCRIPT_SAMPLES = 8;
153
+ /** Maximum raw script timings accepted by `createLongAnimationFrameScriptSamples`. */
154
+ export declare const MAX_LONG_ANIMATION_FRAME_SCRIPT_TIMINGS = 1024;
155
+ /**
156
+ * Retain only the longest script attributions so diagnostic reports stay
157
+ * predictably bounded. `sourceLabel` is the sanitization boundary: raw
158
+ * observer URLs (which may carry query strings or tokens) must be reduced to
159
+ * a safe label here and are never stored verbatim.
160
+ */
161
+ export declare function createLongAnimationFrameScriptSamples(scripts: readonly LongAnimationFrameScriptTiming[], sourceLabel: (sourceUrl: string) => string): readonly LongAnimationFrameScriptSample[];
162
+ export interface ResourceLoadSample {
163
+ /** Completion time of the load, in milliseconds. Must not precede `startAtMs`. */
164
+ readonly atMs: number;
165
+ /** Load duration in milliseconds. */
166
+ readonly durationMs: number;
167
+ /** True when the load completed while the page was hidden. Hidden loads are ignored. */
168
+ readonly hidden: boolean;
169
+ /** Consumer-sanitized diagnostic label for the resource. */
170
+ readonly name: string;
171
+ /** Start time of the load, in milliseconds. */
172
+ readonly startAtMs: number;
173
+ }
174
+ /**
175
+ * Aggregated view over one recording window. Counters marked cumulative are
176
+ * never trimmed by history eviction; fields backed by retained history reflect
177
+ * only the samples still held (see `hitchCauseCounts`).
178
+ */
179
+ export interface FramePerformanceSnapshot<TSample extends FrameHitchSample = FrameHitchSample> {
180
+ /** Mean frame delta over foreground frames only; interrupted frames are excluded. */
181
+ readonly averageFrameMs: number;
182
+ /** Mean previous-frame render work over foreground frames only. */
183
+ readonly averageRenderWorkMs: number;
184
+ /** Mean update work over foreground frames only. */
185
+ readonly averageUpdateWorkMs: number;
186
+ /** Frames sampled while active and uninterrupted (frameCount minus interruptionCount). */
187
+ readonly foregroundFrameCount: number;
188
+ /** Cumulative count of every sampled frame, including interrupted frames. */
189
+ readonly frameCount: number;
190
+ /** Cumulative hitch count; unlike `hitches` it never shrinks on eviction. */
191
+ readonly hitchCount: number;
192
+ /**
193
+ * Shallow copies of retained hitch samples. Mutating the array or the
194
+ * numeric base fields of a returned sample cannot corrupt the recorder;
195
+ * nested game-context objects are shared references and must be treated as
196
+ * read-only.
197
+ */
198
+ readonly hitches: readonly TSample[];
199
+ /**
200
+ * Cause counts over the retained hitch history only; eviction shrinks these
201
+ * counts while `hitchCount` stays cumulative for the window.
202
+ */
203
+ readonly hitchCauseCounts: Readonly<Record<FrameHitchCause, number>>;
204
+ /** Cumulative sum of frame deltas over frames excluded from the foreground averages. */
205
+ readonly interruptedFrameMs: number;
206
+ /** Cumulative count of hidden, visibility-interrupted, and estimated scheduler-interrupted frames. */
207
+ readonly interruptionCount: number;
208
+ /** Shallow copy of the most recent frame by `atMs`; omitted before the first frame. */
209
+ readonly lastFrame?: TSample;
210
+ /** Diagnosis recomputed for the newest retained hitch; omitted when none is retained. */
211
+ readonly lastHitchDiagnosis?: FrameHitchDiagnosis;
212
+ /** Shallow copy of the newest visible long animation frame; omitted when none was recorded. */
213
+ readonly lastLongAnimationFrame?: LongAnimationFrameSample;
214
+ /** Shallow copy of the newest visible long task; omitted when none was recorded. */
215
+ readonly lastLongTask?: LongTaskSample;
216
+ /** Shallow copy of the newest visible resource load; omitted when none was recorded. */
217
+ readonly lastResourceLoad?: ResourceLoadSample;
218
+ /** Cumulative visible long animation frame count. */
219
+ readonly longAnimationFrameCount: number;
220
+ /** Cumulative visible long task count. */
221
+ readonly longTaskCount: number;
222
+ /**
223
+ * Cumulative memory-reclamation diagnoses; unlike
224
+ * `hitchCauseCounts['memory-reclamation']` this is never history-trimmed.
225
+ */
226
+ readonly memoryReclamationHitchCount: number;
227
+ /** Monotonic marker incremented by every `reset()`; the only field reset never clears. */
228
+ readonly resetCount: number;
229
+ /** Cumulative visible resource load count. */
230
+ readonly resourceLoadCount: number;
231
+ /** Cumulative count of estimated scheduler-or-compositor interruptions. */
232
+ readonly schedulerInterruptionCount: number;
233
+ /** Worst foreground frame delta; interrupted frames are excluded from this worst. */
234
+ readonly worstFrameMs: number;
235
+ /** Diagnosis of the worst foreground hitch; omitted when the worst frame stayed under the hitch threshold. */
236
+ readonly worstHitchDiagnosis?: FrameHitchDiagnosis;
237
+ /** Worst frame delta among interrupted frames (hidden, visibility, or estimated scheduler). */
238
+ readonly worstInterruptedFrameMs: number;
239
+ /** Worst visible long animation frame duration. */
240
+ readonly worstLongAnimationFrameMs: number;
241
+ /** Worst visible long task duration. */
242
+ readonly worstLongTaskMs: number;
243
+ /** Worst visible resource load duration. */
244
+ readonly worstResourceLoadMs: number;
245
+ /** Worst previous-frame render work over foreground frames. */
246
+ readonly worstRenderWorkMs: number;
247
+ /** Worst update work over foreground frames. */
248
+ readonly worstUpdateWorkMs: number;
249
+ }
250
+ /** Constructor settings for `FrameHitchRecorder`. */
251
+ export interface FrameHitchRecorderOptions {
252
+ /** Frame gaps at or above this threshold count as hitches. Must be finite and positive. */
253
+ readonly hitchThresholdMs?: number;
254
+ /** Update or render work at or above this threshold can mark a hitch on its own. Must be finite and positive. */
255
+ readonly workThresholdMs?: number;
256
+ /**
257
+ * Maximum retained samples per history list. `0` retains no samples while
258
+ * every cumulative counter keeps working. Must be an integer in
259
+ * `[0, MAX_FRAME_HITCH_HISTORY_LIMIT]`.
260
+ */
261
+ readonly historyLimit?: number;
262
+ }
263
+ export declare class FrameHitchRecorder<TSample extends FrameHitchSample = FrameHitchSample> {
264
+ private static readonly browserWorkShareLimit;
265
+ private static readonly explanatoryWorkRatio;
266
+ private static readonly relatedSampleOverlapToleranceMs;
267
+ private static readonly scriptDominanceFloorMs;
268
+ private frameCount;
269
+ private hitchCount;
270
+ private readonly hitches;
271
+ private interruptionCount;
272
+ private lastFrame;
273
+ private readonly longAnimationFrames;
274
+ private lastLongAnimationFrame;
275
+ private lastLongTask;
276
+ private lastResourceLoad;
277
+ private longAnimationFrameCount;
278
+ private readonly longTasks;
279
+ private longTaskCount;
280
+ private memoryReclamationHitchCount;
281
+ private resetCount;
282
+ private resourceLoadCount;
283
+ private readonly resourceLoads;
284
+ private schedulerInterruptionCount;
285
+ private totalFrameMs;
286
+ private totalInterruptedFrameMs;
287
+ private totalRenderWorkMs;
288
+ private totalUpdateWorkMs;
289
+ private worstFrameMs;
290
+ private worstFrameSample;
291
+ private worstInterruptedFrameMs;
292
+ private worstLongAnimationFrameMs;
293
+ private worstLongTaskMs;
294
+ private worstResourceLoadMs;
295
+ private worstRenderWorkMs;
296
+ private worstUpdateWorkMs;
297
+ private readonly hitchThresholdMs;
298
+ private readonly workThresholdMs;
299
+ private readonly historyLimit;
300
+ constructor(options?: FrameHitchRecorderOptions);
301
+ /**
302
+ * Record one frame sample.
303
+ *
304
+ * Frames that were hidden, crossed a visibility transition, or are
305
+ * estimated scheduler interruptions are counted in `interruptionCount`
306
+ * (with their time in `interruptedFrameMs`) and excluded from foreground
307
+ * averages, hitches, and foreground worsts — they are never silently
308
+ * dropped. The scheduler estimate is decided at record time from the
309
+ * observations retained at that moment and is final: unlike retained
310
+ * hitches, an observation arriving later does not reclassify it, so the
311
+ * excluded gap stays visible through the interruption counters.
312
+ */
313
+ record(sample: TSample): void;
314
+ /**
315
+ * Record one long animation frame observation. Hidden samples are validated
316
+ * and then ignored. Out-of-order samples are accepted: counts, worsts, and
317
+ * histories account for them, `last*` keeps the newest sample by `atMs`, and
318
+ * eviction drops the oldest by `atMs` so a late buffered sample cannot evict
319
+ * newer evidence. Because diagnoses are recomputed at `snapshot()` time from
320
+ * the observations still retained, a sample arriving after a retained hitch
321
+ * can retroactively change that hitch's cause label until either sample is
322
+ * evicted. Estimated scheduler interruptions, however, are decided at record
323
+ * time and are not re-examined later.
324
+ */
325
+ recordLongAnimationFrame(sample: LongAnimationFrameSample): void;
326
+ /** Record one long task observation. Hidden samples are validated and then ignored. */
327
+ recordLongTask(sample: LongTaskSample): void;
328
+ /** Record one resource load observation. Hidden samples are validated and then ignored. */
329
+ recordResourceLoad(sample: ResourceLoadSample): void;
330
+ /**
331
+ * Estimate the cause of one frame's gap.
332
+ *
333
+ * The verdict is ordered heuristic evidence, not a confirmed root cause:
334
+ *
335
+ * - `game-update` / `phaser-render`: the frame's own update work, or the
336
+ * previous frame's render work, is at least `workThresholdMs` and at
337
+ * least `explanatoryWorkRatio` of the gap. (`phaser-render` names the
338
+ * render callback of the consuming engine; the classification itself is
339
+ * engine-independent.)
340
+ * - `memory-reclamation`: a 100–1000 ms gap with a heap drop at or below
341
+ * `HEAP_RECLAMATION_THRESHOLD_BYTES`. A heap drop alone does not prove a
342
+ * GC pause caused the hitch, and multi-second gaps are excluded because
343
+ * collectors also run while the renderer is suspended.
344
+ * - `main-thread-long-task` / `browser-rendering`: a retained observation
345
+ * overlapping the frame window. Overlap does not prove the observation
346
+ * blocked this frame's main thread.
347
+ * - `scheduler-or-compositor`: the residual estimate when nothing else
348
+ * explains the gap, including long LoAF entries whose reported blocking,
349
+ * script, and style/layout work are a negligible share of the gap
350
+ * (renderer suspension). The absence of observations never proves the
351
+ * game performed well.
352
+ */
353
+ diagnose(sample: TSample): FrameHitchDiagnosis;
354
+ /**
355
+ * Clear every aggregate and retained sample for the next recording window.
356
+ * `resetCount` is the only field that survives: it increments monotonically
357
+ * so diagnostics can observe an accepted reset without timing guesses. Old
358
+ * observations cannot leak into the new window because all observation
359
+ * histories are cleared too.
360
+ */
361
+ reset(): void;
362
+ /** Monotonic reset marker; equals `snapshot().resetCount`. */
363
+ getResetCount(): number;
364
+ snapshot(): FramePerformanceSnapshot<TSample>;
365
+ /**
366
+ * Find the most explanatory observation overlapping a frame's gap window.
367
+ *
368
+ * The window is `[updateStart - frameDeltaMs, updateStart]`, where
369
+ * `updateStart = atMs - updateWorkMs` is where this frame's update callback
370
+ * began. Work inside the update callback itself is deliberately excluded:
371
+ * it is already measured by `updateWorkMs` and diagnosed as `game-update`,
372
+ * and work after `atMs` belongs to the next frame. Anchoring the gap at the
373
+ * update start rather than at `atMs` keeps the previous frame's render and
374
+ * any pre-update blocking associated with this gap without absorbing
375
+ * neighboring frames' work.
376
+ */
377
+ private findRelated;
378
+ private isMemoryReclamationHitch;
379
+ /**
380
+ * Keep a retained sample history ordered by `atMs` and bounded, so eviction
381
+ * always drops the oldest sample by event time rather than by arrival
382
+ * order. Mostly-sorted pushes make the sort near-linear; the sort runs only
383
+ * when a sample is actually recorded, never per frame.
384
+ */
385
+ private trimHistoryByAtMs;
386
+ }
387
+ /**
388
+ * Structural subset of `Performance` carrying the Chromium-only `memory`
389
+ * extension. Accepting the structural shape (instead of the DOM type) keeps
390
+ * this package importable in DOM-free environments; the caller owns how the
391
+ * source object is obtained.
392
+ */
393
+ export interface HeapMemorySource {
394
+ readonly memory?: {
395
+ readonly usedJSHeapSize?: number;
396
+ };
397
+ }
398
+ /** Read the used JS heap size from a supplied source; `undefined` when unavailable or non-finite. */
399
+ export declare function readUsedHeapBytes(source: HeapMemorySource | undefined): number | undefined;
package/dist/index.js ADDED
@@ -0,0 +1,631 @@
1
+ /**
2
+ * Engine-independent frame performance diagnostics.
3
+ *
4
+ * This module records game-supplied frame samples, estimates causes for frame
5
+ * hitches using optional browser observations, and aggregates bounded
6
+ * snapshots. It installs no observers, reads no platform globals, starts no
7
+ * timers, and performs no I/O: wiring `performance`, `PerformanceObserver`,
8
+ * `document.visibilityState`, engine events, domain fields, and report sinks
9
+ * is the consumer's responsibility.
10
+ *
11
+ * Time contract: every timestamp (`atMs`, `startAtMs`) and duration is in
12
+ * milliseconds on one monotonic, non-decreasing clock supplied by the consumer
13
+ * (for example `performance.now()`). The recorder never reads a clock itself,
14
+ * so tests can drive it with synthetic timestamps.
15
+ */
16
+ export const FRAME_HITCH_CAUSES = [
17
+ 'browser-rendering',
18
+ 'game-update',
19
+ 'main-thread-long-task',
20
+ 'memory-reclamation',
21
+ 'phaser-render',
22
+ 'resource-load',
23
+ 'scheduler-or-compositor',
24
+ ];
25
+ /** Frames whose gap reaches this threshold are hitches. */
26
+ export const FRAME_HITCH_THRESHOLD_MS = 50;
27
+ /**
28
+ * Update or render work reaching this threshold marks a hitch even when the
29
+ * frame gap itself stayed below `FRAME_HITCH_THRESHOLD_MS`.
30
+ */
31
+ export const FRAME_WORK_THRESHOLD_MS = 20;
32
+ /**
33
+ * Heap-reclamation estimation only applies to gaps of at least this length;
34
+ * shorter gaps with heap drops stay attributed to whatever other evidence
35
+ * exists, because incidental minor collections are common in healthy frames.
36
+ */
37
+ export const HEAP_RECLAMATION_FRAME_THRESHOLD_MS = 100;
38
+ /**
39
+ * A heap drop at or below this value (that is, a large collection) is
40
+ * evidence for `memory-reclamation` — it does not prove a major GC pause was
41
+ * the hitch's root cause.
42
+ */
43
+ export const HEAP_RECLAMATION_THRESHOLD_BYTES = -10 * 1024 * 1024;
44
+ /**
45
+ * Gaps of at least this length with no explanatory game, render, or
46
+ * observation work are estimated as `scheduler-or-compositor` interruptions.
47
+ */
48
+ export const SCHEDULER_INTERRUPTION_THRESHOLD_MS = 1000;
49
+ /** Default retained history length per sample list. */
50
+ export const FRAME_HITCH_HISTORY_LIMIT = 12;
51
+ /** Upper bound accepted for `historyLimit`. `snapshot()` cost grows with retained hitches times retained observations, so the bound caps the worst case rather than the defaults. */
52
+ export const MAX_FRAME_HITCH_HISTORY_LIMIT = 1000;
53
+ /** Maximum script attributions retained per long animation frame sample. */
54
+ export const MAX_LONG_ANIMATION_FRAME_SCRIPT_SAMPLES = 8;
55
+ /** Maximum raw script timings accepted by `createLongAnimationFrameScriptSamples`. */
56
+ export const MAX_LONG_ANIMATION_FRAME_SCRIPT_TIMINGS = 1024;
57
+ /**
58
+ * Retain only the longest script attributions so diagnostic reports stay
59
+ * predictably bounded. `sourceLabel` is the sanitization boundary: raw
60
+ * observer URLs (which may carry query strings or tokens) must be reduced to
61
+ * a safe label here and are never stored verbatim.
62
+ */
63
+ export function createLongAnimationFrameScriptSamples(scripts, sourceLabel) {
64
+ if (!Array.isArray(scripts)) {
65
+ throw new Error(`LongAnimationFrameScriptTiming[] must be an array (received ${describeValue(scripts)}).`);
66
+ }
67
+ if (scripts.length > MAX_LONG_ANIMATION_FRAME_SCRIPT_TIMINGS) {
68
+ throw new Error(`LongAnimationFrameScriptTiming[] length ${scripts.length} exceeds ${MAX_LONG_ANIMATION_FRAME_SCRIPT_TIMINGS}.`);
69
+ }
70
+ if (typeof sourceLabel !== 'function') {
71
+ throw new Error(`sourceLabel must be a function (received ${describeValue(sourceLabel)}).`);
72
+ }
73
+ for (const script of scripts) {
74
+ assertScriptTimingShape(script);
75
+ if (script.duration !== undefined) {
76
+ assertFiniteNonNegativeNumber(script.duration, 'LongAnimationFrameScriptTiming.duration');
77
+ }
78
+ if (script.forcedStyleAndLayoutDuration !== undefined) {
79
+ assertFiniteNonNegativeNumber(script.forcedStyleAndLayoutDuration, 'LongAnimationFrameScriptTiming.forcedStyleAndLayoutDuration');
80
+ }
81
+ if (script.pauseDuration !== undefined) {
82
+ assertFiniteNonNegativeNumber(script.pauseDuration, 'LongAnimationFrameScriptTiming.pauseDuration');
83
+ }
84
+ if (script.invoker !== undefined) {
85
+ assertString(script.invoker, 'LongAnimationFrameScriptTiming.invoker');
86
+ }
87
+ if (script.invokerType !== undefined) {
88
+ assertString(script.invokerType, 'LongAnimationFrameScriptTiming.invokerType');
89
+ }
90
+ if (script.sourceFunctionName !== undefined) {
91
+ assertString(script.sourceFunctionName, 'LongAnimationFrameScriptTiming.sourceFunctionName');
92
+ }
93
+ if (script.sourceURL !== undefined) {
94
+ assertString(script.sourceURL, 'LongAnimationFrameScriptTiming.sourceURL');
95
+ }
96
+ }
97
+ const longestScripts = scripts
98
+ .map((script) => ({ durationMs: script.duration ?? 0, script }))
99
+ .sort((left, right) => right.durationMs - left.durationMs)
100
+ .slice(0, MAX_LONG_ANIMATION_FRAME_SCRIPT_SAMPLES);
101
+ return longestScripts.map(({ durationMs, script }) => {
102
+ const labeledSourceUrl = script.sourceURL === undefined || script.sourceURL.length === 0
103
+ ? ''
104
+ : sourceLabel(script.sourceURL);
105
+ assertString(labeledSourceUrl, 'sourceLabel(sourceUrl) result');
106
+ return {
107
+ durationMs,
108
+ forcedStyleAndLayoutDurationMs: script.forcedStyleAndLayoutDuration ?? 0,
109
+ invoker: script.invoker ?? '',
110
+ invokerType: script.invokerType ?? '',
111
+ pauseDurationMs: script.pauseDuration ?? 0,
112
+ sourceFunctionName: script.sourceFunctionName ?? '',
113
+ sourceUrl: labeledSourceUrl,
114
+ };
115
+ });
116
+ }
117
+ export class FrameHitchRecorder {
118
+ static browserWorkShareLimit = 0.1;
119
+ static explanatoryWorkRatio = 0.35;
120
+ static relatedSampleOverlapToleranceMs = 16;
121
+ static scriptDominanceFloorMs = 20;
122
+ frameCount = 0;
123
+ hitchCount = 0;
124
+ hitches = [];
125
+ interruptionCount = 0;
126
+ lastFrame;
127
+ longAnimationFrames = [];
128
+ lastLongAnimationFrame;
129
+ lastLongTask;
130
+ lastResourceLoad;
131
+ longAnimationFrameCount = 0;
132
+ longTasks = [];
133
+ longTaskCount = 0;
134
+ memoryReclamationHitchCount = 0;
135
+ resetCount = 0;
136
+ resourceLoadCount = 0;
137
+ resourceLoads = [];
138
+ schedulerInterruptionCount = 0;
139
+ totalFrameMs = 0;
140
+ totalInterruptedFrameMs = 0;
141
+ totalRenderWorkMs = 0;
142
+ totalUpdateWorkMs = 0;
143
+ worstFrameMs = 0;
144
+ worstFrameSample;
145
+ worstInterruptedFrameMs = 0;
146
+ worstLongAnimationFrameMs = 0;
147
+ worstLongTaskMs = 0;
148
+ worstResourceLoadMs = 0;
149
+ worstRenderWorkMs = 0;
150
+ worstUpdateWorkMs = 0;
151
+ hitchThresholdMs;
152
+ workThresholdMs;
153
+ historyLimit;
154
+ constructor(options = {}) {
155
+ const hitchThresholdMs = options.hitchThresholdMs ?? FRAME_HITCH_THRESHOLD_MS;
156
+ const workThresholdMs = options.workThresholdMs ?? FRAME_WORK_THRESHOLD_MS;
157
+ const historyLimit = options.historyLimit ?? FRAME_HITCH_HISTORY_LIMIT;
158
+ if (!Number.isFinite(hitchThresholdMs) || hitchThresholdMs <= 0) {
159
+ throw new Error(`FrameHitchRecorderOptions.hitchThresholdMs must be a finite positive number (received ${describeValue(hitchThresholdMs)}).`);
160
+ }
161
+ if (!Number.isFinite(workThresholdMs) || workThresholdMs <= 0) {
162
+ throw new Error(`FrameHitchRecorderOptions.workThresholdMs must be a finite positive number (received ${describeValue(workThresholdMs)}).`);
163
+ }
164
+ if (!Number.isInteger(historyLimit)
165
+ || historyLimit < 0
166
+ || historyLimit > MAX_FRAME_HITCH_HISTORY_LIMIT) {
167
+ throw new Error(`FrameHitchRecorderOptions.historyLimit must be an integer between 0 and ${MAX_FRAME_HITCH_HISTORY_LIMIT} (received ${describeValue(historyLimit)}).`);
168
+ }
169
+ this.hitchThresholdMs = hitchThresholdMs;
170
+ this.workThresholdMs = workThresholdMs;
171
+ this.historyLimit = historyLimit;
172
+ }
173
+ /**
174
+ * Record one frame sample.
175
+ *
176
+ * Frames that were hidden, crossed a visibility transition, or are
177
+ * estimated scheduler interruptions are counted in `interruptionCount`
178
+ * (with their time in `interruptedFrameMs`) and excluded from foreground
179
+ * averages, hitches, and foreground worsts — they are never silently
180
+ * dropped. The scheduler estimate is decided at record time from the
181
+ * observations retained at that moment and is final: unlike retained
182
+ * hitches, an observation arriving later does not reclassify it, so the
183
+ * excluded gap stays visible through the interruption counters.
184
+ */
185
+ record(sample) {
186
+ validateFrameHitchSample(sample);
187
+ this.frameCount += 1;
188
+ if (this.lastFrame === undefined || sample.atMs >= this.lastFrame.atMs) {
189
+ this.lastFrame = sample;
190
+ }
191
+ // Diagnose once per record: the verdict is deterministic for unchanged
192
+ // recorder state, and re-scanning the observation histories would bill
193
+ // the frame loop of an already-hitching game twice.
194
+ let diagnosis;
195
+ const schedulerCandidate = !sample.hidden
196
+ && !sample.visibilityInterrupted
197
+ && sample.frameDeltaMs >= SCHEDULER_INTERRUPTION_THRESHOLD_MS;
198
+ if (schedulerCandidate) {
199
+ diagnosis = this.diagnose(sample);
200
+ }
201
+ const schedulerInterrupted = schedulerCandidate
202
+ && diagnosis !== undefined
203
+ && diagnosis.cause === 'scheduler-or-compositor';
204
+ if (sample.hidden || sample.visibilityInterrupted || schedulerInterrupted) {
205
+ this.interruptionCount += 1;
206
+ this.schedulerInterruptionCount += Number(schedulerInterrupted);
207
+ this.totalInterruptedFrameMs += sample.frameDeltaMs;
208
+ this.worstInterruptedFrameMs = Math.max(this.worstInterruptedFrameMs, sample.frameDeltaMs);
209
+ return;
210
+ }
211
+ if (sample.frameDeltaMs > this.worstFrameMs) {
212
+ this.worstFrameMs = sample.frameDeltaMs;
213
+ this.worstFrameSample = sample;
214
+ }
215
+ this.worstRenderWorkMs = Math.max(this.worstRenderWorkMs, sample.previousRenderWorkMs);
216
+ this.worstUpdateWorkMs = Math.max(this.worstUpdateWorkMs, sample.updateWorkMs);
217
+ this.totalFrameMs += sample.frameDeltaMs;
218
+ this.totalRenderWorkMs += sample.previousRenderWorkMs;
219
+ this.totalUpdateWorkMs += sample.updateWorkMs;
220
+ if (sample.frameDeltaMs < this.hitchThresholdMs
221
+ && sample.previousRenderWorkMs < this.workThresholdMs
222
+ && sample.updateWorkMs < this.workThresholdMs) {
223
+ return;
224
+ }
225
+ this.hitchCount += 1;
226
+ diagnosis ??= this.diagnose(sample);
227
+ if (diagnosis.cause === 'memory-reclamation') {
228
+ this.memoryReclamationHitchCount += 1;
229
+ }
230
+ this.hitches.push(sample);
231
+ this.trimHistoryByAtMs(this.hitches);
232
+ }
233
+ /**
234
+ * Record one long animation frame observation. Hidden samples are validated
235
+ * and then ignored. Out-of-order samples are accepted: counts, worsts, and
236
+ * histories account for them, `last*` keeps the newest sample by `atMs`, and
237
+ * eviction drops the oldest by `atMs` so a late buffered sample cannot evict
238
+ * newer evidence. Because diagnoses are recomputed at `snapshot()` time from
239
+ * the observations still retained, a sample arriving after a retained hitch
240
+ * can retroactively change that hitch's cause label until either sample is
241
+ * evicted. Estimated scheduler interruptions, however, are decided at record
242
+ * time and are not re-examined later.
243
+ */
244
+ recordLongAnimationFrame(sample) {
245
+ validateLongAnimationFrameSample(sample);
246
+ if (sample.hidden) {
247
+ return;
248
+ }
249
+ if (this.lastLongAnimationFrame === undefined
250
+ || sample.atMs >= this.lastLongAnimationFrame.atMs) {
251
+ this.lastLongAnimationFrame = sample;
252
+ }
253
+ this.longAnimationFrameCount += 1;
254
+ this.worstLongAnimationFrameMs = Math.max(this.worstLongAnimationFrameMs, sample.durationMs);
255
+ this.longAnimationFrames.push(sample);
256
+ this.trimHistoryByAtMs(this.longAnimationFrames);
257
+ }
258
+ /** Record one long task observation. Hidden samples are validated and then ignored. */
259
+ recordLongTask(sample) {
260
+ validateLongTaskSample(sample);
261
+ if (sample.hidden) {
262
+ return;
263
+ }
264
+ if (this.lastLongTask === undefined || sample.atMs >= this.lastLongTask.atMs) {
265
+ this.lastLongTask = sample;
266
+ }
267
+ this.longTaskCount += 1;
268
+ this.worstLongTaskMs = Math.max(this.worstLongTaskMs, sample.durationMs);
269
+ this.longTasks.push(sample);
270
+ this.trimHistoryByAtMs(this.longTasks);
271
+ }
272
+ /** Record one resource load observation. Hidden samples are validated and then ignored. */
273
+ recordResourceLoad(sample) {
274
+ validateResourceLoadSample(sample);
275
+ if (sample.hidden) {
276
+ return;
277
+ }
278
+ if (this.lastResourceLoad === undefined || sample.atMs >= this.lastResourceLoad.atMs) {
279
+ this.lastResourceLoad = sample;
280
+ }
281
+ this.resourceLoadCount += 1;
282
+ this.worstResourceLoadMs = Math.max(this.worstResourceLoadMs, sample.durationMs);
283
+ this.resourceLoads.push(sample);
284
+ this.trimHistoryByAtMs(this.resourceLoads);
285
+ }
286
+ /**
287
+ * Estimate the cause of one frame's gap.
288
+ *
289
+ * The verdict is ordered heuristic evidence, not a confirmed root cause:
290
+ *
291
+ * - `game-update` / `phaser-render`: the frame's own update work, or the
292
+ * previous frame's render work, is at least `workThresholdMs` and at
293
+ * least `explanatoryWorkRatio` of the gap. (`phaser-render` names the
294
+ * render callback of the consuming engine; the classification itself is
295
+ * engine-independent.)
296
+ * - `memory-reclamation`: a 100–1000 ms gap with a heap drop at or below
297
+ * `HEAP_RECLAMATION_THRESHOLD_BYTES`. A heap drop alone does not prove a
298
+ * GC pause caused the hitch, and multi-second gaps are excluded because
299
+ * collectors also run while the renderer is suspended.
300
+ * - `main-thread-long-task` / `browser-rendering`: a retained observation
301
+ * overlapping the frame window. Overlap does not prove the observation
302
+ * blocked this frame's main thread.
303
+ * - `scheduler-or-compositor`: the residual estimate when nothing else
304
+ * explains the gap, including long LoAF entries whose reported blocking,
305
+ * script, and style/layout work are a negligible share of the gap
306
+ * (renderer suspension). The absence of observations never proves the
307
+ * game performed well.
308
+ */
309
+ diagnose(sample) {
310
+ validateFrameHitchSample(sample);
311
+ const base = {
312
+ atMs: sample.atMs,
313
+ frameDeltaMs: sample.frameDeltaMs,
314
+ };
315
+ const explanatoryWorkMs = sample.frameDeltaMs * FrameHitchRecorder.explanatoryWorkRatio;
316
+ if (sample.updateWorkMs >= this.workThresholdMs
317
+ && sample.updateWorkMs >= explanatoryWorkMs) {
318
+ return { ...base, cause: 'game-update' };
319
+ }
320
+ if (sample.previousRenderWorkMs >= this.workThresholdMs
321
+ && sample.previousRenderWorkMs >= explanatoryWorkMs) {
322
+ return { ...base, cause: 'phaser-render' };
323
+ }
324
+ if (this.isMemoryReclamationHitch(sample)) {
325
+ return { ...base, cause: 'memory-reclamation' };
326
+ }
327
+ const relatedLongTask = this.findRelated(sample, this.longTasks);
328
+ if (relatedLongTask !== undefined
329
+ && relatedLongTask.durationMs >= explanatoryWorkMs) {
330
+ return {
331
+ ...base,
332
+ cause: 'main-thread-long-task',
333
+ relatedLongTaskMs: relatedLongTask.durationMs,
334
+ };
335
+ }
336
+ const relatedLongAnimationFrame = this.findRelated(sample, this.longAnimationFrames);
337
+ if (relatedLongAnimationFrame !== undefined) {
338
+ const browserWorkMs = Math.max(relatedLongAnimationFrame.blockingDurationMs, relatedLongAnimationFrame.scriptDurationMs, relatedLongAnimationFrame.styleAndLayoutDurationMs);
339
+ // A renderer can emit a LoAF whose duration spans an occluded or
340
+ // suspended interval while reporting essentially no blocking, script,
341
+ // style, or layout work. That is a browser scheduling interruption, not
342
+ // evidence that the game spent the whole gap rendering.
343
+ if (sample.frameDeltaMs >= SCHEDULER_INTERRUPTION_THRESHOLD_MS
344
+ && browserWorkMs
345
+ < sample.frameDeltaMs * FrameHitchRecorder.browserWorkShareLimit) {
346
+ return {
347
+ ...base,
348
+ cause: 'scheduler-or-compositor',
349
+ relatedLongAnimationFrameMs: relatedLongAnimationFrame.durationMs,
350
+ };
351
+ }
352
+ const scriptDominated = relatedLongAnimationFrame.scriptDurationMs
353
+ >= FrameHitchRecorder.scriptDominanceFloorMs
354
+ && relatedLongAnimationFrame.scriptDurationMs
355
+ >= relatedLongAnimationFrame.renderDurationMs;
356
+ return {
357
+ ...base,
358
+ cause: scriptDominated ? 'main-thread-long-task' : 'browser-rendering',
359
+ relatedLongAnimationFrameMs: relatedLongAnimationFrame.durationMs,
360
+ };
361
+ }
362
+ const relatedResourceLoad = this.findRelated(sample, this.resourceLoads);
363
+ if (relatedResourceLoad !== undefined
364
+ && relatedResourceLoad.durationMs >= explanatoryWorkMs) {
365
+ return {
366
+ ...base,
367
+ cause: 'resource-load',
368
+ relatedResourceLoadMs: relatedResourceLoad.durationMs,
369
+ };
370
+ }
371
+ return { ...base, cause: 'scheduler-or-compositor' };
372
+ }
373
+ /**
374
+ * Clear every aggregate and retained sample for the next recording window.
375
+ * `resetCount` is the only field that survives: it increments monotonically
376
+ * so diagnostics can observe an accepted reset without timing guesses. Old
377
+ * observations cannot leak into the new window because all observation
378
+ * histories are cleared too.
379
+ */
380
+ reset() {
381
+ this.resetCount += 1;
382
+ this.frameCount = 0;
383
+ this.hitchCount = 0;
384
+ this.hitches.length = 0;
385
+ this.interruptionCount = 0;
386
+ this.lastFrame = undefined;
387
+ this.lastLongAnimationFrame = undefined;
388
+ this.lastLongTask = undefined;
389
+ this.lastResourceLoad = undefined;
390
+ this.longAnimationFrameCount = 0;
391
+ this.longAnimationFrames.length = 0;
392
+ this.longTasks.length = 0;
393
+ this.longTaskCount = 0;
394
+ this.memoryReclamationHitchCount = 0;
395
+ this.resourceLoadCount = 0;
396
+ this.resourceLoads.length = 0;
397
+ this.schedulerInterruptionCount = 0;
398
+ this.totalFrameMs = 0;
399
+ this.totalInterruptedFrameMs = 0;
400
+ this.totalRenderWorkMs = 0;
401
+ this.totalUpdateWorkMs = 0;
402
+ this.worstFrameMs = 0;
403
+ this.worstFrameSample = undefined;
404
+ this.worstInterruptedFrameMs = 0;
405
+ this.worstLongAnimationFrameMs = 0;
406
+ this.worstLongTaskMs = 0;
407
+ this.worstResourceLoadMs = 0;
408
+ this.worstRenderWorkMs = 0;
409
+ this.worstUpdateWorkMs = 0;
410
+ }
411
+ /** Monotonic reset marker; equals `snapshot().resetCount`. */
412
+ getResetCount() {
413
+ return this.resetCount;
414
+ }
415
+ snapshot() {
416
+ const foregroundFrameCount = this.frameCount - this.interruptionCount;
417
+ const averageOverForegroundFrames = (totalMs) => foregroundFrameCount === 0 ? 0 : totalMs / foregroundFrameCount;
418
+ const hitchDiagnoses = this.hitches.map((sample) => this.diagnose(sample));
419
+ const hitchCauseCounts = createEmptyHitchCauseCounts();
420
+ for (const diagnosis of hitchDiagnoses) {
421
+ hitchCauseCounts[diagnosis.cause] += 1;
422
+ }
423
+ const lastHitchDiagnosis = hitchDiagnoses.at(-1);
424
+ const worstHitchDiagnosis = this.worstFrameSample === undefined
425
+ || this.worstFrameMs < this.hitchThresholdMs
426
+ ? undefined
427
+ : this.diagnose(this.worstFrameSample);
428
+ return {
429
+ averageFrameMs: averageOverForegroundFrames(this.totalFrameMs),
430
+ averageRenderWorkMs: averageOverForegroundFrames(this.totalRenderWorkMs),
431
+ averageUpdateWorkMs: averageOverForegroundFrames(this.totalUpdateWorkMs),
432
+ foregroundFrameCount,
433
+ frameCount: this.frameCount,
434
+ hitchCount: this.hitchCount,
435
+ hitches: this.hitches.map((sample) => ({ ...sample })),
436
+ hitchCauseCounts,
437
+ interruptedFrameMs: this.totalInterruptedFrameMs,
438
+ interruptionCount: this.interruptionCount,
439
+ ...(this.lastFrame === undefined ? {} : { lastFrame: { ...this.lastFrame } }),
440
+ ...(lastHitchDiagnosis === undefined ? {} : { lastHitchDiagnosis }),
441
+ ...(this.lastLongAnimationFrame === undefined
442
+ ? {}
443
+ : { lastLongAnimationFrame: cloneLongAnimationFrameSample(this.lastLongAnimationFrame) }),
444
+ ...(this.lastLongTask === undefined
445
+ ? {}
446
+ : { lastLongTask: { ...this.lastLongTask } }),
447
+ ...(this.lastResourceLoad === undefined
448
+ ? {}
449
+ : { lastResourceLoad: { ...this.lastResourceLoad } }),
450
+ longAnimationFrameCount: this.longAnimationFrameCount,
451
+ longTaskCount: this.longTaskCount,
452
+ memoryReclamationHitchCount: this.memoryReclamationHitchCount,
453
+ resetCount: this.resetCount,
454
+ resourceLoadCount: this.resourceLoadCount,
455
+ schedulerInterruptionCount: this.schedulerInterruptionCount,
456
+ worstFrameMs: this.worstFrameMs,
457
+ ...(worstHitchDiagnosis === undefined ? {} : { worstHitchDiagnosis }),
458
+ worstInterruptedFrameMs: this.worstInterruptedFrameMs,
459
+ worstLongAnimationFrameMs: this.worstLongAnimationFrameMs,
460
+ worstLongTaskMs: this.worstLongTaskMs,
461
+ worstResourceLoadMs: this.worstResourceLoadMs,
462
+ worstRenderWorkMs: this.worstRenderWorkMs,
463
+ worstUpdateWorkMs: this.worstUpdateWorkMs,
464
+ };
465
+ }
466
+ /**
467
+ * Find the most explanatory observation overlapping a frame's gap window.
468
+ *
469
+ * The window is `[updateStart - frameDeltaMs, updateStart]`, where
470
+ * `updateStart = atMs - updateWorkMs` is where this frame's update callback
471
+ * began. Work inside the update callback itself is deliberately excluded:
472
+ * it is already measured by `updateWorkMs` and diagnosed as `game-update`,
473
+ * and work after `atMs` belongs to the next frame. Anchoring the gap at the
474
+ * update start rather than at `atMs` keeps the previous frame's render and
475
+ * any pre-update blocking associated with this gap without absorbing
476
+ * neighboring frames' work.
477
+ */
478
+ findRelated(frame, samples) {
479
+ const updateStartedAtMs = frame.atMs - frame.updateWorkMs;
480
+ const frameWindowStartedAtMs = updateStartedAtMs - frame.frameDeltaMs;
481
+ const overlapToleranceMs = FrameHitchRecorder.relatedSampleOverlapToleranceMs;
482
+ let mostExplanatory;
483
+ for (const sample of samples) {
484
+ const overlaps = sample.atMs >= frameWindowStartedAtMs - overlapToleranceMs
485
+ && sample.startAtMs <= updateStartedAtMs + overlapToleranceMs;
486
+ if (overlaps
487
+ && (mostExplanatory === undefined || sample.durationMs >= mostExplanatory.durationMs)) {
488
+ mostExplanatory = sample;
489
+ }
490
+ }
491
+ return mostExplanatory;
492
+ }
493
+ isMemoryReclamationHitch(sample) {
494
+ // A multi-second scheduler or occlusion gap can trigger collection while
495
+ // suspended; trust heap correlation only for sub-second gaps so that
496
+ // incidental collection stays an interruption.
497
+ return sample.frameDeltaMs >= HEAP_RECLAMATION_FRAME_THRESHOLD_MS
498
+ && sample.frameDeltaMs < SCHEDULER_INTERRUPTION_THRESHOLD_MS
499
+ && (sample.heapDeltaBytes ?? 0) <= HEAP_RECLAMATION_THRESHOLD_BYTES;
500
+ }
501
+ /**
502
+ * Keep a retained sample history ordered by `atMs` and bounded, so eviction
503
+ * always drops the oldest sample by event time rather than by arrival
504
+ * order. Mostly-sorted pushes make the sort near-linear; the sort runs only
505
+ * when a sample is actually recorded, never per frame.
506
+ */
507
+ trimHistoryByAtMs(samples) {
508
+ const previous = samples.at(-2);
509
+ if (previous !== undefined && previous.atMs > samples.at(-1).atMs) {
510
+ samples.sort((left, right) => left.atMs - right.atMs);
511
+ }
512
+ if (samples.length > this.historyLimit) {
513
+ samples.splice(0, samples.length - this.historyLimit);
514
+ }
515
+ }
516
+ }
517
+ function createEmptyHitchCauseCounts() {
518
+ return Object.fromEntries(FRAME_HITCH_CAUSES.map((cause) => [cause, 0]));
519
+ }
520
+ function cloneLongAnimationFrameSample(sample) {
521
+ if (sample.scripts === undefined) {
522
+ return { ...sample };
523
+ }
524
+ return { ...sample, scripts: [...sample.scripts] };
525
+ }
526
+ /** Read the used JS heap size from a supplied source; `undefined` when unavailable or non-finite. */
527
+ export function readUsedHeapBytes(source) {
528
+ const usedHeap = source?.memory?.usedJSHeapSize;
529
+ return usedHeap === undefined || !Number.isFinite(usedHeap) ? undefined : usedHeap;
530
+ }
531
+ function validateFrameHitchSample(sample) {
532
+ assertFiniteNonNegativeNumber(sample.atMs, 'FrameHitchSample.atMs');
533
+ assertFiniteNonNegativeNumber(sample.frameDeltaMs, 'FrameHitchSample.frameDeltaMs');
534
+ assertFiniteNonNegativeNumber(sample.previousRenderWorkMs, 'FrameHitchSample.previousRenderWorkMs');
535
+ assertFiniteNonNegativeNumber(sample.updateWorkMs, 'FrameHitchSample.updateWorkMs');
536
+ assertBoolean(sample.hidden, 'FrameHitchSample.hidden');
537
+ assertBoolean(sample.visibilityInterrupted, 'FrameHitchSample.visibilityInterrupted');
538
+ if (sample.heapBytes !== undefined) {
539
+ assertFiniteNonNegativeNumber(sample.heapBytes, 'FrameHitchSample.heapBytes');
540
+ }
541
+ if (sample.heapDeltaBytes !== undefined) {
542
+ // Heap deltas are signed by design: a large negative delta is a normal
543
+ // collection signal, not an invalid duration.
544
+ assertFiniteNumber(sample.heapDeltaBytes, 'FrameHitchSample.heapDeltaBytes');
545
+ }
546
+ }
547
+ function validateLongTaskSample(sample) {
548
+ validateObservationTiming(sample, 'LongTaskSample');
549
+ assertBoolean(sample.hidden, 'LongTaskSample.hidden');
550
+ assertString(sample.name, 'LongTaskSample.name');
551
+ }
552
+ function validateLongAnimationFrameSample(sample) {
553
+ validateObservationTiming(sample, 'LongAnimationFrameSample');
554
+ assertBoolean(sample.hidden, 'LongAnimationFrameSample.hidden');
555
+ assertFiniteNonNegativeNumber(sample.blockingDurationMs, 'LongAnimationFrameSample.blockingDurationMs');
556
+ assertFiniteNonNegativeNumber(sample.renderDurationMs, 'LongAnimationFrameSample.renderDurationMs');
557
+ assertFiniteNonNegativeNumber(sample.scriptDurationMs, 'LongAnimationFrameSample.scriptDurationMs');
558
+ assertFiniteNonNegativeNumber(sample.styleAndLayoutDurationMs, 'LongAnimationFrameSample.styleAndLayoutDurationMs');
559
+ if (!Number.isInteger(sample.scriptInvocations) || sample.scriptInvocations < 0) {
560
+ throw new Error(`LongAnimationFrameSample.scriptInvocations must be a non-negative integer (received ${describeValue(sample.scriptInvocations)}).`);
561
+ }
562
+ if (sample.scripts !== undefined) {
563
+ if (!Array.isArray(sample.scripts)) {
564
+ throw new Error(`LongAnimationFrameSample.scripts must be an array (received ${describeValue(sample.scripts)}).`);
565
+ }
566
+ if (sample.scripts.length > MAX_LONG_ANIMATION_FRAME_SCRIPT_SAMPLES) {
567
+ throw new Error(`LongAnimationFrameSample.scripts length ${sample.scripts.length} exceeds ${MAX_LONG_ANIMATION_FRAME_SCRIPT_SAMPLES}.`);
568
+ }
569
+ for (const script of sample.scripts) {
570
+ assertScriptSampleShape(script);
571
+ validateLongAnimationFrameScriptSample(script);
572
+ }
573
+ }
574
+ }
575
+ /** Reject non-object entries before field validation dereferences them. */
576
+ function assertScriptTimingShape(script) {
577
+ if (typeof script !== 'object' || script === null) {
578
+ throw new Error(`LongAnimationFrameScriptTiming entry must be an object (received ${describeValue(script)}).`);
579
+ }
580
+ }
581
+ /** Reject non-object entries before field validation dereferences them. */
582
+ function assertScriptSampleShape(script) {
583
+ if (typeof script !== 'object' || script === null) {
584
+ throw new Error(`LongAnimationFrameScriptSample entry must be an object (received ${describeValue(script)}).`);
585
+ }
586
+ }
587
+ function validateLongAnimationFrameScriptSample(script) {
588
+ assertFiniteNonNegativeNumber(script.durationMs, 'LongAnimationFrameScriptSample.durationMs');
589
+ assertFiniteNonNegativeNumber(script.forcedStyleAndLayoutDurationMs, 'LongAnimationFrameScriptSample.forcedStyleAndLayoutDurationMs');
590
+ assertFiniteNonNegativeNumber(script.pauseDurationMs, 'LongAnimationFrameScriptSample.pauseDurationMs');
591
+ assertString(script.invoker, 'LongAnimationFrameScriptSample.invoker');
592
+ assertString(script.invokerType, 'LongAnimationFrameScriptSample.invokerType');
593
+ assertString(script.sourceFunctionName, 'LongAnimationFrameScriptSample.sourceFunctionName');
594
+ assertString(script.sourceUrl, 'LongAnimationFrameScriptSample.sourceUrl');
595
+ }
596
+ function validateResourceLoadSample(sample) {
597
+ validateObservationTiming(sample, 'ResourceLoadSample');
598
+ assertBoolean(sample.hidden, 'ResourceLoadSample.hidden');
599
+ assertString(sample.name, 'ResourceLoadSample.name');
600
+ }
601
+ function validateObservationTiming(sample, label) {
602
+ assertFiniteNonNegativeNumber(sample.startAtMs, `${label}.startAtMs`);
603
+ assertFiniteNonNegativeNumber(sample.atMs, `${label}.atMs`);
604
+ assertFiniteNonNegativeNumber(sample.durationMs, `${label}.durationMs`);
605
+ if (sample.atMs < sample.startAtMs) {
606
+ throw new Error(`${label}.atMs must not precede ${label}.startAtMs.`);
607
+ }
608
+ }
609
+ function assertFiniteNonNegativeNumber(value, field) {
610
+ if (!Number.isFinite(value) || value < 0) {
611
+ throw new Error(`${field} must be a finite non-negative number (received ${describeValue(value)}).`);
612
+ }
613
+ }
614
+ function assertFiniteNumber(value, field) {
615
+ if (!Number.isFinite(value)) {
616
+ throw new Error(`${field} must be a finite number (received ${describeValue(value)}).`);
617
+ }
618
+ }
619
+ function assertBoolean(value, field) {
620
+ if (typeof value !== 'boolean') {
621
+ throw new Error(`${field} must be a boolean (received ${describeValue(value)}).`);
622
+ }
623
+ }
624
+ function assertString(value, field) {
625
+ if (typeof value !== 'string') {
626
+ throw new Error(`${field} must be a string (received ${describeValue(value)}).`);
627
+ }
628
+ }
629
+ function describeValue(value) {
630
+ return typeof value === 'string' ? JSON.stringify(value) : String(value);
631
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@mpgd/runtime-diagnostics",
3
+ "version": "0.1.0",
4
+ "description": "Engine-independent frame hitch recording, cause estimation, and bounded performance snapshots for headless game diagnostics.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/imjlk/mpgd-kit.git",
9
+ "directory": "packages/runtime-diagnostics"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/imjlk/mpgd-kit/issues"
13
+ },
14
+ "homepage": "https://github.com/imjlk/mpgd-kit#readme",
15
+ "keywords": [
16
+ "mpgd",
17
+ "phaser",
18
+ "game-development",
19
+ "performance",
20
+ "diagnostics"
21
+ ],
22
+ "type": "module",
23
+ "sideEffects": false,
24
+ "main": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "scripts": {
39
+ "check": "ttsc --noEmit",
40
+ "test": "cd ../.. && node tools/run-ttsx.mjs packages/runtime-diagnostics/src/index.test.ts",
41
+ "lint": "ttsc --noEmit",
42
+ "format": "ttsc format",
43
+ "fix": "ttsc fix"
44
+ },
45
+ "devDependencies": {
46
+ "ttsc": "0.18.4",
47
+ "typescript": "7.0.2"
48
+ }
49
+ }