diff --git a/packages/utils/package.json b/packages/utils/package.json index 0f8098777..b9bf87395 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -24,7 +24,7 @@ }, "type": "module", "engines": { - "node": ">=17.0.0" + "node": ">=18.2.0" }, "dependencies": { "@code-pushup/models": "0.102.0", diff --git a/packages/utils/src/lib/clock-epoch.ts b/packages/utils/src/lib/clock-epoch.ts index b4380e6af..19afbe065 100644 --- a/packages/utils/src/lib/clock-epoch.ts +++ b/packages/utils/src/lib/clock-epoch.ts @@ -41,6 +41,18 @@ export function epochClock(init: EpochClockOptions = {}) { msToUs(timeOriginMs + perfMs); const fromEntryStartTimeMs = fromPerfMs; + const fromEntry = ( + entry: { + startTime: Milliseconds; + entryType: string; + duration: Milliseconds; + }, + useEndTime = false, + ) => + fromPerfMs( + entry.startTime + + (entry.entryType === 'measure' && useEndTime ? entry.duration : 0), + ); const fromDateNowMs = fromEpochMs; return { @@ -55,6 +67,7 @@ export function epochClock(init: EpochClockOptions = {}) { fromEpochMs, fromEpochUs, fromPerfMs, + fromEntry, fromEntryStartTimeMs, fromDateNowMs, }; diff --git a/packages/utils/src/lib/clock-epoch.unit.test.ts b/packages/utils/src/lib/clock-epoch.unit.test.ts index cb464d9eb..781f633ae 100644 --- a/packages/utils/src/lib/clock-epoch.unit.test.ts +++ b/packages/utils/src/lib/clock-epoch.unit.test.ts @@ -10,6 +10,7 @@ describe('epochClock', () => { expect(c.fromEpochMs).toBeFunction(); expect(c.fromEpochUs).toBeFunction(); expect(c.fromPerfMs).toBeFunction(); + expect(c.fromEntry).toBeFunction(); expect(c.fromEntryStartTimeMs).toBeFunction(); expect(c.fromDateNowMs).toBeFunction(); }); @@ -33,7 +34,7 @@ describe('epochClock', () => { it('should support performance clock by default for epochNowUs', () => { const c = epochClock(); expect(c.timeOriginMs).toBe(500_000); - expect(c.epochNowUs()).toBe(500_000_000); // timeOrigin + performance.now() = timeOrigin + 0 + expect(c.epochNowUs()).toBe(500_000_000); }); it.each([ @@ -72,6 +73,41 @@ describe('epochClock', () => { ]).toStrictEqual([c.fromPerfMs(0), c.fromPerfMs(1000)]); }); + it('should convert performance mark to microseconds', () => { + const markEntry = { + name: 'test-mark', + entryType: 'mark', + startTime: 1000, + duration: 0, + } as PerformanceMark; + + expect(defaultClock.fromEntry(markEntry)).toBe( + defaultClock.fromPerfMs(1000), + ); + expect(defaultClock.fromEntry(markEntry, true)).toBe( + defaultClock.fromPerfMs(1000), + ); + }); + + it('should convert performance measure to microseconds', () => { + const measureEntry = { + name: 'test-measure', + entryType: 'measure', + startTime: 1000, + duration: 500, + } as PerformanceMeasure; + + expect(defaultClock.fromEntry(measureEntry)).toBe( + defaultClock.fromPerfMs(1000), + ); + expect(defaultClock.fromEntry(measureEntry, false)).toBe( + defaultClock.fromPerfMs(1000), + ); + expect(defaultClock.fromEntry(measureEntry, true)).toBe( + defaultClock.fromPerfMs(1500), + ); + }); + it('should convert Date.now() milliseconds to microseconds', () => { const c = epochClock(); expect([ diff --git a/packages/utils/src/lib/trace-file-utils.ts b/packages/utils/src/lib/trace-file-utils.ts new file mode 100644 index 000000000..2a2f3eb30 --- /dev/null +++ b/packages/utils/src/lib/trace-file-utils.ts @@ -0,0 +1,268 @@ +import os from 'node:os'; +import type { PerformanceMark, PerformanceMeasure } from 'node:perf_hooks'; +import { threadId } from 'node:worker_threads'; +import { defaultClock } from './clock-epoch.js'; +import type { + BeginEvent, + CompleteEvent, + EndEvent, + InstantEvent, + InstantEventArgs, + InstantEventTracingStartedInBrowser, + SpanEvent, + SpanEventArgs, + TraceEvent, + TraceEventContainer, +} from './trace-file.type.js'; + +/** Global counter for generating unique span IDs within a trace */ +// eslint-disable-next-line functional/no-let +let id2Count = 0; + +/** + * Generates a unique ID for linking begin and end span events in Chrome traces. + * @returns Object with local ID string for the id2 field + */ +export const nextId2 = () => ({ local: `0x${++id2Count}` }); + +/** + * Provides default values for trace event properties. + * @param opt - Optional overrides for pid, tid, and timestamp + * @returns Object with pid, tid, and timestamp + */ +const defaults = (opt?: { pid?: number; tid?: number; ts?: number }) => ({ + pid: opt?.pid ?? process.pid, + tid: opt?.tid ?? threadId, + ts: opt?.ts ?? defaultClock.epochNowUs(), +}); + +/** + * Generates a unique frame tree node ID from process and thread IDs. + * @param pid - Process ID + * @param tid - Thread ID + * @returns Combined numeric ID + */ +export const frameTreeNodeId = (pid: number, tid: number) => + Number.parseInt(`${pid}0${tid}`, 10); + +/** + * Generates a frame name string from process and thread IDs. + * @param pid - Process ID + * @param tid - Thread ID + * @returns Formatted frame name + */ +export const frameName = (pid: number, tid: number) => `FRAME0P${pid}T${tid}`; + +/** + * Creates an instant trace event for marking a point in time. + * @param opt - Event configuration options + * @returns InstantEvent object + */ +export const getInstantEvent = (opt: { + name: string; + ts?: number; + pid?: number; + tid?: number; + args?: InstantEventArgs; +}): InstantEvent => ({ + cat: 'blink.user_timing', + ph: 'i', + name: opt.name, + ...defaults(opt), + args: opt.args ?? {}, +}); + +/** + * Creates a start tracing event with frame information. + * This event is needed at the beginning of the traceEvents array to make tell the UI profiling has started, and it should visualize the data. + * @param opt - Tracing configuration options + * @returns StartTracingEvent object + */ +export const getInstantEventTracingStartedInBrowser = (opt: { + url: string; + ts?: number; + pid?: number; + tid?: number; +}): InstantEventTracingStartedInBrowser => { + const { pid, tid, ts } = defaults(opt); + const id = frameTreeNodeId(pid, tid); + + return { + cat: 'devtools.timeline', + ph: 'i', + name: 'TracingStartedInBrowser', + pid, + tid, + ts, + args: { + data: { + frameTreeNodeId: id, + frames: [ + { + frame: frameName(pid, tid), + isInPrimaryMainFrame: true, + isOutermostMainFrame: true, + name: '', + processId: pid, + url: opt.url, + }, + ], + persistentIds: true, + }, + }, + }; +}; + +/** + * Creates a complete trace event with duration. + * @param opt - Event configuration with name and duration + * @returns CompleteEvent object + */ +export const getCompleteEvent = (opt: { + name: string; + dur: number; + ts?: number; + pid?: number; + tid?: number; +}): CompleteEvent => ({ + cat: 'devtools.timeline', + ph: 'X', + name: opt.name, + dur: opt.dur, + ...defaults(opt), + args: {}, +}); + +/** Options for creating span events */ +type SpanOpt = { + name: string; + id2: { local: string }; + ts?: number; + pid?: number; + tid?: number; + args?: SpanEventArgs; +}; + +/** + * Creates a begin span event. + * @param ph - Phase ('b' for begin) + * @param opt - Span event options + * @returns BeginEvent object + */ +export function getSpanEvent(ph: 'b', opt: SpanOpt): BeginEvent; +/** + * Creates an end span event. + * @param ph - Phase ('e' for end) + * @param opt - Span event options + * @returns EndEvent object + */ +export function getSpanEvent(ph: 'e', opt: SpanOpt): EndEvent; +/** + * Creates a span event (begin or end). + * @param ph - Phase ('b' or 'e') + * @param opt - Span event options + * @returns SpanEvent object + */ +export function getSpanEvent(ph: 'b' | 'e', opt: SpanOpt): SpanEvent { + return { + cat: 'blink.user_timing', + ph, + name: opt.name, + id2: opt.id2, + ...defaults(opt), + args: opt.args?.data?.detail + ? { data: { detail: opt.args.data.detail } } + : {}, + }; +} + +/** + * Creates a pair of begin and end span events. + * @param opt - Span configuration with start/end timestamps + * @returns Tuple of BeginEvent and EndEvent + */ +export const getSpan = (opt: { + name: string; + tsB: number; + tsE: number; + id2?: { local: string }; + pid?: number; + tid?: number; + args?: SpanEventArgs; + tsMarkerPadding?: number; +}): [BeginEvent, EndEvent] => { + // tsMarkerPadding is here to make the measure slightly smaller so the markers align perfectly. + // Otherwise, the marker is visible at the start of the measure below the frame + // No padding Padding + // spans: ======== |======| + // marks: | | + const pad = opt.tsMarkerPadding ?? 1; + // b|e need to share the same id2 + const id2 = opt.id2 ?? nextId2(); + + return [ + getSpanEvent('b', { + ...opt, + id2, + ts: opt.tsB + pad, + }), + getSpanEvent('e', { + ...opt, + id2, + ts: opt.tsE - pad, + }), + ]; +}; + +/** + * Converts a PerformanceMark to an instant trace event. + * @param entry - Performance mark entry + * @param opt - Optional overrides for name, pid, and tid + * @returns InstantEvent object + */ +export const markToInstantEvent = ( + entry: PerformanceMark, + opt?: { name?: string; pid?: number; tid?: number }, +): InstantEvent => + getInstantEvent({ + ...opt, + name: opt?.name ?? entry.name, + ts: defaultClock.fromEntry(entry), + args: entry.detail ? { detail: entry.detail } : undefined, + }); + +/** + * Converts a PerformanceMeasure to a pair of span events. + * @param entry - Performance measure entry + * @param opt - Optional overrides for name, pid, and tid + * @returns Tuple of BeginEvent and EndEvent + */ +export const measureToSpanEvents = ( + entry: PerformanceMeasure, + opt?: { name?: string; pid?: number; tid?: number }, +): [BeginEvent, EndEvent] => + getSpan({ + ...opt, + name: opt?.name ?? entry.name, + tsB: defaultClock.fromEntry(entry), + tsE: defaultClock.fromEntry(entry, true), + args: entry.detail ? { data: { detail: entry.detail } } : undefined, + }); + +/** + * Creates a complete trace file container with metadata. + * @param opt - Trace file configuration + * @returns TraceEventContainer with events and metadata + */ +export const getTraceFile = (opt: { + traceEvents: TraceEvent[]; + startTime?: string; +}): TraceEventContainer => ({ + traceEvents: opt.traceEvents, + displayTimeUnit: 'ms', + metadata: { + source: 'Node.js UserTiming', + startTime: opt.startTime ?? new Date().toISOString(), + hardwareConcurrency: os.cpus().length, + }, +}); diff --git a/packages/utils/src/lib/trace-file-utils.unit.test.ts b/packages/utils/src/lib/trace-file-utils.unit.test.ts new file mode 100644 index 000000000..e8cbf319a --- /dev/null +++ b/packages/utils/src/lib/trace-file-utils.unit.test.ts @@ -0,0 +1,487 @@ +import type { PerformanceMark, PerformanceMeasure } from 'node:perf_hooks'; +import { describe, expect, it } from 'vitest'; +import { + frameName, + frameTreeNodeId, + getCompleteEvent, + getInstantEvent, + getInstantEventTracingStartedInBrowser, + getSpan, + getSpanEvent, + getTraceFile, + markToInstantEvent, + measureToSpanEvents, +} from './trace-file-utils.js'; + +describe('getTraceFile', () => { + it('should create trace file with empty events array', () => { + const result = getTraceFile({ traceEvents: [] }); + + expect(result).toStrictEqual({ + traceEvents: [], + displayTimeUnit: 'ms', + metadata: { + source: 'Node.js UserTiming', + startTime: expect.any(String), + hardwareConcurrency: expect.any(Number), + }, + }); + expect(() => new Date(result?.metadata!.startTime)).not.toThrow(); + }); + + it('should create trace file with events', () => { + expect( + getTraceFile({ + traceEvents: [ + getInstantEvent({ + name: 'test-event', + ts: 1_234_567_890, + pid: 123, + tid: 456, + }), + ], + }), + ).toStrictEqual({ + traceEvents: [ + expect.objectContaining({ + name: 'test-event', + ts: 1_234_567_890, + pid: 123, + tid: 456, + }), + ], + displayTimeUnit: 'ms', + metadata: { + source: 'Node.js UserTiming', + startTime: expect.any(String), + hardwareConcurrency: expect.any(Number), + }, + }); + }); + + it('should use custom startTime when provided', () => { + const result = getTraceFile({ + traceEvents: [], + startTime: '2023-01-01T00:00:00.000Z', + }); + + expect(result).toHaveProperty( + 'metadata', + expect.objectContaining({ + startTime: '2023-01-01T00:00:00.000Z', + }), + ); + }); + + it('should include hardware concurrency', () => { + expect(getTraceFile({ traceEvents: [] })).toHaveProperty( + 'metadata', + expect.objectContaining({ + hardwareConcurrency: expect.any(Number), + }), + ); + }); +}); + +describe('frameTreeNodeId', () => { + it.each([ + [123, 456, 1_230_456], + [1, 2, 102], + [999, 999, 9_990_999], + ])('should generate correct frame tree node ID', (pid, tid, expected) => { + expect(frameTreeNodeId(pid, tid)).toBe(expected); + }); +}); + +describe('frameName', () => { + it.each([ + [123, 456], + [1, 2], + [999, 999], + ])('should generate correct frame name', (pid, tid) => { + expect(frameName(pid, tid)).toBe(`FRAME0P${pid}T${tid}`); + }); +}); + +describe('getInstantEventTracingStartedInBrowser', () => { + it('should create start tracing event with required url', () => { + expect( + getInstantEventTracingStartedInBrowser({ url: 'https://example.com' }), + ).toStrictEqual({ + cat: 'devtools.timeline', + ph: 'i', + name: 'TracingStartedInBrowser', + pid: expect.any(Number), + tid: expect.any(Number), + ts: expect.any(Number), + args: { + data: { + frameTreeNodeId: expect.any(Number), + frames: [ + { + frame: expect.stringMatching(/^FRAME0P\d+T\d+$/), + isInPrimaryMainFrame: true, + isOutermostMainFrame: true, + name: '', + processId: expect.any(Number), + url: 'https://example.com', + }, + ], + persistentIds: true, + }, + }, + }); + }); + + it('should use custom pid and tid', () => { + expect( + getInstantEventTracingStartedInBrowser({ + url: 'https://test.com', + pid: 777, + tid: 888, + }), + ).toStrictEqual({ + cat: 'devtools.timeline', + ph: 'i', + name: 'TracingStartedInBrowser', + pid: 777, + tid: 888, + ts: expect.any(Number), + args: { + data: { + frameTreeNodeId: 7_770_888, + frames: [ + { + frame: 'FRAME0P777T888', + isInPrimaryMainFrame: true, + isOutermostMainFrame: true, + name: '', + processId: 777, + url: 'https://test.com', + }, + ], + persistentIds: true, + }, + }, + }); + }); +}); + +describe('getCompleteEvent', () => { + it('should create complete event with required fields', () => { + expect( + getCompleteEvent({ + name: 'test-complete', + dur: 1000, + }), + ).toStrictEqual({ + cat: 'devtools.timeline', + ph: 'X', + name: 'test-complete', + dur: 1000, + pid: expect.any(Number), + tid: expect.any(Number), + ts: expect.any(Number), + args: {}, + }); + }); + + it('should use custom pid, tid, and ts', () => { + expect( + getCompleteEvent({ + name: 'custom-complete', + dur: 500, + pid: 111, + tid: 222, + ts: 1_234_567_890, + }), + ).toStrictEqual({ + cat: 'devtools.timeline', + ph: 'X', + name: 'custom-complete', + dur: 500, + pid: 111, + tid: 222, + ts: 1_234_567_890, + args: {}, + }); + }); +}); + +describe('markToInstantEvent', () => { + it('should convert performance mark to instant event with detail', () => { + expect( + markToInstantEvent({ + name: 'test-mark', + startTime: 1000, + detail: { customData: 'test' }, + } as PerformanceMark), + ).toStrictEqual({ + cat: 'blink.user_timing', + ph: 'i', + name: 'test-mark', + pid: expect.any(Number), + tid: expect.any(Number), + ts: expect.any(Number), + args: { detail: { customData: 'test' } }, + }); + }); + + it('should convert performance mark to instant event without detail', () => { + expect( + markToInstantEvent({ + name: 'test-mark', + startTime: 1000, + detail: null, + } as PerformanceMark), + ).toStrictEqual({ + cat: 'blink.user_timing', + ph: 'i', + name: 'test-mark', + pid: expect.any(Number), + tid: expect.any(Number), + ts: expect.any(Number), + args: {}, + }); + }); + + it('should use custom options when provided', () => { + expect( + markToInstantEvent( + { + name: 'test-mark', + startTime: 1000, + detail: { customData: 'test' }, + } as PerformanceMark, + { + name: 'custom-name', + pid: 999, + tid: 888, + }, + ), + ).toStrictEqual({ + cat: 'blink.user_timing', + ph: 'i', + name: 'custom-name', + pid: 999, + tid: 888, + ts: expect.any(Number), + args: { detail: { customData: 'test' } }, + }); + }); +}); + +describe('measureToSpanEvents', () => { + it('should convert performance measure to span events with detail', () => { + expect( + measureToSpanEvents({ + name: 'test-measure', + startTime: 1000, + duration: 500, + detail: { measurement: 'data' }, + } as PerformanceMeasure), + ).toStrictEqual([ + { + cat: 'blink.user_timing', + ph: 'b', + name: 'test-measure', + pid: expect.any(Number), + tid: expect.any(Number), + ts: expect.any(Number), + id2: { local: expect.stringMatching(/^0x\d+$/) }, + args: { data: { detail: { measurement: 'data' } } }, + }, + { + cat: 'blink.user_timing', + ph: 'e', + name: 'test-measure', + pid: expect.any(Number), + tid: expect.any(Number), + ts: expect.any(Number), + id2: { local: expect.stringMatching(/^0x\d+$/) }, + args: { data: { detail: { measurement: 'data' } } }, + }, + ]); + }); + + it('should convert performance measure to span events without detail', () => { + expect( + measureToSpanEvents({ + name: 'test-measure', + startTime: 1000, + duration: 500, + detail: undefined, + } as PerformanceMeasure), + ).toStrictEqual([ + { + cat: 'blink.user_timing', + ph: 'b', + name: 'test-measure', + pid: expect.any(Number), + tid: expect.any(Number), + ts: expect.any(Number), + id2: { local: expect.stringMatching(/^0x\d+$/) }, + args: {}, + }, + { + cat: 'blink.user_timing', + ph: 'e', + name: 'test-measure', + pid: expect.any(Number), + tid: expect.any(Number), + ts: expect.any(Number), + id2: { local: expect.stringMatching(/^0x\d+$/) }, + args: {}, + }, + ]); + }); + + it('should use custom options when provided', () => { + const result = measureToSpanEvents( + { + name: 'test-measure', + startTime: 1000, + duration: 500, + detail: { measurement: 'data' }, + } as PerformanceMeasure, + { + name: 'custom-measure', + pid: 777, + tid: 666, + }, + ); + + expect(result).toStrictEqual([ + expect.objectContaining({ + name: 'custom-measure', + pid: 777, + tid: 666, + args: { data: { detail: { measurement: 'data' } } }, + }), + expect.objectContaining({ + name: 'custom-measure', + pid: 777, + tid: 666, + args: { data: { detail: { measurement: 'data' } } }, + }), + ]); + }); +}); + +describe('getSpanEvent', () => { + it('should create begin event with args detail', () => { + expect( + getSpanEvent('b', { + name: 'test-span', + id2: { local: '0x1' }, + args: { data: { detail: { customData: 'test' } as any } }, + }), + ).toStrictEqual({ + cat: 'blink.user_timing', + ph: 'b', + name: 'test-span', + pid: expect.any(Number), + tid: expect.any(Number), + ts: expect.any(Number), + id2: { local: '0x1' }, + args: { data: { detail: { customData: 'test' } } }, + }); + }); + + it('should create end event without args detail', () => { + expect( + getSpanEvent('e', { + name: 'test-span', + id2: { local: '0x2' }, + }), + ).toStrictEqual({ + cat: 'blink.user_timing', + ph: 'e', + name: 'test-span', + pid: expect.any(Number), + tid: expect.any(Number), + ts: expect.any(Number), + id2: { local: '0x2' }, + args: {}, + }); + }); +}); + +describe('getSpan', () => { + it('should create span events with custom tsMarkerPadding', () => { + const result = getSpan({ + name: 'test-span', + tsB: 1000, + tsE: 1500, + tsMarkerPadding: 5, + args: {}, + }); + + expect(result).toStrictEqual([ + { + cat: 'blink.user_timing', + ph: 'b', + name: 'test-span', + pid: expect.any(Number), + tid: expect.any(Number), + ts: 1005, + id2: { local: expect.stringMatching(/^0x\d+$/) }, + args: {}, + }, + { + cat: 'blink.user_timing', + ph: 'e', + name: 'test-span', + pid: expect.any(Number), + tid: expect.any(Number), + ts: 1495, + id2: { local: expect.stringMatching(/^0x\d+$/) }, + args: {}, + }, + ]); + }); + + it('should generate id2 when not provided', () => { + const result = getSpan({ + name: 'test-span', + tsB: 1000, + tsE: 1500, + }); + + expect(result).toHaveLength(2); + expect(result[0].id2?.local).toMatch(/^0x\d+$/); + expect(result[1].id2).toEqual(result[0].id2); + }); + + it('should use provided id2', () => { + expect( + getSpan({ + name: 'test-span', + tsB: 1000, + tsE: 1500, + id2: { local: 'custom-id' }, + }), + ).toStrictEqual([ + { + cat: 'blink.user_timing', + ph: 'b', + name: 'test-span', + pid: expect.any(Number), + tid: expect.any(Number), + ts: 1001, + id2: { local: 'custom-id' }, + args: {}, + }, + { + cat: 'blink.user_timing', + ph: 'e', + name: 'test-span', + pid: expect.any(Number), + tid: expect.any(Number), + ts: 1499, + id2: { local: 'custom-id' }, + args: {}, + }, + ]); + }); +}); diff --git a/packages/utils/src/lib/trace-file.type.ts b/packages/utils/src/lib/trace-file.type.ts new file mode 100644 index 000000000..e59a0d7c8 --- /dev/null +++ b/packages/utils/src/lib/trace-file.type.ts @@ -0,0 +1,292 @@ +import type { UserTimingDetail } from './user-timing-extensibility-api.type.js'; + +/** + * Arguments for instant trace events. + * @property {UserTimingDetail} [detail] - Optional user timing detail with DevTools payload + */ +export type InstantEventArgs = { + detail?: UserTimingDetail; +} & { [key: string]: unknown }; + +/** + * Arguments for span trace events (begin/end events). + * @property {object} [data] - Optional data object + * @property {UserTimingDetail} [data.detail] - Optional user timing detail with DevTools payload + */ +export type SpanEventArgs = { + data?: { detail?: UserTimingDetail }; +} & { [key: string]: unknown }; + +/** + * Arguments for complete trace events. + * @property {Record} [detail] - Optional detail object with arbitrary properties + */ +export type CompleteEventArgs = { detail?: Record }; + +/** + * Arguments for start tracing events. + * @property {object} data - Tracing initialization data + * @property {number} data.frameTreeNodeId - Frame tree node identifier + * @property {Array} data.frames - Array of frame information + * @property {boolean} data.persistentIds - Whether IDs are persistent + */ +export type InstantEventTracingStartedInBrowserArgs = { + data: { + frameTreeNodeId: number; + frames: { + frame: string; + isInPrimaryMainFrame: boolean; + isOutermostMainFrame: boolean; + name: string; + processId: number; + url: string; + }[]; + persistentIds: boolean; + }; +}; + +/** + * Union type of all possible trace event arguments. + */ +export type TraceArgs = + | InstantEventArgs + | SpanEventArgs + | CompleteEventArgs + | InstantEventTracingStartedInBrowserArgs; + +/** + * Base properties shared by all trace events. + * @property {string} cat - Event category + * @property {string} name - Event name + * @property {number} pid - Process ID + * @property {number} tid - Thread ID + * @property {number} ts - Timestamp in epoch microseconds + * @property {TraceArgs} [args] - Optional event arguments + */ +export type BaseTraceEvent = { + cat: string; + name: string; + pid: number; + tid: number; + ts: number; + args: TraceArgs; +}; + +/** + * Start tracing event for Chrome DevTools tracing. + */ +export type InstantEventTracingStartedInBrowser = BaseTraceEvent & { + cat: 'devtools.timeline'; + ph: 'i'; + name: 'TracingStartedInBrowser'; + args: InstantEventTracingStartedInBrowserArgs; +}; + +/** + * Complete trace event with duration. + * Represents a complete operation with start time and duration. + * @property {'X'} ph - Phase indicator for complete events + * @property {number} dur - Duration in microseconds + */ +export type CompleteEvent = BaseTraceEvent & { ph: 'X'; dur: number }; + +/** + * Instant trace event representing a single point in time. + * Used for user timing marks and other instantaneous events. + * @property {'blink.user_timing'} cat - Fixed category for user timing events + * @property {'i'} ph - Phase indicator for instant events + * @property {never} [dur] - Duration is not applicable for instant events + * @property {InstantEventArgs} [args] - Optional event arguments + */ +export type InstantEvent = Omit & { + cat: 'blink.user_timing'; + ph: 'i'; + dur?: never; + args: InstantEventArgs; +}; + +/** + * Core properties for span trace events (begin/end pairs). + * @property {object} id2 - Span identifier + * @property {string} id2.local - Local span ID (unique to the process, same for b and e events) + * @property {SpanEventArgs} [args] - Optional event arguments + */ +type SpanCore = Omit & { + id2: { local: string }; + args: SpanEventArgs; +}; +/** + * Begin event for a span (paired with an end event). + * @property {'b'} ph - Phase indicator for begin events + * @property {never} [dur] - Duration is not applicable for begin events + */ +export type BeginEvent = SpanCore & { + ph: 'b'; + dur?: never; +}; + +/** + * End event for a span (paired with a begin event). + * @property {'e'} ph - Phase indicator for end events + * @property {never} [dur] - Duration is not applicable for end events + */ +export type EndEvent = SpanCore & { ph: 'e'; dur?: never }; + +/** + * Union type for span events (begin or end). + */ +export type SpanEvent = BeginEvent | EndEvent; + +/** + * Union type of all trace event types. + */ +export type TraceEvent = + | InstantEvent + | CompleteEvent + | SpanEvent + | InstantEventTracingStartedInBrowser; + +/** + * Raw arguments format for trace events before processing. + * Either contains a detail string directly or nested in a data object. + */ +type RawArgs = + | { detail?: string; [key: string]: unknown } + | { data?: { detail?: string }; [key: string]: unknown }; + +/** + * Raw trace event format before type conversion. + * Similar to TraceEvent but with unprocessed arguments. + */ +export type TraceEventRaw = Omit & { args: RawArgs }; + +/** + * Time window bounds (min, max) in trace time units (e.g. microseconds). + * @property {number} min - Minimum timestamp in the window + * @property {number} max - Maximum timestamp in the window + * @property {number} range - Calculated range (max - min) + */ +export type BreadcrumbWindow = { + min: number; + max: number; + range: number; +}; + +/** + * Custom label for a specific trace entry. + * @property {number | string} entryId - ID or index of the trace entry + * @property {string} label - Label text for the entry + * @property {string} [color] - Optional display color for the label + */ +export type EntryLabel = { + entryId: number | string; + label: string; + color?: string; +}; + +/** + * Link or relation between two trace entries. + * @property {number | string} fromEntryId - Source entry ID for the link + * @property {number | string} toEntryId - Target entry ID for the link + * @property {string} [linkType] - Optional type or description of the link + */ +export type EntryLink = { + fromEntryId: number | string; + toEntryId: number | string; + linkType?: string; +}; + +/** + * A time range annotated with a label. + * @property {number} startTime - Start timestamp of the range (microseconds) + * @property {number} endTime - End timestamp of the range (microseconds) + * @property {string} label - Annotation label for the time range + * @property {string} [color] - Optional display color for the range + */ +export type LabelledTimeRange = { + startTime: number; + endTime: number; + label: string; + color?: string; +}; + +/** + * Hidden or expandable entries information. + * @property {unknown[]} hiddenEntries - IDs or indexes of hidden entries + * @property {unknown[]} expandableEntries - IDs or indexes of expandable entries + */ +export type EntriesModifications = { + hiddenEntries: unknown[]; + expandableEntries: unknown[]; +}; + +/** + * Initial breadcrumb information for time ranges and window. + * @property {BreadcrumbWindow} window - Time window bounds + * @property {unknown | null} child - Child breadcrumb or null + */ +export type InitialBreadcrumb = { + window: BreadcrumbWindow; + child: unknown | null; +}; + +/** + * Annotations such as labels and links between entries. + * @property {EntryLabel[]} entryLabels - Custom labels for entries + * @property {LabelledTimeRange[]} labelledTimeRanges - Time ranges annotated with labels + * @property {EntryLink[]} linksBetweenEntries - Links or relations between entries + */ +export type Annotations = { + entryLabels: EntryLabel[]; + labelledTimeRanges: LabelledTimeRange[]; + linksBetweenEntries: EntryLink[]; +}; + +/** + * Modifications made to trace data or UI in DevTools export + */ +export type Modifications = { + entriesModifications: EntriesModifications; + initialBreadcrumb: InitialBreadcrumb; + annotations: Annotations; +}; + +/** + * Top-level metadata for a trace file exported by Chrome DevTools. + * DevTools may add new fields over time. + */ +export type TraceMetadata = { + /** Usually "DevTools" for exports from the Performance panel */ + source: string; + + /** ISO timestamp when trace was recorded */ + startTime: string; + + /** May be present when recorded with throttling settings */ + hardwareConcurrency?: number; + + /** Common fields found in DevTools traces */ + cpuThrottling?: number; + networkThrottling?: string; + enhancedTraceVersion?: number; + + /** Allow additional custom metadata properties */ + [key: string]: unknown; +}; + +/** + * Structured container for trace events with metadata. + * @property {TraceEvent[]} traceEvents - Array of trace events + * @property {'ms' | 'ns'} [displayTimeUnit] - Time unit for display (milliseconds or nanoseconds) + * @property {TraceMetadata} [metadata] - Optional metadata about the trace + */ +export type TraceEventContainer = { + traceEvents: TraceEvent[]; + displayTimeUnit?: 'ms' | 'ns'; + metadata?: TraceMetadata; +}; + +/** + * Trace file format - either an array of events or a structured container. + */ +export type TraceFile = TraceEvent[] | TraceEventContainer; diff --git a/packages/utils/src/lib/user-timing-extensibility-api.type.ts b/packages/utils/src/lib/user-timing-extensibility-api.type.ts index 0e75be77b..7ea4979f2 100644 --- a/packages/utils/src/lib/user-timing-extensibility-api.type.ts +++ b/packages/utils/src/lib/user-timing-extensibility-api.type.ts @@ -152,3 +152,12 @@ export type MarkOptionsWithDevtools< export type MeasureOptionsWithDevtools = { detail?: WithDevToolsPayload; } & Omit; + +/** + * Detail object containing DevTools payload for user timing events. + * Extends WithDevToolsPayload to include track entry or marker payload. + * This can be used in trace event arguments to provide additional context in DevTools. + */ +export type UserTimingDetail = WithDevToolsPayload< + TrackEntryPayload | MarkerPayload +>;