@forgeax/engine-profiler 0.1.2
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/LICENSE +202 -0
- package/README.md +164 -0
- package/bin/forgeax-engine-profiler.mjs +5 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/browser-profile-detail-owner.test-d.d.ts +2 -0
- package/dist/__tests__/browser-profile-detail-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/browser-user-timing.test.d.ts +2 -0
- package/dist/__tests__/browser-user-timing.test.d.ts.map +1 -0
- package/dist/__tests__/cli-consumer.integration.test.d.ts +2 -0
- package/dist/__tests__/cli-consumer.integration.test.d.ts.map +1 -0
- package/dist/__tests__/cli-contract.test.d.ts +2 -0
- package/dist/__tests__/cli-contract.test.d.ts.map +1 -0
- package/dist/__tests__/cli-input-errors.test.d.ts +2 -0
- package/dist/__tests__/cli-input-errors.test.d.ts.map +1 -0
- package/dist/__tests__/cli-process.integration.test.d.ts +2 -0
- package/dist/__tests__/cli-process.integration.test.d.ts.map +1 -0
- package/dist/__tests__/compare.test.d.ts +2 -0
- package/dist/__tests__/compare.test.d.ts.map +1 -0
- package/dist/__tests__/comparison-side-owner.test-d.d.ts +2 -0
- package/dist/__tests__/comparison-side-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/consumer-smoke.integration.test.d.ts +2 -0
- package/dist/__tests__/consumer-smoke.integration.test.d.ts.map +1 -0
- package/dist/__tests__/generated-freshness.test.d.ts +2 -0
- package/dist/__tests__/generated-freshness.test.d.ts.map +1 -0
- package/dist/__tests__/model-determinism.test.d.ts +2 -0
- package/dist/__tests__/model-determinism.test.d.ts.map +1 -0
- package/dist/__tests__/model-query.test.d.ts +2 -0
- package/dist/__tests__/model-query.test.d.ts.map +1 -0
- package/dist/__tests__/overflow-long-tail.test.d.ts +2 -0
- package/dist/__tests__/overflow-long-tail.test.d.ts.map +1 -0
- package/dist/__tests__/profile-source-owner.test-d.d.ts +2 -0
- package/dist/__tests__/profile-source-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/profiler-error-owner.test-d.d.ts +2 -0
- package/dist/__tests__/profiler-error-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/published-entry.browser.test.d.ts +2 -0
- package/dist/__tests__/published-entry.browser.test.d.ts.map +1 -0
- package/dist/__tests__/recorder-contract.test.d.ts +2 -0
- package/dist/__tests__/recorder-contract.test.d.ts.map +1 -0
- package/dist/__tests__/schema-contract.test.d.ts +2 -0
- package/dist/__tests__/schema-contract.test.d.ts.map +1 -0
- package/dist/__tests__/schema-derived-union-owner.test.d.ts +2 -0
- package/dist/__tests__/schema-derived-union-owner.test.d.ts.map +1 -0
- package/dist/browser-user-timing.d.ts +24 -0
- package/dist/browser-user-timing.d.ts.map +1 -0
- package/dist/browser-user-timing.mjs +140 -0
- package/dist/browser-user-timing.mjs.map +1 -0
- package/dist/cli.d.ts +21 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.mjs +7783 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/clock.d.ts +5 -0
- package/dist/clock.d.ts.map +1 -0
- package/dist/compare.d.ts +42 -0
- package/dist/compare.d.ts.map +1 -0
- package/dist/errors.d.ts +64 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/generated/profile-capture.d.ts +44 -0
- package/dist/generated/profile-capture.d.ts.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +8111 -0
- package/dist/index.mjs.map +1 -0
- package/dist/model.d.ts +44 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/profiler.d.ts +24 -0
- package/dist/profiler.d.ts.map +1 -0
- package/dist/recorder.d.ts +26 -0
- package/dist/recorder.d.ts.map +1 -0
- package/dist/schema.d.ts +13 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/tool-preview-profile.d.ts +33 -0
- package/dist/tool-preview-profile.d.ts.map +1 -0
- package/dist/types.d.ts +44 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +76 -0
- package/schema/profile-capture.schema.json +71 -0
- package/scripts/consume-smoke.mjs +17 -0
- package/scripts/generate-profile-types.mjs +75 -0
- package/src/__tests__/browser-profile-detail-owner.test-d.ts +26 -0
- package/src/__tests__/browser-user-timing.test.ts +127 -0
- package/src/__tests__/cli-consumer.integration.test.ts +113 -0
- package/src/__tests__/cli-contract.test.ts +115 -0
- package/src/__tests__/cli-input-errors.test.ts +210 -0
- package/src/__tests__/cli-process.integration.test.ts +154 -0
- package/src/__tests__/compare.test.ts +223 -0
- package/src/__tests__/comparison-side-owner.test-d.ts +28 -0
- package/src/__tests__/consumer-smoke.integration.test.ts +37 -0
- package/src/__tests__/fixtures/cli/invalid-version.json +18 -0
- package/src/__tests__/fixtures/cli/partial-capture.json +28 -0
- package/src/__tests__/fixtures/cli/valid-capture.json +43 -0
- package/src/__tests__/fixtures/profile-capture/invalid-boundary.json +144 -0
- package/src/__tests__/fixtures/profile-capture/model-input.json +54 -0
- package/src/__tests__/fixtures/profile-capture/sink-failure.json +30 -0
- package/src/__tests__/fixtures/profile-capture/valid-complete.json +43 -0
- package/src/__tests__/fixtures/profile-capture/valid-overflow.json +45 -0
- package/src/__tests__/generated-freshness.test.ts +14 -0
- package/src/__tests__/model-determinism.test.ts +70 -0
- package/src/__tests__/model-query.test.ts +128 -0
- package/src/__tests__/overflow-long-tail.test.ts +131 -0
- package/src/__tests__/profile-source-owner.test-d.ts +45 -0
- package/src/__tests__/profiler-error-owner.test-d.ts +64 -0
- package/src/__tests__/published-entry.browser.test.ts +27 -0
- package/src/__tests__/recorder-contract.test.ts +163 -0
- package/src/__tests__/schema-contract.test.ts +42 -0
- package/src/__tests__/schema-derived-union-owner.test.ts +57 -0
- package/src/browser-user-timing.ts +194 -0
- package/src/cli.ts +425 -0
- package/src/clock.ts +18 -0
- package/src/compare.ts +159 -0
- package/src/errors.ts +78 -0
- package/src/generated/profile-capture.ts +48 -0
- package/src/index.ts +54 -0
- package/src/model.ts +161 -0
- package/src/profiler.ts +201 -0
- package/src/recorder.ts +417 -0
- package/src/schema.ts +168 -0
- package/src/tool-preview-profile.ts +67 -0
- package/src/types.ts +56 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Generated by scripts/generate-profile-types.mjs. Do not edit.
|
|
2
|
+
// Schema version: 1.0
|
|
3
|
+
|
|
4
|
+
export type ProfileSource = 'app' | 'render';
|
|
5
|
+
export type ProfileRecordKind = 'phase' | 'skip';
|
|
6
|
+
export type ProfileCompletenessStatus = 'complete' | 'partial' | 'overflow';
|
|
7
|
+
|
|
8
|
+
export interface ProfilePhaseRecord {
|
|
9
|
+
readonly kind: 'phase';
|
|
10
|
+
readonly source: ProfileSource;
|
|
11
|
+
readonly frameId: number;
|
|
12
|
+
readonly phase: string;
|
|
13
|
+
readonly parentSource?: ProfileSource;
|
|
14
|
+
readonly parentPhase?: string;
|
|
15
|
+
readonly startMicros: number;
|
|
16
|
+
readonly endMicros: number;
|
|
17
|
+
readonly durationMicros: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ProfileSkipRecord {
|
|
21
|
+
readonly kind: 'skip';
|
|
22
|
+
readonly source: ProfileSource;
|
|
23
|
+
readonly frameId: number;
|
|
24
|
+
readonly phase: string;
|
|
25
|
+
readonly reason: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ProfileRecord = ProfilePhaseRecord | ProfileSkipRecord;
|
|
29
|
+
|
|
30
|
+
export interface ProfileCompleteness {
|
|
31
|
+
readonly status: ProfileCompletenessStatus;
|
|
32
|
+
readonly retainedEventCount: number;
|
|
33
|
+
readonly droppedEventCount: number;
|
|
34
|
+
readonly incompleteReason?: string;
|
|
35
|
+
readonly firstAffectedFrameId?: number;
|
|
36
|
+
readonly lastAffectedFrameId?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ProfileCapture {
|
|
40
|
+
readonly schemaVersion: '1.0';
|
|
41
|
+
readonly captureId: string;
|
|
42
|
+
readonly timeUnit: 'microseconds';
|
|
43
|
+
readonly frameLimit: number;
|
|
44
|
+
readonly eventLimit: number;
|
|
45
|
+
readonly phaseCatalog: Readonly<{ app: readonly string[]; render: readonly string[] }>;
|
|
46
|
+
readonly records: readonly ProfileRecord[];
|
|
47
|
+
readonly completeness: ProfileCompleteness;
|
|
48
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Creates the default monotonic microsecond clock. */
|
|
2
|
+
|
|
3
|
+
/** Opt-in browser User Timing projection shared by editor and Play hosts. */
|
|
4
|
+
export {
|
|
5
|
+
createUserTimingProfiler,
|
|
6
|
+
type UserTimingProfilerOptions,
|
|
7
|
+
} from './browser-user-timing.js';
|
|
8
|
+
export { createProfileClock } from './clock.js';
|
|
9
|
+
export type {
|
|
10
|
+
ProfileComparisonError,
|
|
11
|
+
ProfileComparisonPhaseDelta,
|
|
12
|
+
ProfileComparisonPhaseFact,
|
|
13
|
+
ProfileComparisonPhaseIdentity,
|
|
14
|
+
ProfileComparisonPhaseRow,
|
|
15
|
+
ProfileComparisonProjection,
|
|
16
|
+
ProfileComparisonSide,
|
|
17
|
+
} from './compare.js';
|
|
18
|
+
/** Compares two validated captures without adding policy or mutating either input. */
|
|
19
|
+
export { compareProfileCaptures } from './compare.js';
|
|
20
|
+
/** Structured expected-failure vocabulary for profiler operations. */
|
|
21
|
+
export type { ProfilerError, ProfilerErrorCode } from './errors.js';
|
|
22
|
+
export type {
|
|
23
|
+
ProfileFrameModel,
|
|
24
|
+
ProfileModel,
|
|
25
|
+
ProfilePhaseModel,
|
|
26
|
+
ProfileSummaryModel,
|
|
27
|
+
} from './model.js';
|
|
28
|
+
/** Projects a validated capture into offline frame and phase summaries. */
|
|
29
|
+
export { buildProfileModel } from './model.js';
|
|
30
|
+
/** Bounded CPU profiler capability; it is inert until a host passes it to App or Render. */
|
|
31
|
+
export type { Profiler } from './profiler.js';
|
|
32
|
+
/** Creates an opt-in bounded profiler with optional sink and allocation evidence. */
|
|
33
|
+
export { createProfiler } from './profiler.js';
|
|
34
|
+
export type { ProfileDetail, RecorderSession } from './recorder.js';
|
|
35
|
+
/** Validates a persisted capture before model building or CLI-style analysis. */
|
|
36
|
+
export { validateProfileCapture } from './schema.js';
|
|
37
|
+
export {
|
|
38
|
+
consumeToolPreviewProfile,
|
|
39
|
+
type ToolPreviewProfileManifest,
|
|
40
|
+
type ToolPreviewProfileRef,
|
|
41
|
+
type ToolPreviewProfileResult,
|
|
42
|
+
} from './tool-preview-profile.js';
|
|
43
|
+
export type {
|
|
44
|
+
ProfileAllocationReport,
|
|
45
|
+
ProfileCapture,
|
|
46
|
+
ProfileCompleteness,
|
|
47
|
+
ProfileFrameToken,
|
|
48
|
+
ProfilePhaseRecord,
|
|
49
|
+
ProfileRecord,
|
|
50
|
+
ProfileResult,
|
|
51
|
+
ProfileSink,
|
|
52
|
+
ProfileSkipRecord,
|
|
53
|
+
ProfileSource,
|
|
54
|
+
} from './types.js';
|
package/src/model.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { type ProfileArtifactError, validateProfileCapture } from './schema.js';
|
|
2
|
+
import type { ProfileCapture, ProfilePhaseRecord, ProfileRecord, ProfileResult } from './types.js';
|
|
3
|
+
|
|
4
|
+
export interface ProfileFrameModel {
|
|
5
|
+
readonly frameId: number;
|
|
6
|
+
readonly recordCount: number;
|
|
7
|
+
readonly phaseCount: number;
|
|
8
|
+
readonly skipCount: number;
|
|
9
|
+
readonly durationMicros: number;
|
|
10
|
+
readonly records: readonly ProfileRecord[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ProfilePhaseModel {
|
|
14
|
+
readonly source: ProfilePhaseRecord['source'];
|
|
15
|
+
readonly phase: string;
|
|
16
|
+
readonly parentSource?: ProfilePhaseRecord['source'];
|
|
17
|
+
readonly parentPhase?: string;
|
|
18
|
+
readonly count: number;
|
|
19
|
+
readonly skipCount: number;
|
|
20
|
+
readonly p95DurationMicros: number | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ProfileSummaryModel {
|
|
24
|
+
readonly schemaVersion: ProfileCapture['schemaVersion'];
|
|
25
|
+
readonly captureId: string;
|
|
26
|
+
readonly timeUnit: ProfileCapture['timeUnit'];
|
|
27
|
+
readonly frameLimit: number;
|
|
28
|
+
readonly eventLimit: number;
|
|
29
|
+
readonly completeness: ProfileCapture['completeness'];
|
|
30
|
+
readonly frameRange: { readonly first: number; readonly last: number } | null;
|
|
31
|
+
readonly frameCount: number;
|
|
32
|
+
readonly recordCount: number;
|
|
33
|
+
readonly phaseCount: number;
|
|
34
|
+
readonly skipCount: number;
|
|
35
|
+
readonly p95DurationMicros: number | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ProfileModel {
|
|
39
|
+
readonly summary: ProfileSummaryModel;
|
|
40
|
+
readonly completeness: ProfileCapture['completeness'];
|
|
41
|
+
readonly frames: readonly ProfileFrameModel[];
|
|
42
|
+
readonly phases: readonly ProfilePhaseModel[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function nearestRankP95(values: readonly number[]): number | null {
|
|
46
|
+
if (values.length === 0) return null;
|
|
47
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
48
|
+
return sorted[Math.ceil(sorted.length * 0.95) - 1] ?? null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function cloneRecord(record: ProfileRecord): ProfileRecord {
|
|
52
|
+
return { ...record };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isRootPhase(record: ProfileRecord): record is ProfilePhaseRecord {
|
|
56
|
+
return record.kind === 'phase' && record.parentPhase === undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function buildFrames(records: readonly ProfileRecord[]): ProfileFrameModel[] {
|
|
60
|
+
const frameMap = new Map<number, ProfileRecord[]>();
|
|
61
|
+
for (const record of records) {
|
|
62
|
+
const frameRecords = frameMap.get(record.frameId);
|
|
63
|
+
if (frameRecords === undefined) frameMap.set(record.frameId, [cloneRecord(record)]);
|
|
64
|
+
else frameRecords.push(cloneRecord(record));
|
|
65
|
+
}
|
|
66
|
+
return [...frameMap.entries()].map(([frameId, frameRecords]) => ({
|
|
67
|
+
frameId,
|
|
68
|
+
recordCount: frameRecords.length,
|
|
69
|
+
phaseCount: frameRecords.filter((record) => record.kind === 'phase').length,
|
|
70
|
+
skipCount: frameRecords.filter((record) => record.kind === 'skip').length,
|
|
71
|
+
durationMicros: frameRecords.reduce(
|
|
72
|
+
(total, record) => total + (isRootPhase(record) ? record.durationMicros : 0),
|
|
73
|
+
0,
|
|
74
|
+
),
|
|
75
|
+
records: frameRecords,
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function buildPhases(records: readonly ProfileRecord[]): ProfilePhaseModel[] {
|
|
80
|
+
const phaseMap = new Map<
|
|
81
|
+
string,
|
|
82
|
+
{
|
|
83
|
+
source: ProfilePhaseRecord['source'];
|
|
84
|
+
phase: string;
|
|
85
|
+
parentSource: ProfilePhaseRecord['source'] | undefined;
|
|
86
|
+
parentPhase: string | undefined;
|
|
87
|
+
durations: number[];
|
|
88
|
+
skipCount: number;
|
|
89
|
+
}
|
|
90
|
+
>();
|
|
91
|
+
for (const record of records) {
|
|
92
|
+
const parentSource = record.kind === 'phase' ? record.parentSource : undefined;
|
|
93
|
+
const parentPhase = record.kind === 'phase' ? record.parentPhase : undefined;
|
|
94
|
+
const key = `${record.source}:${parentSource ?? ''}:${parentPhase ?? ''}:${record.phase}`;
|
|
95
|
+
let entry = phaseMap.get(key);
|
|
96
|
+
if (entry === undefined) {
|
|
97
|
+
entry = {
|
|
98
|
+
source: record.source,
|
|
99
|
+
phase: record.phase,
|
|
100
|
+
parentSource,
|
|
101
|
+
parentPhase,
|
|
102
|
+
durations: [],
|
|
103
|
+
skipCount: 0,
|
|
104
|
+
};
|
|
105
|
+
phaseMap.set(key, entry);
|
|
106
|
+
}
|
|
107
|
+
if (record.kind === 'phase') entry.durations.push(record.durationMicros);
|
|
108
|
+
else entry.skipCount += 1;
|
|
109
|
+
}
|
|
110
|
+
return [...phaseMap.values()].map(
|
|
111
|
+
({ source, phase, parentSource, parentPhase, durations, skipCount }) => ({
|
|
112
|
+
source,
|
|
113
|
+
phase,
|
|
114
|
+
...(parentSource === undefined ? {} : { parentSource }),
|
|
115
|
+
...(parentPhase === undefined ? {} : { parentPhase }),
|
|
116
|
+
count: durations.length,
|
|
117
|
+
skipCount,
|
|
118
|
+
p95DurationMicros: nearestRankP95(durations),
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function buildProfileModel(
|
|
124
|
+
value: unknown,
|
|
125
|
+
): ProfileResult<ProfileModel, ProfileArtifactError> {
|
|
126
|
+
const validated = validateProfileCapture(value);
|
|
127
|
+
if (!validated.ok) return validated;
|
|
128
|
+
const capture = validated.value;
|
|
129
|
+
const frames = buildFrames(capture.records);
|
|
130
|
+
const phases = buildPhases(capture.records);
|
|
131
|
+
const durations = capture.records.filter(isRootPhase).map((record) => record.durationMicros);
|
|
132
|
+
const completeness = { ...capture.completeness };
|
|
133
|
+
const phaseCount = capture.records.filter((record) => record.kind === 'phase').length;
|
|
134
|
+
const skipCount = capture.records.length - phaseCount;
|
|
135
|
+
const frameRange =
|
|
136
|
+
frames.length === 0
|
|
137
|
+
? null
|
|
138
|
+
: { first: frames[0]?.frameId as number, last: frames[frames.length - 1]?.frameId as number };
|
|
139
|
+
return {
|
|
140
|
+
ok: true,
|
|
141
|
+
value: {
|
|
142
|
+
summary: {
|
|
143
|
+
schemaVersion: capture.schemaVersion,
|
|
144
|
+
captureId: capture.captureId,
|
|
145
|
+
timeUnit: capture.timeUnit,
|
|
146
|
+
frameLimit: capture.frameLimit,
|
|
147
|
+
eventLimit: capture.eventLimit,
|
|
148
|
+
completeness,
|
|
149
|
+
frameRange,
|
|
150
|
+
frameCount: frames.length,
|
|
151
|
+
recordCount: capture.records.length,
|
|
152
|
+
phaseCount,
|
|
153
|
+
skipCount,
|
|
154
|
+
p95DurationMicros: nearestRankP95(durations),
|
|
155
|
+
},
|
|
156
|
+
completeness,
|
|
157
|
+
frames,
|
|
158
|
+
phases,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
package/src/profiler.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { createProfileClock, type ProfileClock } from './clock.js';
|
|
2
|
+
import type { ProfilerError, ProfilerResult } from './errors.js';
|
|
3
|
+
import type { RecorderPhaseCatalog } from './recorder.js';
|
|
4
|
+
import { createRecorder, type RecorderLimits, type RecorderSession } from './recorder.js';
|
|
5
|
+
import type { ProfileAllocationReport, ProfileCapture, ProfileSink } from './types.js';
|
|
6
|
+
|
|
7
|
+
export interface ProfilerOptions {
|
|
8
|
+
readonly clock?: ProfileClock;
|
|
9
|
+
readonly sink?: ProfileSink;
|
|
10
|
+
readonly enabled?: boolean;
|
|
11
|
+
readonly phaseCatalog?: RecorderPhaseCatalog;
|
|
12
|
+
readonly allocationReport?: ProfileAllocationReport;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type ProfilerCatalogSource = keyof RecorderPhaseCatalog;
|
|
16
|
+
|
|
17
|
+
export interface Profiler {
|
|
18
|
+
registerPhaseCatalog(
|
|
19
|
+
source: ProfilerCatalogSource,
|
|
20
|
+
phases: readonly string[],
|
|
21
|
+
): ProfilerResult<() => void>;
|
|
22
|
+
startCapture(limits: RecorderLimits): ProfilerResult<RecorderSession>;
|
|
23
|
+
activeCaptureId(): string | undefined;
|
|
24
|
+
activeSession(): RecorderSession | undefined;
|
|
25
|
+
latestCapture(): ProfileCapture | undefined;
|
|
26
|
+
readonly phaseCatalog: RecorderPhaseCatalog;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createProfiler(options: ProfilerOptions = {}): Profiler {
|
|
30
|
+
const clock = options.clock ?? createProfileClock();
|
|
31
|
+
const enabled = options.enabled ?? true;
|
|
32
|
+
const sink = options.sink;
|
|
33
|
+
const phaseCatalog: RecorderPhaseCatalog =
|
|
34
|
+
options.phaseCatalog === undefined
|
|
35
|
+
? { app: [], render: [] }
|
|
36
|
+
: { app: [...options.phaseCatalog.app], render: [...options.phaseCatalog.render] };
|
|
37
|
+
const registeredSources = new Set<ProfilerCatalogSource>();
|
|
38
|
+
if (options.phaseCatalog !== undefined) {
|
|
39
|
+
registeredSources.add('app');
|
|
40
|
+
registeredSources.add('render');
|
|
41
|
+
}
|
|
42
|
+
let nextCaptureNumber = 1;
|
|
43
|
+
let active: RecorderSession | undefined;
|
|
44
|
+
let latest: ProfileCapture | undefined;
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
registerPhaseCatalog(source, phases) {
|
|
48
|
+
if (registeredSources.has(source)) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
error: {
|
|
52
|
+
code: 'phase-catalog-conflict',
|
|
53
|
+
expected: `one ${source} phase catalog definition`,
|
|
54
|
+
hint: 'Use the owner declaration already registered on this profiler.',
|
|
55
|
+
detail: {
|
|
56
|
+
source,
|
|
57
|
+
expected: [...phaseCatalog[source]],
|
|
58
|
+
actual: [...phases],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const definition = [...phases];
|
|
64
|
+
Object.assign(phaseCatalog, { [source]: definition });
|
|
65
|
+
registeredSources.add(source);
|
|
66
|
+
let active = true;
|
|
67
|
+
return {
|
|
68
|
+
ok: true,
|
|
69
|
+
value: () => {
|
|
70
|
+
if (!active) return;
|
|
71
|
+
active = false;
|
|
72
|
+
if (phaseCatalog[source] !== definition) return;
|
|
73
|
+
Object.assign(phaseCatalog, { [source]: [] });
|
|
74
|
+
registeredSources.delete(source);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
startCapture(limits) {
|
|
79
|
+
if (!enabled) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
error: {
|
|
83
|
+
code: 'profiler-not-enabled',
|
|
84
|
+
expected: 'an explicitly enabled profiler',
|
|
85
|
+
hint: 'Enable the development profiler capability before starting capture.',
|
|
86
|
+
detail: { enabled: false },
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (active !== undefined) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
error: {
|
|
94
|
+
code: 'capture-already-active',
|
|
95
|
+
expected: 'no active capture',
|
|
96
|
+
hint: 'Finish or stop the active capture before starting another one.',
|
|
97
|
+
detail: { captureId: active.captureId },
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const captureId = `capture-${String(nextCaptureNumber).padStart(4, '0')}`;
|
|
102
|
+
nextCaptureNumber += 1;
|
|
103
|
+
const result = createRecorder(
|
|
104
|
+
captureId,
|
|
105
|
+
limits,
|
|
106
|
+
clock,
|
|
107
|
+
phaseCatalog,
|
|
108
|
+
options.allocationReport,
|
|
109
|
+
);
|
|
110
|
+
if (!result.ok) return result;
|
|
111
|
+
const session = wrapSink(result.value, sink, limits.frameLimit, (capture) => {
|
|
112
|
+
latest = capture;
|
|
113
|
+
active = undefined;
|
|
114
|
+
});
|
|
115
|
+
active = session;
|
|
116
|
+
return { ok: true, value: session };
|
|
117
|
+
},
|
|
118
|
+
activeCaptureId() {
|
|
119
|
+
return active?.captureId;
|
|
120
|
+
},
|
|
121
|
+
activeSession() {
|
|
122
|
+
return active;
|
|
123
|
+
},
|
|
124
|
+
latestCapture() {
|
|
125
|
+
return latest;
|
|
126
|
+
},
|
|
127
|
+
get phaseCatalog() {
|
|
128
|
+
return phaseCatalog;
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function wrapSink(
|
|
134
|
+
session: RecorderSession,
|
|
135
|
+
sink: ProfileSink | undefined,
|
|
136
|
+
frameLimit: number,
|
|
137
|
+
complete: (capture: ProfileCapture) => void,
|
|
138
|
+
): RecorderSession {
|
|
139
|
+
let frameCount = 0;
|
|
140
|
+
let terminal: ProfilerResult<ProfileCapture> | undefined;
|
|
141
|
+
|
|
142
|
+
function finish(): ProfilerResult<ProfileCapture> {
|
|
143
|
+
if (terminal !== undefined) return terminal;
|
|
144
|
+
const result = session.finish();
|
|
145
|
+
if (!result.ok) return result;
|
|
146
|
+
complete(result.value);
|
|
147
|
+
if (sink === undefined) {
|
|
148
|
+
terminal = result;
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
const sinkResult = sink.write(result.value);
|
|
153
|
+
if (sinkResult !== undefined && !sinkResult.ok) {
|
|
154
|
+
terminal = {
|
|
155
|
+
ok: false,
|
|
156
|
+
error: {
|
|
157
|
+
code: 'profile-sink-failed',
|
|
158
|
+
expected: 'a sink that accepts a partial or complete artifact',
|
|
159
|
+
hint: 'Replace the sink or retain the returned partial artifact for offline retry.',
|
|
160
|
+
detail: { message: sinkResult.error.detail?.message ?? 'sink rejected artifact' },
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
return terminal;
|
|
164
|
+
}
|
|
165
|
+
terminal = result;
|
|
166
|
+
return result;
|
|
167
|
+
} catch (caught) {
|
|
168
|
+
terminal = {
|
|
169
|
+
ok: false,
|
|
170
|
+
error: {
|
|
171
|
+
code: 'profile-sink-failed',
|
|
172
|
+
expected: 'a sink that accepts a partial or complete artifact',
|
|
173
|
+
hint: 'Replace the sink or retain the returned partial artifact for offline retry.',
|
|
174
|
+
detail: {
|
|
175
|
+
message: caught instanceof Error ? caught.message : 'sink threw a non-Error value',
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
return terminal;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
...session,
|
|
185
|
+
beginFrame(frameId) {
|
|
186
|
+
const result = session.beginFrame(frameId);
|
|
187
|
+
if (result.ok) frameCount += 1;
|
|
188
|
+
return result;
|
|
189
|
+
},
|
|
190
|
+
endFrame() {
|
|
191
|
+
const result = session.endFrame();
|
|
192
|
+
if (result.ok && frameCount >= frameLimit) finish();
|
|
193
|
+
return result;
|
|
194
|
+
},
|
|
195
|
+
finish() {
|
|
196
|
+
return finish();
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export type { ProfileCapture, ProfilerError };
|