418 lines
16 KiB
TypeScript
418 lines
16 KiB
TypeScript
import type { SceneGpuBudgetSnapshot } from '@/scene/resources';
|
|
|
|
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
|
declare const __PORTFOLIO_TEST_TOKEN_DIGEST__: string;
|
|
|
|
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
|
|
const TEST_TOKEN_DIGEST = typeof __PORTFOLIO_TEST_TOKEN_DIGEST__ === 'undefined'
|
|
? ''
|
|
: __PORTFOLIO_TEST_TOKEN_DIGEST__;
|
|
|
|
export interface LayoutShiftSample {
|
|
startTime: number;
|
|
value: number;
|
|
hadRecentInput: boolean;
|
|
}
|
|
|
|
export interface LongTaskSample {
|
|
startTime: number;
|
|
duration: number;
|
|
}
|
|
|
|
export interface EventTimingSample {
|
|
target: string;
|
|
startTime: number;
|
|
duration: number;
|
|
interactionId: number;
|
|
}
|
|
|
|
export interface ScriptedActionSample {
|
|
id: string;
|
|
target: string;
|
|
dispatchTime: number;
|
|
captureEndTime: number | null;
|
|
acknowledgementCount: number;
|
|
mutationTime: number | null;
|
|
doubleRafTime: number | null;
|
|
eventTimingSupported: boolean;
|
|
eventEntries: readonly EventTimingSample[];
|
|
}
|
|
|
|
export interface SceneInterval { start: number; end: number }
|
|
export interface SceneFrameSample { start: number; end: number }
|
|
export type RuntimeAllocationSample = SceneGpuBudgetSnapshot;
|
|
|
|
export const nearestRankP95 = (samples: readonly number[]): number => {
|
|
if (samples.length === 0) throw new Error('At least one sample is required.');
|
|
const sorted = [...samples].sort((left, right) => left - right);
|
|
return sorted[Math.ceil(0.95 * sorted.length) - 1]!;
|
|
};
|
|
|
|
export const latestLcpBeforeCutoff = (entries: readonly number[], cutoff: number): number => {
|
|
const eligible = entries.filter((entry) => entry < cutoff);
|
|
if (eligible.length === 0) throw new Error('An LCP entry is required before the cutoff.');
|
|
return Math.max(...eligible);
|
|
};
|
|
|
|
export const calculateCls = (entries: readonly LayoutShiftSample[]): number => {
|
|
const eligible = entries
|
|
.filter((entry) => !entry.hadRecentInput)
|
|
.sort((left, right) => left.startTime - right.startTime);
|
|
let maximum = 0;
|
|
let windowStart = -Infinity;
|
|
let previous = -Infinity;
|
|
let sum = 0;
|
|
for (const entry of eligible) {
|
|
if (entry.startTime - previous > 1_000 || entry.startTime - windowStart > 5_000) {
|
|
windowStart = entry.startTime;
|
|
sum = 0;
|
|
}
|
|
sum += entry.value;
|
|
previous = entry.startTime;
|
|
maximum = Math.max(maximum, sum);
|
|
}
|
|
return maximum;
|
|
};
|
|
|
|
export const calculateLabTbt = (
|
|
tasks: readonly LongTaskSample[],
|
|
fcp: number | null,
|
|
runningAt: number,
|
|
): number => {
|
|
if (fcp === null) throw new Error('FCP is required for lab total blocking time.');
|
|
return tasks.reduce((total, task) => {
|
|
const start = Math.max(task.startTime + 50, fcp);
|
|
const end = Math.min(task.startTime + task.duration, runningAt);
|
|
return total + Math.max(0, end - start);
|
|
}, 0);
|
|
};
|
|
|
|
export const postRunningLongTasks = (
|
|
tasks: readonly LongTaskSample[],
|
|
runningAt: number,
|
|
traceEnd: number,
|
|
): LongTaskSample[] => tasks.filter((task) => (
|
|
task.startTime < traceEnd && task.startTime + task.duration > runningAt
|
|
));
|
|
|
|
const exactActionIds = new Set(['start', 'volume', 'mute', 'unmute', 'pause', 'resume', 'toolkit', 'contact']);
|
|
|
|
export const evaluateEventToNextPaint = (
|
|
actions: readonly ScriptedActionSample[],
|
|
): { values: number[]; p95: number } => {
|
|
if (actions.length !== 8 || new Set(actions.map((action) => action.id)).size !== 8 || actions.some((action) => !exactActionIds.has(action.id))) {
|
|
throw new Error('The exact eight scripted actions are required.');
|
|
}
|
|
const values = actions.map((action) => {
|
|
if (action.acknowledgementCount !== 1) throw new Error('Each action requires exactly one acknowledgement.');
|
|
if (action.mutationTime === null || action.mutationTime > action.dispatchTime + 500) throw new Error('The exact state mutation missed its deadline.');
|
|
if (action.doubleRafTime === null || action.doubleRafTime > action.dispatchTime + 500) throw new Error('Double RAF confirmation missed its deadline.');
|
|
if (!action.eventTimingSupported) throw new Error('Event Timing observation is unsupported.');
|
|
const matching = action.eventEntries.filter((entry) => (
|
|
entry.target === action.target
|
|
&& entry.startTime >= action.dispatchTime
|
|
&& entry.startTime <= action.dispatchTime + 500
|
|
&& (action.captureEndTime === null || entry.startTime < action.captureEndTime)
|
|
));
|
|
if (matching.length === 0) return 16;
|
|
const groups = new Map<number, number>();
|
|
for (const entry of matching) {
|
|
if (entry.interactionId === 0) continue;
|
|
groups.set(entry.interactionId, Math.max(groups.get(entry.interactionId) ?? 0, entry.duration));
|
|
}
|
|
if (groups.size !== 1) throw new Error(`Event Timing groups are ambiguous for ${action.id}: ${JSON.stringify(matching)}`);
|
|
return [...groups.values()][0]!;
|
|
});
|
|
return { values, p95: nearestRankP95(values) };
|
|
};
|
|
|
|
export const evaluateSceneContinuity = (
|
|
intervals: readonly SceneInterval[],
|
|
callbacks: readonly SceneFrameSample[],
|
|
expectedFramePeriod: number,
|
|
): { passes: boolean; expectedCallbacks: number; actualCallbacks: number } => {
|
|
if (!(expectedFramePeriod > 0)) throw new Error('Expected frame period must be positive.');
|
|
const eligible = intervals.filter((interval) => interval.end - interval.start >= expectedFramePeriod);
|
|
let expectedCallbacks = 0;
|
|
let actualCallbacks = 0;
|
|
let boundariesPass = eligible.length > 0;
|
|
for (const interval of eligible) {
|
|
expectedCallbacks += Math.floor((interval.end - interval.start) / expectedFramePeriod);
|
|
const inside = callbacks.filter((sample) => sample.start >= interval.start && sample.end <= interval.end);
|
|
actualCallbacks += inside.length;
|
|
if (inside.length === 0) {
|
|
boundariesPass = false;
|
|
continue;
|
|
}
|
|
if (inside[0]!.start - interval.start > 3 * expectedFramePeriod) boundariesPass = false;
|
|
for (let index = 1; index < inside.length; index += 1) {
|
|
if (inside[index]!.start - inside[index - 1]!.start > 3 * expectedFramePeriod) boundariesPass = false;
|
|
}
|
|
if (interval.end - inside.at(-1)!.end > 3 * expectedFramePeriod) boundariesPass = false;
|
|
}
|
|
const coverage = expectedCallbacks > 0 ? actualCallbacks / expectedCallbacks : 0;
|
|
return { passes: boundariesPass && coverage >= 0.9, expectedCallbacks, actualCallbacks };
|
|
};
|
|
|
|
export const calculateLostPresentationShare = (
|
|
rafTimestamps: readonly number[],
|
|
expectedFramePeriod: number,
|
|
minimumOpportunities: number,
|
|
): { opportunities: number; lost: number; share: number; hasSufficientCoverage: boolean } => {
|
|
let opportunities = 0;
|
|
let lost = 0;
|
|
for (let index = 1; index < rafTimestamps.length; index += 1) {
|
|
const interval = rafTimestamps[index]! - rafTimestamps[index - 1]!;
|
|
const nextOpportunities = Math.max(1, Math.round(interval / expectedFramePeriod));
|
|
opportunities += nextOpportunities;
|
|
lost += nextOpportunities - 1;
|
|
}
|
|
return {
|
|
opportunities,
|
|
lost,
|
|
share: opportunities === 0 ? 1 : lost / opportunities,
|
|
hasSufficientCoverage: opportunities >= minimumOpportunities,
|
|
};
|
|
};
|
|
|
|
export const unexpectedLateShifts = (
|
|
entries: readonly LayoutShiftSample[],
|
|
firstSceneFrame: number,
|
|
traceEnd: number,
|
|
): LayoutShiftSample[] => entries.filter((entry) => (
|
|
!entry.hadRecentInput && entry.startTime >= firstSceneFrame && entry.startTime < traceEnd
|
|
));
|
|
|
|
interface PerformanceSnapshot {
|
|
version: 'drive-trace.v1';
|
|
lcp: readonly number[];
|
|
layoutShifts: readonly LayoutShiftSample[];
|
|
longTasks: readonly LongTaskSample[];
|
|
pageRafs: readonly number[];
|
|
sceneFrames: readonly SceneFrameSample[];
|
|
sceneIntervals: readonly SceneInterval[];
|
|
firstSceneFrame: number | null;
|
|
traceStart: number | null;
|
|
traceEnd: number | null;
|
|
cutoff: number;
|
|
navigationBarrier: { armed: boolean; pendingEpoch: number | null; cancelledEpoch: number | null };
|
|
actions: readonly ScriptedActionSample[];
|
|
runtimeAllocation: RuntimeAllocationSample | null;
|
|
}
|
|
|
|
interface PerformanceHooks {
|
|
readonly version: 'drive-trace.v1';
|
|
startTrace(): void;
|
|
endTrace(): void;
|
|
recordSceneFrame(start: number, end: number): void;
|
|
recordSceneState(state: string): void;
|
|
recordRuntimeAllocation(allocation: RuntimeAllocationSample): void;
|
|
armNavigationSettlementBarrier(): void;
|
|
registerNavigationSettlement(epoch: number): void;
|
|
holdNavigationSettlement(epoch: number): boolean;
|
|
noteNavigationEpoch(epoch: number): void;
|
|
beginAction(id: string, selector: string): void;
|
|
recordActionMutation(id: string): void;
|
|
snapshot(): PerformanceSnapshot;
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
__PORTFOLIO_PERFORMANCE__?: PerformanceHooks;
|
|
}
|
|
}
|
|
|
|
const digest = async (value: string): Promise<string> => {
|
|
const output = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
|
|
return [...new Uint8Array(output)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
};
|
|
|
|
export const installAuthorizedPerformanceInstrumentation = async (): Promise<void> => {
|
|
if (!TEST_BUILD) return;
|
|
const url = new URL(location.href);
|
|
if (url.searchParams.get('__portfolio_performance') !== 'drive-trace.v1') return;
|
|
const token = url.searchParams.get('__portfolio_test_token');
|
|
if (token === null || await digest(token) !== TEST_TOKEN_DIGEST) return;
|
|
|
|
const lcp: number[] = [];
|
|
const layoutShifts: LayoutShiftSample[] = [];
|
|
const longTasks: LongTaskSample[] = [];
|
|
const pageRafs: number[] = [];
|
|
const sceneFrames: SceneFrameSample[] = [];
|
|
const sceneIntervals: SceneInterval[] = [];
|
|
let firstSceneFrame: number | null = null;
|
|
let runtimeAllocation: RuntimeAllocationSample | null = null;
|
|
let traceStart: number | null = null;
|
|
let traceEnd: number | null = null;
|
|
let runningIntervalStart: number | null = null;
|
|
let cutoff = Infinity;
|
|
let sentinel = 0;
|
|
let barrierArmed = false;
|
|
let barrierPendingEpoch: number | null = null;
|
|
let barrierCancelledEpoch: number | null = null;
|
|
const actionRecords = new Map<string, ScriptedActionSample & { element: Element | null }>();
|
|
|
|
const observe = (type: string, callback: (entry: PerformanceEntry) => void): void => {
|
|
if (typeof PerformanceObserver === 'undefined') return;
|
|
try {
|
|
const observer = new PerformanceObserver((list) => list.getEntries().forEach(callback));
|
|
observer.observe({ type, buffered: true });
|
|
} catch { /* unsupported entry type */ }
|
|
};
|
|
observe('largest-contentful-paint', (entry) => lcp.push(entry.startTime));
|
|
observe('layout-shift', (entry) => {
|
|
const shift = entry as PerformanceEntry & { value?: number; hadRecentInput?: boolean };
|
|
layoutShifts.push({ startTime: shift.startTime, value: shift.value ?? 0, hadRecentInput: shift.hadRecentInput ?? false });
|
|
});
|
|
observe('longtask', (entry) => longTasks.push({ startTime: entry.startTime, duration: entry.duration }));
|
|
let eventTimingSupported = false;
|
|
if (typeof PerformanceObserver !== 'undefined') {
|
|
try {
|
|
const eventObserver = new PerformanceObserver((list) => {
|
|
for (const entry of list.getEntries()) {
|
|
const timed = entry as PerformanceEntry & { interactionId?: number; target?: Node | null };
|
|
for (const record of actionRecords.values()) {
|
|
if (timed.target === record.element
|
|
&& timed.startTime >= record.dispatchTime
|
|
&& (record.captureEndTime === null || timed.startTime < record.captureEndTime)) {
|
|
(record.eventEntries as EventTimingSample[]).push({
|
|
target: record.id,
|
|
startTime: timed.startTime,
|
|
duration: timed.duration,
|
|
interactionId: timed.interactionId ?? 0,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
const eventOptions = { type: 'event', buffered: true, durationThreshold: 16 } as PerformanceObserverInit & { durationThreshold: number };
|
|
eventObserver.observe(eventOptions);
|
|
eventTimingSupported = true;
|
|
} catch { /* Event Timing unsupported */ }
|
|
}
|
|
const cutOff = (event: Event): void => {
|
|
if (event.isTrusted) cutoff = Math.min(cutoff, performance.now());
|
|
};
|
|
addEventListener('pointerdown', cutOff, { capture: true, once: true });
|
|
addEventListener('keydown', cutOff, { capture: true, once: true });
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.hidden) cutoff = Math.min(cutoff, performance.now());
|
|
});
|
|
const sentinelFrame = (now: number): void => {
|
|
if (traceStart !== null && traceEnd === null) pageRafs.push(now);
|
|
sentinel = requestAnimationFrame(sentinelFrame);
|
|
};
|
|
sentinel = requestAnimationFrame(sentinelFrame);
|
|
|
|
const hooks: PerformanceHooks = {
|
|
version: 'drive-trace.v1',
|
|
startTrace() {
|
|
traceStart = performance.now();
|
|
traceEnd = null;
|
|
pageRafs.length = 0;
|
|
sceneFrames.length = 0;
|
|
sceneIntervals.length = 0;
|
|
runningIntervalStart = document.querySelector('.journey-world')?.getAttribute('data-scene-state') === 'running' ? traceStart : null;
|
|
},
|
|
endTrace() {
|
|
traceEnd = performance.now();
|
|
if (runningIntervalStart !== null) {
|
|
sceneIntervals.push({ start: runningIntervalStart, end: traceEnd });
|
|
runningIntervalStart = null;
|
|
}
|
|
cancelAnimationFrame(sentinel);
|
|
},
|
|
recordSceneFrame(start, end) {
|
|
if (firstSceneFrame === null) firstSceneFrame = end;
|
|
if (traceStart !== null && traceEnd === null) sceneFrames.push({ start, end });
|
|
},
|
|
recordSceneState(state) {
|
|
const now = performance.now();
|
|
if (traceStart === null || traceEnd !== null) return;
|
|
if (state === 'running' && runningIntervalStart === null) runningIntervalStart = now;
|
|
if (state !== 'running' && runningIntervalStart !== null) {
|
|
sceneIntervals.push({ start: runningIntervalStart, end: now });
|
|
runningIntervalStart = null;
|
|
}
|
|
},
|
|
recordRuntimeAllocation(allocation) {
|
|
runtimeAllocation = Object.freeze({
|
|
...allocation,
|
|
textures: Object.freeze(allocation.textures.map((item) => Object.freeze({ ...item }))),
|
|
renderTargets: Object.freeze(allocation.renderTargets.map((item) => Object.freeze({ ...item }))),
|
|
});
|
|
},
|
|
armNavigationSettlementBarrier() {
|
|
barrierArmed = true;
|
|
barrierPendingEpoch = null;
|
|
barrierCancelledEpoch = null;
|
|
},
|
|
registerNavigationSettlement(epoch) {
|
|
if (barrierArmed) barrierPendingEpoch = epoch;
|
|
},
|
|
holdNavigationSettlement(epoch) {
|
|
return barrierArmed && barrierPendingEpoch === epoch;
|
|
},
|
|
noteNavigationEpoch(epoch) {
|
|
if (barrierArmed && barrierPendingEpoch !== null && barrierPendingEpoch !== epoch) {
|
|
barrierCancelledEpoch = barrierPendingEpoch;
|
|
barrierPendingEpoch = null;
|
|
barrierArmed = false;
|
|
}
|
|
},
|
|
beginAction(id, selector) {
|
|
if (!exactActionIds.has(id) || actionRecords.has(id)) throw new Error('The performance action is invalid or duplicated.');
|
|
const element = document.querySelector(selector);
|
|
if (!(element instanceof Element)) throw new Error('The performance action target is unavailable.');
|
|
const dispatchTime = performance.now();
|
|
for (const record of actionRecords.values()) {
|
|
if (record.element === element && record.captureEndTime === null) record.captureEndTime = dispatchTime;
|
|
}
|
|
actionRecords.set(id, {
|
|
id,
|
|
target: id,
|
|
dispatchTime,
|
|
captureEndTime: null,
|
|
acknowledgementCount: 0,
|
|
mutationTime: null,
|
|
doubleRafTime: null,
|
|
eventTimingSupported,
|
|
eventEntries: [],
|
|
element,
|
|
});
|
|
},
|
|
recordActionMutation(id) {
|
|
const record = actionRecords.get(id);
|
|
if (record === undefined) return;
|
|
record.acknowledgementCount += 1;
|
|
record.mutationTime = performance.now();
|
|
requestAnimationFrame(() => requestAnimationFrame(() => { record.doubleRafTime = performance.now(); }));
|
|
},
|
|
snapshot: () => Object.freeze({
|
|
version: 'drive-trace.v1' as const,
|
|
lcp: Object.freeze([...lcp]),
|
|
layoutShifts: Object.freeze([...layoutShifts]),
|
|
longTasks: Object.freeze([...longTasks]),
|
|
pageRafs: Object.freeze([...pageRafs]),
|
|
sceneFrames: Object.freeze([...sceneFrames]),
|
|
sceneIntervals: Object.freeze([...sceneIntervals]),
|
|
firstSceneFrame,
|
|
traceStart,
|
|
traceEnd,
|
|
cutoff,
|
|
navigationBarrier: Object.freeze({ armed: barrierArmed, pendingEpoch: barrierPendingEpoch, cancelledEpoch: barrierCancelledEpoch }),
|
|
actions: Object.freeze([...actionRecords.values()].map(({ element: _element, ...record }) => Object.freeze({
|
|
...record,
|
|
eventEntries: Object.freeze([...record.eventEntries]),
|
|
}))),
|
|
runtimeAllocation,
|
|
}),
|
|
};
|
|
Object.defineProperty(window, '__PORTFOLIO_PERFORMANCE__', {
|
|
configurable: false,
|
|
enumerable: false,
|
|
writable: false,
|
|
value: Object.freeze(hooks),
|
|
});
|
|
};
|