Skip to content

Commit a5aa9fc

Browse files
authored
feat: add trace event utils (#1215)
1 parent 316407b commit a5aa9fc

File tree

7 files changed

+1107
-2
lines changed

7 files changed

+1107
-2
lines changed

packages/utils/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
},
2525
"type": "module",
2626
"engines": {
27-
"node": ">=17.0.0"
27+
"node": ">=18.2.0"
2828
},
2929
"dependencies": {
3030
"@code-pushup/models": "0.104.0",

packages/utils/src/lib/clock-epoch.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@ export function epochClock(init: EpochClockOptions = {}) {
4141
msToUs(timeOriginMs + perfMs);
4242

4343
const fromEntryStartTimeMs = fromPerfMs;
44+
const fromEntry = (
45+
entry: {
46+
startTime: Milliseconds;
47+
entryType: string;
48+
duration: Milliseconds;
49+
},
50+
useEndTime = false,
51+
) =>
52+
fromPerfMs(
53+
entry.startTime +
54+
(entry.entryType === 'measure' && useEndTime ? entry.duration : 0),
55+
);
4456
const fromDateNowMs = fromEpochMs;
4557

4658
return {
@@ -55,6 +67,7 @@ export function epochClock(init: EpochClockOptions = {}) {
5567
fromEpochMs,
5668
fromEpochUs,
5769
fromPerfMs,
70+
fromEntry,
5871
fromEntryStartTimeMs,
5972
fromDateNowMs,
6073
};

packages/utils/src/lib/clock-epoch.unit.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ describe('epochClock', () => {
1010
expect(c.fromEpochMs).toBeFunction();
1111
expect(c.fromEpochUs).toBeFunction();
1212
expect(c.fromPerfMs).toBeFunction();
13+
expect(c.fromEntry).toBeFunction();
1314
expect(c.fromEntryStartTimeMs).toBeFunction();
1415
expect(c.fromDateNowMs).toBeFunction();
1516
});
@@ -33,7 +34,7 @@ describe('epochClock', () => {
3334
it('should support performance clock by default for epochNowUs', () => {
3435
const c = epochClock();
3536
expect(c.timeOriginMs).toBe(500_000);
36-
expect(c.epochNowUs()).toBe(500_000_000); // timeOrigin + performance.now() = timeOrigin + 0
37+
expect(c.epochNowUs()).toBe(500_000_000);
3738
});
3839

3940
it.each([
@@ -72,6 +73,41 @@ describe('epochClock', () => {
7273
]).toStrictEqual([c.fromPerfMs(0), c.fromPerfMs(1000)]);
7374
});
7475

76+
it('should convert performance mark to microseconds', () => {
77+
const markEntry = {
78+
name: 'test-mark',
79+
entryType: 'mark',
80+
startTime: 1000,
81+
duration: 0,
82+
} as PerformanceMark;
83+
84+
expect(defaultClock.fromEntry(markEntry)).toBe(
85+
defaultClock.fromPerfMs(1000),
86+
);
87+
expect(defaultClock.fromEntry(markEntry, true)).toBe(
88+
defaultClock.fromPerfMs(1000),
89+
);
90+
});
91+
92+
it('should convert performance measure to microseconds', () => {
93+
const measureEntry = {
94+
name: 'test-measure',
95+
entryType: 'measure',
96+
startTime: 1000,
97+
duration: 500,
98+
} as PerformanceMeasure;
99+
100+
expect(defaultClock.fromEntry(measureEntry)).toBe(
101+
defaultClock.fromPerfMs(1000),
102+
);
103+
expect(defaultClock.fromEntry(measureEntry, false)).toBe(
104+
defaultClock.fromPerfMs(1000),
105+
);
106+
expect(defaultClock.fromEntry(measureEntry, true)).toBe(
107+
defaultClock.fromPerfMs(1500),
108+
);
109+
});
110+
75111
it('should convert Date.now() milliseconds to microseconds', () => {
76112
const c = epochClock();
77113
expect([
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import os from 'node:os';
2+
import type { PerformanceMark, PerformanceMeasure } from 'node:perf_hooks';
3+
import { threadId } from 'node:worker_threads';
4+
import { defaultClock } from './clock-epoch.js';
5+
import type {
6+
BeginEvent,
7+
CompleteEvent,
8+
EndEvent,
9+
InstantEvent,
10+
InstantEventArgs,
11+
InstantEventTracingStartedInBrowser,
12+
SpanEvent,
13+
SpanEventArgs,
14+
TraceEvent,
15+
TraceEventContainer,
16+
} from './trace-file.type.js';
17+
18+
/** Global counter for generating unique span IDs within a trace */
19+
// eslint-disable-next-line functional/no-let
20+
let id2Count = 0;
21+
22+
/**
23+
* Generates a unique ID for linking begin and end span events in Chrome traces.
24+
* @returns Object with local ID string for the id2 field
25+
*/
26+
export const nextId2 = () => ({ local: `0x${++id2Count}` });
27+
28+
/**
29+
* Provides default values for trace event properties.
30+
* @param opt - Optional overrides for pid, tid, and timestamp
31+
* @returns Object with pid, tid, and timestamp
32+
*/
33+
const defaults = (opt?: { pid?: number; tid?: number; ts?: number }) => ({
34+
pid: opt?.pid ?? process.pid,
35+
tid: opt?.tid ?? threadId,
36+
ts: opt?.ts ?? defaultClock.epochNowUs(),
37+
});
38+
39+
/**
40+
* Generates a unique frame tree node ID from process and thread IDs.
41+
* @param pid - Process ID
42+
* @param tid - Thread ID
43+
* @returns Combined numeric ID
44+
*/
45+
export const frameTreeNodeId = (pid: number, tid: number) =>
46+
Number.parseInt(`${pid}0${tid}`, 10);
47+
48+
/**
49+
* Generates a frame name string from process and thread IDs.
50+
* @param pid - Process ID
51+
* @param tid - Thread ID
52+
* @returns Formatted frame name
53+
*/
54+
export const frameName = (pid: number, tid: number) => `FRAME0P${pid}T${tid}`;
55+
56+
/**
57+
* Creates an instant trace event for marking a point in time.
58+
* @param opt - Event configuration options
59+
* @returns InstantEvent object
60+
*/
61+
export const getInstantEvent = (opt: {
62+
name: string;
63+
ts?: number;
64+
pid?: number;
65+
tid?: number;
66+
args?: InstantEventArgs;
67+
}): InstantEvent => ({
68+
cat: 'blink.user_timing',
69+
ph: 'i',
70+
name: opt.name,
71+
...defaults(opt),
72+
args: opt.args ?? {},
73+
});
74+
75+
/**
76+
* Creates a start tracing event with frame information.
77+
* 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.
78+
* @param opt - Tracing configuration options
79+
* @returns StartTracingEvent object
80+
*/
81+
export const getInstantEventTracingStartedInBrowser = (opt: {
82+
url: string;
83+
ts?: number;
84+
pid?: number;
85+
tid?: number;
86+
}): InstantEventTracingStartedInBrowser => {
87+
const { pid, tid, ts } = defaults(opt);
88+
const id = frameTreeNodeId(pid, tid);
89+
90+
return {
91+
cat: 'devtools.timeline',
92+
ph: 'i',
93+
name: 'TracingStartedInBrowser',
94+
pid,
95+
tid,
96+
ts,
97+
args: {
98+
data: {
99+
frameTreeNodeId: id,
100+
frames: [
101+
{
102+
frame: frameName(pid, tid),
103+
isInPrimaryMainFrame: true,
104+
isOutermostMainFrame: true,
105+
name: '',
106+
processId: pid,
107+
url: opt.url,
108+
},
109+
],
110+
persistentIds: true,
111+
},
112+
},
113+
};
114+
};
115+
116+
/**
117+
* Creates a complete trace event with duration.
118+
* @param opt - Event configuration with name and duration
119+
* @returns CompleteEvent object
120+
*/
121+
export const getCompleteEvent = (opt: {
122+
name: string;
123+
dur: number;
124+
ts?: number;
125+
pid?: number;
126+
tid?: number;
127+
}): CompleteEvent => ({
128+
cat: 'devtools.timeline',
129+
ph: 'X',
130+
name: opt.name,
131+
dur: opt.dur,
132+
...defaults(opt),
133+
args: {},
134+
});
135+
136+
/** Options for creating span events */
137+
type SpanOpt = {
138+
name: string;
139+
id2: { local: string };
140+
ts?: number;
141+
pid?: number;
142+
tid?: number;
143+
args?: SpanEventArgs;
144+
};
145+
146+
/**
147+
* Creates a begin span event.
148+
* @param ph - Phase ('b' for begin)
149+
* @param opt - Span event options
150+
* @returns BeginEvent object
151+
*/
152+
export function getSpanEvent(ph: 'b', opt: SpanOpt): BeginEvent;
153+
/**
154+
* Creates an end span event.
155+
* @param ph - Phase ('e' for end)
156+
* @param opt - Span event options
157+
* @returns EndEvent object
158+
*/
159+
export function getSpanEvent(ph: 'e', opt: SpanOpt): EndEvent;
160+
/**
161+
* Creates a span event (begin or end).
162+
* @param ph - Phase ('b' or 'e')
163+
* @param opt - Span event options
164+
* @returns SpanEvent object
165+
*/
166+
export function getSpanEvent(ph: 'b' | 'e', opt: SpanOpt): SpanEvent {
167+
return {
168+
cat: 'blink.user_timing',
169+
ph,
170+
name: opt.name,
171+
id2: opt.id2,
172+
...defaults(opt),
173+
args: opt.args?.data?.detail
174+
? { data: { detail: opt.args.data.detail } }
175+
: {},
176+
};
177+
}
178+
179+
/**
180+
* Creates a pair of begin and end span events.
181+
* @param opt - Span configuration with start/end timestamps
182+
* @returns Tuple of BeginEvent and EndEvent
183+
*/
184+
export const getSpan = (opt: {
185+
name: string;
186+
tsB: number;
187+
tsE: number;
188+
id2?: { local: string };
189+
pid?: number;
190+
tid?: number;
191+
args?: SpanEventArgs;
192+
tsMarkerPadding?: number;
193+
}): [BeginEvent, EndEvent] => {
194+
// tsMarkerPadding is here to make the measure slightly smaller so the markers align perfectly.
195+
// Otherwise, the marker is visible at the start of the measure below the frame
196+
// No padding Padding
197+
// spans: ======== |======|
198+
// marks: | |
199+
const pad = opt.tsMarkerPadding ?? 1;
200+
// b|e need to share the same id2
201+
const id2 = opt.id2 ?? nextId2();
202+
203+
return [
204+
getSpanEvent('b', {
205+
...opt,
206+
id2,
207+
ts: opt.tsB + pad,
208+
}),
209+
getSpanEvent('e', {
210+
...opt,
211+
id2,
212+
ts: opt.tsE - pad,
213+
}),
214+
];
215+
};
216+
217+
/**
218+
* Converts a PerformanceMark to an instant trace event.
219+
* @param entry - Performance mark entry
220+
* @param opt - Optional overrides for name, pid, and tid
221+
* @returns InstantEvent object
222+
*/
223+
export const markToInstantEvent = (
224+
entry: PerformanceMark,
225+
opt?: { name?: string; pid?: number; tid?: number },
226+
): InstantEvent =>
227+
getInstantEvent({
228+
...opt,
229+
name: opt?.name ?? entry.name,
230+
ts: defaultClock.fromEntry(entry),
231+
args: entry.detail ? { detail: entry.detail } : undefined,
232+
});
233+
234+
/**
235+
* Converts a PerformanceMeasure to a pair of span events.
236+
* @param entry - Performance measure entry
237+
* @param opt - Optional overrides for name, pid, and tid
238+
* @returns Tuple of BeginEvent and EndEvent
239+
*/
240+
export const measureToSpanEvents = (
241+
entry: PerformanceMeasure,
242+
opt?: { name?: string; pid?: number; tid?: number },
243+
): [BeginEvent, EndEvent] =>
244+
getSpan({
245+
...opt,
246+
name: opt?.name ?? entry.name,
247+
tsB: defaultClock.fromEntry(entry),
248+
tsE: defaultClock.fromEntry(entry, true),
249+
args: entry.detail ? { data: { detail: entry.detail } } : undefined,
250+
});
251+
252+
/**
253+
* Creates a complete trace file container with metadata.
254+
* @param opt - Trace file configuration
255+
* @returns TraceEventContainer with events and metadata
256+
*/
257+
export const getTraceFile = (opt: {
258+
traceEvents: TraceEvent[];
259+
startTime?: string;
260+
}): TraceEventContainer => ({
261+
traceEvents: opt.traceEvents,
262+
displayTimeUnit: 'ms',
263+
metadata: {
264+
source: 'Node.js UserTiming',
265+
startTime: opt.startTime ?? new Date().toISOString(),
266+
hardwareConcurrency: os.cpus().length,
267+
},
268+
});

0 commit comments

Comments
 (0)