@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,163 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createProfiler } from '../profiler.js';
|
|
3
|
+
|
|
4
|
+
const TEST_PHASE_CATALOG = { app: ['frame-total'], render: ['record'] } as const;
|
|
5
|
+
|
|
6
|
+
function createClock() {
|
|
7
|
+
let current = 1000;
|
|
8
|
+
return {
|
|
9
|
+
nowMicros() {
|
|
10
|
+
current += 10;
|
|
11
|
+
return current;
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function expectOk<T>(result: { ok: boolean; value?: T; error?: { code: string } }): T {
|
|
17
|
+
expect(result.ok).toBe(true);
|
|
18
|
+
if (!result.ok) throw new Error(result.error?.code ?? 'unexpected profiler error');
|
|
19
|
+
return result.value as T;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('bounded profiler recorder contract', () => {
|
|
23
|
+
it('rejects invalid limits before entering an active capture', () => {
|
|
24
|
+
const profiler = createProfiler({ clock: createClock(), phaseCatalog: TEST_PHASE_CATALOG });
|
|
25
|
+
|
|
26
|
+
for (const limits of [
|
|
27
|
+
{ frameLimit: 0, eventLimit: 1 },
|
|
28
|
+
{ frameLimit: 1, eventLimit: 0 },
|
|
29
|
+
{ frameLimit: -1, eventLimit: 1 },
|
|
30
|
+
{ frameLimit: 1.5, eventLimit: 1 },
|
|
31
|
+
{ frameLimit: Number.POSITIVE_INFINITY, eventLimit: 1 },
|
|
32
|
+
]) {
|
|
33
|
+
const result = profiler.startCapture(limits);
|
|
34
|
+
expect(result.ok).toBe(false);
|
|
35
|
+
if (!result.ok) expect(result.error.code).toBe('capture-boundary-invalid');
|
|
36
|
+
}
|
|
37
|
+
expect(profiler.activeCaptureId()).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('keeps one active capture and reports its identity on conflict', () => {
|
|
41
|
+
const profiler = createProfiler({ clock: createClock(), phaseCatalog: TEST_PHASE_CATALOG });
|
|
42
|
+
const session = expectOk(profiler.startCapture({ frameLimit: 2, eventLimit: 4 }));
|
|
43
|
+
const second = profiler.startCapture({ frameLimit: 2, eventLimit: 4 });
|
|
44
|
+
|
|
45
|
+
expect(second.ok).toBe(false);
|
|
46
|
+
if (!second.ok) {
|
|
47
|
+
expect(second.error.code).toBe('capture-already-active');
|
|
48
|
+
if (second.error.code === 'capture-already-active') {
|
|
49
|
+
expect(second.error.detail.captureId).toBe(session.captureId);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
expect(profiler.activeCaptureId()).toBe(session.captureId);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('pairs open phases and preserves a complete bounded artifact', () => {
|
|
56
|
+
const profiler = createProfiler({ clock: createClock(), phaseCatalog: TEST_PHASE_CATALOG });
|
|
57
|
+
const session = expectOk(profiler.startCapture({ frameLimit: 1, eventLimit: 2 }));
|
|
58
|
+
|
|
59
|
+
expectOk(session.beginFrame(1));
|
|
60
|
+
expectOk(session.beginPhase({ source: 'app', phase: 'frame-total' }));
|
|
61
|
+
expectOk(session.endPhase());
|
|
62
|
+
expectOk(session.endFrame());
|
|
63
|
+
const artifact = expectOk(session.finish());
|
|
64
|
+
|
|
65
|
+
expect(artifact.completeness.status).toBe('complete');
|
|
66
|
+
expect(artifact.records).toHaveLength(1);
|
|
67
|
+
expect(artifact.records[0]?.kind).toBe('phase');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('defers capture event object materialization until finish', () => {
|
|
71
|
+
const allocationReport = { profilerEventObjectAllocations: 0 };
|
|
72
|
+
const profiler = createProfiler({
|
|
73
|
+
clock: createClock(),
|
|
74
|
+
phaseCatalog: TEST_PHASE_CATALOG,
|
|
75
|
+
allocationReport,
|
|
76
|
+
});
|
|
77
|
+
const session = expectOk(profiler.startCapture({ frameLimit: 1, eventLimit: 2 }));
|
|
78
|
+
|
|
79
|
+
expectOk(session.beginFrame(1));
|
|
80
|
+
expectOk(session.beginPhase({ source: 'app', phase: 'frame-total' }));
|
|
81
|
+
expectOk(session.endPhase());
|
|
82
|
+
expect(allocationReport.profilerEventObjectAllocations).toBe(0);
|
|
83
|
+
expectOk(session.endFrame());
|
|
84
|
+
expectOk(session.finish());
|
|
85
|
+
expect(allocationReport.profilerEventObjectAllocations).toBe(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('accepts a phase catalog registered after capture starts', () => {
|
|
89
|
+
const profiler = createProfiler({ clock: createClock() });
|
|
90
|
+
const session = expectOk(profiler.startCapture({ frameLimit: 1, eventLimit: 2 }));
|
|
91
|
+
|
|
92
|
+
expectOk(profiler.registerPhaseCatalog('app', TEST_PHASE_CATALOG.app));
|
|
93
|
+
expectOk(session.beginFrame(1));
|
|
94
|
+
expectOk(session.beginPhase('app', 'frame-total'));
|
|
95
|
+
expectOk(session.endPhase());
|
|
96
|
+
expectOk(session.endFrame());
|
|
97
|
+
const artifact = expectOk(session.finish());
|
|
98
|
+
|
|
99
|
+
expect(artifact.records).toHaveLength(1);
|
|
100
|
+
expect(artifact.records[0]?.kind).toBe('phase');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('releases a phase catalog lease without mutating a finished capture', () => {
|
|
104
|
+
const profiler = createProfiler({ clock: createClock() });
|
|
105
|
+
const release = expectOk(profiler.registerPhaseCatalog('app', TEST_PHASE_CATALOG.app));
|
|
106
|
+
const session = expectOk(profiler.startCapture({ frameLimit: 1, eventLimit: 2 }));
|
|
107
|
+
|
|
108
|
+
expectOk(session.beginFrame(1));
|
|
109
|
+
expectOk(session.beginPhase('app', 'frame-total'));
|
|
110
|
+
expectOk(session.endPhase());
|
|
111
|
+
expectOk(session.endFrame());
|
|
112
|
+
const artifact = expectOk(session.finish());
|
|
113
|
+
release();
|
|
114
|
+
release();
|
|
115
|
+
|
|
116
|
+
expect(profiler.phaseCatalog.app).toEqual([]);
|
|
117
|
+
expect(artifact.phaseCatalog.app).toEqual(TEST_PHASE_CATALOG.app);
|
|
118
|
+
expect(profiler.registerPhaseCatalog('app', TEST_PHASE_CATALOG.app).ok).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('stops record growth after event overflow while retaining scalar accounting', () => {
|
|
122
|
+
const profiler = createProfiler({ clock: createClock(), phaseCatalog: TEST_PHASE_CATALOG });
|
|
123
|
+
const session = expectOk(profiler.startCapture({ frameLimit: 20, eventLimit: 2 }));
|
|
124
|
+
|
|
125
|
+
for (let frameId = 1; frameId <= 20; frameId += 1) {
|
|
126
|
+
expectOk(session.beginFrame(frameId));
|
|
127
|
+
expectOk(session.recordSkip({ source: 'render', phase: 'record', reason: 'no-render' }));
|
|
128
|
+
expectOk(session.endFrame());
|
|
129
|
+
}
|
|
130
|
+
const artifact = expectOk(session.finish());
|
|
131
|
+
|
|
132
|
+
expect(artifact.completeness.status).toBe('overflow');
|
|
133
|
+
expect(artifact.records.length).toBe(2);
|
|
134
|
+
expect(artifact.completeness.retainedEventCount).toBe(2);
|
|
135
|
+
expect(artifact.completeness.droppedEventCount).toBeGreaterThan(0);
|
|
136
|
+
expect(artifact.completeness.firstAffectedFrameId).toBe(3);
|
|
137
|
+
expect(artifact.completeness.lastAffectedFrameId).toBe(20);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('returns partial structured errors for a sink Result.err and a sink throw', () => {
|
|
141
|
+
const sinks = [
|
|
142
|
+
{ write: () => ({ ok: false as const, error: { code: 'profile-sink-failed' as const } }) },
|
|
143
|
+
{
|
|
144
|
+
write: () => {
|
|
145
|
+
throw new Error('sink unavailable');
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
for (const sink of sinks) {
|
|
151
|
+
const profiler = createProfiler({ clock: createClock(), sink });
|
|
152
|
+
const session = expectOk(profiler.startCapture({ frameLimit: 1, eventLimit: 1 }));
|
|
153
|
+
const result = session.finish();
|
|
154
|
+
|
|
155
|
+
expect(result.ok).toBe(false);
|
|
156
|
+
if (!result.ok) {
|
|
157
|
+
expect(result.error.code).toBe('profile-sink-failed');
|
|
158
|
+
expect(result.error.expected).toContain('partial');
|
|
159
|
+
expect(result.error.hint.length).toBeGreaterThan(0);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { validateProfileCapture } from '../schema.js';
|
|
4
|
+
|
|
5
|
+
type Fixture = Record<string, unknown>;
|
|
6
|
+
|
|
7
|
+
function readFixture(name: string): Fixture {
|
|
8
|
+
return JSON.parse(
|
|
9
|
+
readFileSync(new URL(`./fixtures/profile-capture/${name}`, import.meta.url), 'utf8'),
|
|
10
|
+
) as Fixture;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('ProfileCapture schema contract', () => {
|
|
14
|
+
it('accepts complete and overflow self-describing captures', () => {
|
|
15
|
+
for (const name of ['valid-complete.json', 'valid-overflow.json']) {
|
|
16
|
+
const result = validateProfileCapture(readFixture(name));
|
|
17
|
+
expect(result.ok, `${name} should be valid`).toBe(true);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('rejects every declared invalid boundary and completeness case', () => {
|
|
22
|
+
const fixture = readFixture('invalid-boundary.json');
|
|
23
|
+
const cases = fixture.cases as Array<{ name: string; capture: Fixture }>;
|
|
24
|
+
|
|
25
|
+
expect(cases.length).toBeGreaterThanOrEqual(8);
|
|
26
|
+
for (const testCase of cases) {
|
|
27
|
+
const result = validateProfileCapture(testCase.capture);
|
|
28
|
+
expect(result.ok, `${testCase.name} should be rejected`).toBe(false);
|
|
29
|
+
if (result.ok) continue;
|
|
30
|
+
expect(result.error.code).toMatch(/^profile-artifact-/);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('rejects an unknown phase without a second hard-coded phase list', () => {
|
|
35
|
+
const capture = readFixture('valid-complete.json');
|
|
36
|
+
const records = capture.records as Fixture[];
|
|
37
|
+
records[0] = { ...records[0], phase: 'phase-not-in-catalog' };
|
|
38
|
+
|
|
39
|
+
const result = validateProfileCapture(capture);
|
|
40
|
+
expect(result.ok).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
type Schema = { $defs: Record<string, unknown> };
|
|
5
|
+
|
|
6
|
+
function readSchema(): Schema {
|
|
7
|
+
return JSON.parse(
|
|
8
|
+
readFileSync(new URL('../../schema/profile-capture.schema.json', import.meta.url), 'utf8'),
|
|
9
|
+
) as Schema;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function literalUnion(values: readonly string[]): string {
|
|
13
|
+
return values.map((value) => `'${value}'`).join(' | ');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readStringEnum(definition: unknown): readonly string[] {
|
|
17
|
+
return (definition as { enum: readonly string[] }).enum;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readRecordKinds(schema: Schema): readonly string[] {
|
|
21
|
+
const record = schema.$defs.record as { oneOf: readonly { $ref: string }[] };
|
|
22
|
+
return record.oneOf.map(({ $ref }) => {
|
|
23
|
+
const definitionName = $ref.slice('#/$defs/'.length);
|
|
24
|
+
const definition = schema.$defs[definitionName] as {
|
|
25
|
+
properties: { kind: { const: string } };
|
|
26
|
+
};
|
|
27
|
+
return definition.properties.kind.const;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('schema-derived profiler union owners', () => {
|
|
32
|
+
it('projects the three closed unions from schema memberships', () => {
|
|
33
|
+
const schema = readSchema();
|
|
34
|
+
const source = readFileSync(
|
|
35
|
+
new URL('../../scripts/generate-profile-types.mjs', import.meta.url),
|
|
36
|
+
'utf8',
|
|
37
|
+
);
|
|
38
|
+
const generated = readFileSync(
|
|
39
|
+
new URL('../generated/profile-capture.ts', import.meta.url),
|
|
40
|
+
'utf8',
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const sourceUnion = literalUnion(readStringEnum(schema.$defs.source));
|
|
44
|
+
const recordKindUnion = literalUnion(readRecordKinds(schema));
|
|
45
|
+
const completenessStatus = schema.$defs.completeness as {
|
|
46
|
+
properties: { status: { enum: readonly string[] } };
|
|
47
|
+
};
|
|
48
|
+
const completenessUnion = literalUnion(readStringEnum(completenessStatus.properties.status));
|
|
49
|
+
|
|
50
|
+
expect(generated).toContain(`export type ProfileSource = ${sourceUnion};`);
|
|
51
|
+
expect(generated).toContain(`export type ProfileRecordKind = ${recordKindUnion};`);
|
|
52
|
+
expect(generated).toContain(`export type ProfileCompletenessStatus = ${completenessUnion};`);
|
|
53
|
+
expect(source).toContain('schema.$defs.source.enum');
|
|
54
|
+
expect(source).toContain('schema.$defs.record.oneOf');
|
|
55
|
+
expect(source).toContain('schema.$defs.completeness.properties.status.enum');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { Profiler } from './profiler.js';
|
|
2
|
+
import type { ProfileDetail, RecorderSession } from './recorder.js';
|
|
3
|
+
import type { ProfileCapture, ProfilePhaseStart, ProfileSource } from './types.js';
|
|
4
|
+
|
|
5
|
+
/** The small browser API surface used by the opt-in User Timing adapter. */
|
|
6
|
+
type UserTiming = { readonly mark: (name: string) => void };
|
|
7
|
+
|
|
8
|
+
/** Configuration shared by editor and standalone Play browser hosts. */
|
|
9
|
+
export interface UserTimingProfilerOptions {
|
|
10
|
+
/** Global flag that enables marks. Defaults to the harness diagnostic flag. */
|
|
11
|
+
readonly diagnosticsKey?: string;
|
|
12
|
+
/** Stable id written into the optional Profiler capture projection. */
|
|
13
|
+
readonly captureId?: string;
|
|
14
|
+
/** Host callback invoked after an owner phase closes. */
|
|
15
|
+
readonly onPhaseEnd?: (phase: { readonly source: ProfileSource; readonly phase: string }) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type DiagnosticGlobal = Record<string, unknown>;
|
|
19
|
+
type PhaseCatalog = ProfileCapture['phaseCatalog'];
|
|
20
|
+
type BrowserProfileDetail = ProfileDetail;
|
|
21
|
+
const DEFAULT_CAPTURE_ID = 'capture-0001';
|
|
22
|
+
const CAPTURE_ID_PATTERN = /^capture-[0-9]{4,}$/;
|
|
23
|
+
|
|
24
|
+
function resolveCaptureId(captureId: string | undefined): string {
|
|
25
|
+
return captureId === undefined || CAPTURE_ID_PATTERN.test(captureId)
|
|
26
|
+
? (captureId ?? DEFAULT_CAPTURE_ID)
|
|
27
|
+
: DEFAULT_CAPTURE_ID;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function partialCapture(captureId: string, phaseCatalog: PhaseCatalog): ProfileCapture {
|
|
31
|
+
return {
|
|
32
|
+
schemaVersion: '1.0',
|
|
33
|
+
captureId,
|
|
34
|
+
timeUnit: 'microseconds',
|
|
35
|
+
frameLimit: 1,
|
|
36
|
+
eventLimit: 1,
|
|
37
|
+
phaseCatalog,
|
|
38
|
+
records: [],
|
|
39
|
+
completeness: {
|
|
40
|
+
status: 'partial',
|
|
41
|
+
retainedEventCount: 0,
|
|
42
|
+
droppedEventCount: 0,
|
|
43
|
+
incompleteReason: 'user-timing-transport',
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function diagnosticsEnabled(key: string): boolean {
|
|
49
|
+
const value = (globalThis as unknown as DiagnosticGlobal)[key];
|
|
50
|
+
return (
|
|
51
|
+
typeof value === 'object' &&
|
|
52
|
+
value !== null &&
|
|
53
|
+
(value as { readonly enabled?: unknown }).enabled === true
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function diagnosticsDetail(key: string): BrowserProfileDetail {
|
|
58
|
+
const value = (globalThis as unknown as DiagnosticGlobal)[key];
|
|
59
|
+
if (typeof value !== 'object' || value === null) return 'owner';
|
|
60
|
+
const detail = (value as { readonly detail?: unknown }).detail;
|
|
61
|
+
return detail === 'nested' || detail === 'passes' ? detail : 'owner';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Creates a zero-cost-while-disabled browser projection of the engine Profiler.
|
|
66
|
+
*
|
|
67
|
+
* The engine frame loop remains the phase owner. This adapter only translates
|
|
68
|
+
* the already-open phases to User Timing marks when a host explicitly enables
|
|
69
|
+
* the diagnostic flag before app creation. Normal game/editor frames therefore
|
|
70
|
+
* do not allocate a profiler or call performance.mark().
|
|
71
|
+
*/
|
|
72
|
+
export function createUserTimingProfiler(
|
|
73
|
+
options: UserTimingProfilerOptions = {},
|
|
74
|
+
): Profiler | undefined {
|
|
75
|
+
const diagnosticsKey = options.diagnosticsKey ?? '__forgeaxFramePhaseDiagnostics';
|
|
76
|
+
const captureId = resolveCaptureId(options.captureId);
|
|
77
|
+
const timingEnabled = diagnosticsEnabled(diagnosticsKey);
|
|
78
|
+
const detail = diagnosticsDetail(diagnosticsKey);
|
|
79
|
+
const performanceApi = timingEnabled
|
|
80
|
+
? (globalThis as unknown as { readonly performance?: UserTiming }).performance
|
|
81
|
+
: undefined;
|
|
82
|
+
if (!timingEnabled && options.onPhaseEnd === undefined) return undefined;
|
|
83
|
+
if (
|
|
84
|
+
timingEnabled &&
|
|
85
|
+
(performanceApi === undefined || typeof performanceApi.mark !== 'function')
|
|
86
|
+
) {
|
|
87
|
+
if (options.onPhaseEnd === undefined) return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let phaseCatalog: PhaseCatalog = { app: [], render: [] };
|
|
91
|
+
let frameId: number | undefined;
|
|
92
|
+
let active = true;
|
|
93
|
+
let latest: ProfileCapture | undefined;
|
|
94
|
+
const openPhases: Array<{ readonly source: ProfileSource; readonly phase: string }> = [];
|
|
95
|
+
|
|
96
|
+
function mark(name: string): void {
|
|
97
|
+
if (performanceApi === undefined) return;
|
|
98
|
+
try {
|
|
99
|
+
performanceApi.mark(name);
|
|
100
|
+
} catch {
|
|
101
|
+
// Diagnostics must not change the host frame-loop behavior.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function timingSource(source: ProfileSource): 'frame' | 'render' {
|
|
106
|
+
return source === 'app' ? 'frame' : 'render';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const session = {
|
|
110
|
+
captureId,
|
|
111
|
+
detail,
|
|
112
|
+
beginFrame(nextFrameId: number) {
|
|
113
|
+
frameId = nextFrameId;
|
|
114
|
+
openPhases.length = 0;
|
|
115
|
+
return { ok: true as const, value: undefined };
|
|
116
|
+
},
|
|
117
|
+
beginPhase(inputOrSource: ProfilePhaseStart | ProfileSource, phaseName?: string) {
|
|
118
|
+
if (frameId === undefined) return { ok: true as const, value: undefined };
|
|
119
|
+
const input =
|
|
120
|
+
typeof inputOrSource === 'string'
|
|
121
|
+
? { source: inputOrSource, phase: phaseName as string }
|
|
122
|
+
: inputOrSource;
|
|
123
|
+
openPhases.push(input);
|
|
124
|
+
mark(`forgeax.${timingSource(input.source)}.phase.${frameId}.${input.phase}.begin`);
|
|
125
|
+
return { ok: true as const, value: undefined };
|
|
126
|
+
},
|
|
127
|
+
endPhase() {
|
|
128
|
+
const phase = openPhases.pop();
|
|
129
|
+
if (phase !== undefined) options.onPhaseEnd?.(phase);
|
|
130
|
+
if (frameId !== undefined && phase !== undefined) {
|
|
131
|
+
mark(`forgeax.${timingSource(phase.source)}.phase.${frameId}.${phase.phase}.end`);
|
|
132
|
+
}
|
|
133
|
+
return { ok: true as const, value: undefined };
|
|
134
|
+
},
|
|
135
|
+
recordSkip(input: {
|
|
136
|
+
readonly source: ProfileSource;
|
|
137
|
+
readonly phase: string;
|
|
138
|
+
readonly reason: string;
|
|
139
|
+
}) {
|
|
140
|
+
if (frameId !== undefined) {
|
|
141
|
+
mark(
|
|
142
|
+
`forgeax.${timingSource(input.source)}.phase.${frameId}.${input.phase}.skip.${input.reason}`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return { ok: true as const, value: undefined };
|
|
146
|
+
},
|
|
147
|
+
endFrame() {
|
|
148
|
+
frameId = undefined;
|
|
149
|
+
openPhases.length = 0;
|
|
150
|
+
return { ok: true as const, value: undefined };
|
|
151
|
+
},
|
|
152
|
+
finish() {
|
|
153
|
+
latest = partialCapture(captureId, phaseCatalog);
|
|
154
|
+
active = false;
|
|
155
|
+
frameId = undefined;
|
|
156
|
+
openPhases.length = 0;
|
|
157
|
+
return { ok: true as const, value: latest };
|
|
158
|
+
},
|
|
159
|
+
} satisfies RecorderSession;
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
registerPhaseCatalog(source, phases) {
|
|
163
|
+
const definition = [...phases];
|
|
164
|
+
phaseCatalog = { ...phaseCatalog, [source]: definition };
|
|
165
|
+
let registered = true;
|
|
166
|
+
return {
|
|
167
|
+
ok: true,
|
|
168
|
+
value: () => {
|
|
169
|
+
if (!registered) return;
|
|
170
|
+
registered = false;
|
|
171
|
+
if (phaseCatalog[source] !== definition) return;
|
|
172
|
+
phaseCatalog = { ...phaseCatalog, [source]: [] };
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
},
|
|
176
|
+
startCapture() {
|
|
177
|
+
active = true;
|
|
178
|
+
latest = undefined;
|
|
179
|
+
return { ok: true, value: session };
|
|
180
|
+
},
|
|
181
|
+
activeCaptureId() {
|
|
182
|
+
return active ? captureId : undefined;
|
|
183
|
+
},
|
|
184
|
+
activeSession() {
|
|
185
|
+
return active ? session : undefined;
|
|
186
|
+
},
|
|
187
|
+
latestCapture() {
|
|
188
|
+
return latest;
|
|
189
|
+
},
|
|
190
|
+
get phaseCatalog() {
|
|
191
|
+
return phaseCatalog;
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|