From 56b3cbe51c6ce4fe23291fc1ac0c530f61d7bd37 Mon Sep 17 00:00:00 2001 From: John Doe Date: Mon, 12 Jan 2026 01:28:51 +0100 Subject: [PATCH 1/7] fix: add measure utils --- .../user-timing-extensibility-api-utils.ts | 194 ++++++- ...iming-extensibility-api-utils.unit.test.ts | 508 +++++++++++++++++- 2 files changed, 667 insertions(+), 35 deletions(-) diff --git a/packages/utils/src/lib/user-timing-extensibility-api-utils.ts b/packages/utils/src/lib/user-timing-extensibility-api-utils.ts index 78a8e6d78..ed02634ba 100644 --- a/packages/utils/src/lib/user-timing-extensibility-api-utils.ts +++ b/packages/utils/src/lib/user-timing-extensibility-api-utils.ts @@ -1,3 +1,4 @@ +import { performance } from 'node:perf_hooks'; import type { DevToolsColor, DevToolsProperties, @@ -12,13 +13,20 @@ import type { const dataTypeTrackEntry = 'track-entry'; const dataTypeMarker = 'marker'; +export function mergePropertiesWithOverwrite< + const T extends DevToolsProperties, + const U extends DevToolsProperties, +>(baseProperties: T, overrideProperties: U): (T[number] | U[number])[]; +export function mergePropertiesWithOverwrite< + const T extends DevToolsProperties, +>(baseProperties: T): T; export function mergePropertiesWithOverwrite( - baseProperties: DevToolsProperties | undefined, - overrideProperties?: DevToolsProperties | undefined, -) { + baseProperties?: DevToolsProperties, + overrideProperties?: DevToolsProperties, +): DevToolsProperties { return [ ...new Map([...(baseProperties ?? []), ...(overrideProperties ?? [])]), - ] satisfies DevToolsProperties; + ]; } export function markerPayload(options?: Omit) { @@ -49,19 +57,15 @@ export function markerErrorPayload( } satisfies MarkerPayload; } -export function trackEntryErrorPayload< - T extends string, - C extends DevToolsColor, ->( +export function trackEntryErrorPayload( options: Omit & { track: T; - color?: C; }, ) { - const { track, color = 'error' as const, ...restOptions } = options; + const { track, ...restOptions } = options; return { dataType: dataTypeTrackEntry, - color, + color: 'error' as const, track, ...restOptions, } satisfies TrackEntryPayload; @@ -86,7 +90,7 @@ export function errorToEntryMeta( const { properties, tooltipText } = options ?? {}; const props = mergePropertiesWithOverwrite( errorToDevToolsProperties(e), - properties, + properties ?? [], ); return { properties: props, @@ -127,19 +131,6 @@ export function errorToMarkerPayload( } satisfies MarkerPayload; } -/** - * asOptions wraps a DevTools payload into the `detail` property of User Timing entry options. - * - * @example - * profiler.mark('mark', asOptions({ - * dataType: 'marker', - * color: 'error', - * tooltipText: 'This is a marker', - * properties: [ - * ['str', 'This is a detail property'] - * ], - * })); - */ export function asOptions( devtools?: T | null, ): MarkOptionsWithDevtools; @@ -151,5 +142,156 @@ export function asOptions( ): { detail?: WithDevToolsPayload; } { - return devtools == null ? { detail: {} } : { detail: { devtools } }; + if (devtools == null) { + return { detail: {} }; + } + + return { detail: { devtools } }; +} + +export type Names = { + startName: `${N}:start`; + endName: `${N}:end`; + measureName: N; +}; + +export function getNames(base: T): Names; +export function getNames( + base: T, + prefix?: P, +): Names<`${P}:${T}`>; +export function getNames(base: string, prefix?: string) { + const n = prefix ? `${prefix}:${base}` : base; + return { + startName: `${n}:start`, + endName: `${n}:end`, + measureName: n, + } as const; +} + +type Simplify = { [K in keyof T]: T[K] } & object; + +type MergeObjects = T extends readonly [ + infer F extends object, + ...infer R extends readonly object[], +] + ? Simplify> & MergeObjects> + : object; + +export type MergeResult< + P extends readonly Partial[], +> = MergeObjects

& { properties?: DevToolsProperties }; + +export function mergeDevtoolsPayload< + const P extends readonly Partial[], +>(...parts: P): MergeResult

{ + return parts.reduce( + (acc, cur) => ({ + ...acc, + ...cur, + ...(cur.properties || acc.properties + ? { + properties: mergePropertiesWithOverwrite( + acc.properties ?? [], + cur.properties ?? [], + ), + } + : {}), + }), + {} as Partial, + ) as MergeResult

; +} + +export function mergeDevtoolsPayloadAction< + const P extends readonly [ActionTrack, ...Partial[]], +>(...parts: P): MergeObjects

& { properties?: DevToolsProperties } { + return mergeDevtoolsPayload( + ...(parts as unknown as readonly Partial< + TrackEntryPayload | MarkerPayload + >[]), + ) as MergeObjects

& { properties?: DevToolsProperties }; +} + +export type ActionColorPayload = { + color?: DevToolsColor; +}; +export type ActionTrack = TrackEntryPayload & ActionColorPayload; + +export function setupTracks< + const T extends Record>, + const D extends ActionTrack, +>(defaults: D, tracks: T): Record { + return Object.entries(tracks).reduce( + (result, [key, track]) => ({ + ...result, + [key]: mergeDevtoolsPayload(defaults, track) as ActionTrack, + }), + {} as Record, + ); +} + +/** + * This is a helper function used to ensure that the marks used to create a measure do not contain UI interaction properties. + * @param devtools - The devtools payload to convert to mark options. + * @returns The mark options without tooltipText and properties. + */ +function toMarkMeasureOpts(devtools: TrackEntryPayload) { + const { tooltipText: _, properties: __, ...markDevtools } = devtools; + return { detail: { devtools: markDevtools } }; +} + +export type MeasureOptions = Partial & { + success?: (result: unknown) => EntryMeta; + error?: (error: unknown) => EntryMeta; +}; + +export type MeasureCtxOptions = ActionTrack & { + prefix?: string; +} & { + error?: (error: unknown) => EntryMeta; +}; +export function measureCtx(cfg: MeasureCtxOptions) { + const { prefix, error: globalErr, ...defaults } = cfg; + + return (event: string, opt?: MeasureOptions) => { + const { success, error, ...measurePayload } = opt ?? {}; + const merged = mergeDevtoolsPayloadAction(defaults, measurePayload, { + dataType: dataTypeTrackEntry, + }) as TrackEntryPayload; + + const { + startName: s, + endName: e, + measureName: m, + } = getNames(event, prefix); + + return { + start: () => performance.mark(s, toMarkMeasureOpts(merged)), + + success: (r: unknown) => { + const successPayload = mergeDevtoolsPayload(merged, success?.(r) ?? {}); + performance.mark(e, toMarkMeasureOpts(successPayload)); + performance.measure(m, { + start: s, + end: e, + ...asOptions(successPayload), + }); + }, + + error: (err: unknown) => { + const errorPayload = mergeDevtoolsPayload( + errorToEntryMeta(err), + globalErr?.(err) ?? {}, + error?.(err) ?? {}, + { ...merged, color: 'error' }, + ); + performance.mark(e, toMarkMeasureOpts(errorPayload)); + performance.measure(m, { + start: s, + end: e, + ...asOptions(errorPayload), + }); + }, + }; + }; } diff --git a/packages/utils/src/lib/user-timing-extensibility-api-utils.unit.test.ts b/packages/utils/src/lib/user-timing-extensibility-api-utils.unit.test.ts index 440f74787..23c4a9a25 100644 --- a/packages/utils/src/lib/user-timing-extensibility-api-utils.unit.test.ts +++ b/packages/utils/src/lib/user-timing-extensibility-api-utils.unit.test.ts @@ -1,16 +1,29 @@ -import { describe, expect, it } from 'vitest'; +import { performance } from 'node:perf_hooks'; +import { threadId } from 'node:worker_threads'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + type MeasureCtxOptions, + type MeasureOptions, asOptions, errorToDevToolsProperties, errorToEntryMeta, errorToMarkerPayload, errorToTrackEntryPayload, + getNames, markerErrorPayload, markerPayload, + measureCtx, + mergeDevtoolsPayload, mergePropertiesWithOverwrite, + setupTracks, trackEntryErrorPayload, trackEntryPayload, } from './user-timing-extensibility-api-utils.js'; +import type { + EntryMeta, + TrackEntryPayload, + TrackMeta, +} from './user-timing-extensibility-api.type.js'; describe('mergePropertiesWithOverwrite', () => { it('should merge properties with overwrite', () => { @@ -33,15 +46,15 @@ describe('mergePropertiesWithOverwrite', () => { }); it('should handle undefined base properties', () => { - expect( - mergePropertiesWithOverwrite(undefined, [['key', 'value']]), - ).toStrictEqual([['key', 'value']]); + expect(mergePropertiesWithOverwrite([['key', 'value']])).toStrictEqual([ + ['key', 'value'], + ]); }); it('should handle undefined override properties', () => { - expect( - mergePropertiesWithOverwrite([['key', 'value']], undefined), - ).toStrictEqual([['key', 'value']]); + expect(mergePropertiesWithOverwrite([['key', 'value']])).toStrictEqual([ + ['key', 'value'], + ]); }); }); @@ -137,13 +150,12 @@ describe('trackEntryErrorPayload', () => { trackEntryErrorPayload({ track: 'Custom Track', trackGroup: 'Custom Group', - color: 'warning', tooltipText: 'warning occurred', properties: [['level', 'high']], }), ).toStrictEqual({ dataType: 'track-entry', - color: 'warning', + color: 'error', track: 'Custom Track', trackGroup: 'Custom Group', tooltipText: 'warning occurred', @@ -219,6 +231,16 @@ describe('errorToEntryMeta', () => { ], }); }); + + it('should handle error with undefined options', () => { + const result = errorToEntryMeta(new Error('test error'), undefined); + expect(result).toStrictEqual({ + properties: [ + ['Error Type', 'Error'], + ['Error Message', 'test error'], + ], + }); + }); }); describe('errorToTrackEntryPayload', () => { @@ -304,6 +326,140 @@ describe('errorToMarkerPayload', () => { }); }); +describe('getNames', () => { + it('should generate names without prefix', () => { + const result = getNames('test'); + expect(result).toStrictEqual({ + startName: 'test:start', + endName: 'test:end', + measureName: 'test', + }); + }); + + it('should generate names with prefix', () => { + const result = getNames('operation', 'db'); + expect(result).toStrictEqual({ + startName: 'db:operation:start', + endName: 'db:operation:end', + measureName: 'db:operation', + }); + }); + + it('should handle empty prefix', () => { + const result = getNames('task', ''); + expect(result).toStrictEqual({ + startName: 'task:start', + endName: 'task:end', + measureName: 'task', + }); + }); +}); + +describe('mergeDevtoolsPayload', () => { + it('should return empty object when no payloads provided', () => { + expect(mergeDevtoolsPayload()).toStrictEqual({}); + }); + + it('should return the same payload when single payload provided', () => { + const payload: TrackEntryPayload = { + dataType: 'track-entry', + track: 'Test Track', + color: 'primary', + properties: [['key1', 'value1']], + }; + expect(mergeDevtoolsPayload(payload)).toBe(payload); + }); + + it('should merge multiple track entry payloads', () => { + const payload1: TrackEntryPayload = { + track: 'Test Track', + color: 'primary', + }; + const payload2: Partial = { + trackGroup: 'Test Group', + tooltipText: 'Test tooltip', + properties: [['key2', 'value2']], + }; + const payload3: EntryMeta = { + properties: [['key3', 'value3']], + }; + + expect(mergeDevtoolsPayload(payload1, payload2, payload3)).toStrictEqual({ + track: 'Test Track', + color: 'primary', + trackGroup: 'Test Group', + tooltipText: 'Test tooltip', + properties: [ + ['key2', 'value2'], + ['key3', 'value3'], + ], + }); + }); + + it('should merge multiple property payloads with overwrite behavior', () => { + const payload1: EntryMeta = { + properties: [['key1', 'value1']], + }; + const payload2: EntryMeta = { + properties: [ + ['key1', 'overwrite'], + ['key2', 'value2'], + ], + }; + + expect(mergeDevtoolsPayload(payload1, payload2)).toStrictEqual({ + properties: [ + ['key1', 'overwrite'], + ['key2', 'value2'], + ], + }); + }); + + it('should handle undefined and empty properties', () => { + const payload1: TrackMeta = { + track: 'Test', + }; + const payload2: EntryMeta = { + properties: undefined, + }; + + expect(mergeDevtoolsPayload(payload1, payload2)).toStrictEqual({ + track: 'Test', + properties: [['key1', 'value1']], + }); + }); +}); + +describe('setupTracks', () => { + it('should create track definitions with defaults as base', () => { + const defaults: TrackEntryPayload = { + track: 'Main Track', + color: 'primary', + trackGroup: 'My Group', + }; + const tracks = { + main: { track: 'Main Track' }, + secondary: { track: 'Secondary Track' }, + }; + + const result = setupTracks(defaults, tracks); + expect(result).toStrictEqual({ + main: { + track: 'Main Track', + color: 'primary', + trackGroup: 'My Group', + dataType: 'track-entry', + }, + secondary: { + track: 'Secondary Track', + color: 'primary', + trackGroup: 'My Group', + dataType: 'track-entry', + }, + }); + }); +}); + describe('asOptions', () => { it('should convert marker payload to mark options', () => { const devtools = markerPayload({ color: 'primary' }); @@ -327,3 +483,337 @@ describe('asOptions', () => { expect(asOptions(undefined)).toStrictEqual({ detail: {} }); }); }); + +describe('measureCtx', () => { + beforeEach(() => { + vi.spyOn(performance, 'mark').mockImplementation(vi.fn()); + vi.spyOn(performance, 'measure').mockImplementation(vi.fn()); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('creates measure context and supports measurement', () => { + // Your code to profile + const codeToProfile = ({ fail }: { fail?: boolean } = {}) => { + if (fail) { + throw new Error('test error'); + } + return 1; + }; + + // Base global config - define once + const globalDefaults: MeasureCtxOptions = { + track: 'Global Track', + properties: [['Global:Config', `Process ID ${process.pid}`]], + color: 'primary-dark', + error: (error: unknown) => ({ + properties: [['Global:Error', `Custom Error Info: ${String(error)}`]], + }), + } as const; + + // Local overrides - define once + const localOverrides: MeasureOptions = { + color: 'primary', + properties: [['Runtime:Config', `Thread ID ${threadId}`]], + success: (result: unknown) => ({ + properties: [['Runtime:Result', String(result)]], + }), + error: (error: unknown) => ({ + properties: [ + ['Runtime:Error', `Stack Trace: ${String((error as Error)?.stack)}`], + ], + }), + } as const; + + const profilerCtx = measureCtx(globalDefaults); + const { start, success } = profilerCtx('utils', localOverrides); + + start(); // <= start mark + expect(performance.mark).toHaveBeenCalledWith('utils:start', { + detail: { + devtools: { + dataType: 'track-entry', + track: 'Global Track', + color: 'primary', // local override wins + }, + }, + }); + + const result = codeToProfile(); + success(result); // <= end mark + measure (success) + expect(performance.mark).toHaveBeenLastCalledWith('utils:end', { + detail: { + devtools: { + dataType: 'track-entry', + track: 'Global Track', + color: 'primary', + }, + }, + }); + expect(performance.measure).toHaveBeenCalledWith('utils', { + start: 'utils:start', + end: 'utils:end', + detail: { + devtools: { + dataType: 'track-entry', + track: 'Global Track', + color: 'primary', + properties: [ + ['Global:Config', `Process ID ${process.pid}`], + ['Runtime:Config', `Thread ID ${threadId}`], + ['Runtime:Result', String(result)], + ], + }, + }, + }); + }); + + it('creates measure context with minimal config', () => { + expect(measureCtx({ track: 'Global Track' })('utils')).toStrictEqual({ + start: expect.any(Function), + success: expect.any(Function), + error: expect.any(Function), + }); + }); + + it('creates start mark with global defaults', () => { + const { start } = measureCtx({ + track: 'Global Track', + trackGroup: 'Global Track Group', + color: 'primary-dark', + properties: [['Global:Config', `Process ID ${process.pid}`]], + })('load-cfg'); + start(); + expect(performance.mark).toHaveBeenCalledWith('load-cfg:start', { + detail: { + devtools: { + dataType: 'track-entry', + track: 'Global Track', + trackGroup: 'Global Track Group', + color: 'primary-dark', + // Marks do not have EntryMeta as hover/click is rare + }, + }, + }); + }); + + it('creates start mark with local overrides', () => { + const { start } = measureCtx({ + track: 'Global Track', + color: 'primary-dark', + })('load-cfg', { + color: 'primary', + properties: [['Runtime:Config', `Thread ID ${threadId}`]], + }); + start(); + expect(performance.mark).toHaveBeenCalledWith('load-cfg:start', { + detail: { + devtools: { + dataType: 'track-entry', + track: 'Global Track', + color: 'primary', + // Marks do not have EntryMeta as hover/click is rare + }, + }, + }); + }); + + it('creates success mark and measure with global defaults', () => { + const { success } = measureCtx({ + track: 'Global Track', + trackGroup: 'Global Track Group', + color: 'primary-dark', + properties: [['Global:Config', `Process ID ${process.pid}`]], + })('load-cfg'); + success(1); + expect(performance.mark).toHaveBeenCalledWith('load-cfg:end', { + detail: { + devtools: { + dataType: 'track-entry', + track: 'Global Track', + trackGroup: 'Global Track Group', + color: 'primary-dark', + // Marks do not have EntryMeta as hover/click is rare + }, + }, + }); + expect(performance.measure).toHaveBeenCalledWith('load-cfg', { + start: 'load-cfg:start', + end: 'load-cfg:end', + detail: { + devtools: { + dataType: 'track-entry', + track: 'Global Track', + trackGroup: 'Global Track Group', + color: 'primary-dark', + properties: [['Global:Config', `Process ID ${process.pid}`]], + }, + }, + }); + }); + + it('creates success mark and measure with local overrides and success handler', () => { + const { success } = measureCtx({ + track: 'Global Track', + color: 'primary-dark', + properties: [['Global:Config', `Process ID ${process.pid}`]], + })('test', { + color: 'primary', + properties: [['Runtime:Config', `Thread ID ${threadId}`]], + success: (result: unknown) => ({ + properties: [['Runtime:Result', String(result)]], + }), + }); + success(1); + expect(performance.mark).toHaveBeenCalledWith('test:end', { + detail: { + devtools: { + dataType: 'track-entry', + track: 'Global Track', + color: 'primary', + // Marks do not have EntryMeta as hover/click is rare + }, + }, + }); + expect(performance.measure).toHaveBeenCalledWith('test', { + start: 'test:start', + end: 'test:end', + detail: { + devtools: { + dataType: 'track-entry', + track: 'Global Track', + color: 'primary', + properties: [ + ['Global:Config', `Process ID ${process.pid}`], + ['Runtime:Config', `Thread ID ${threadId}`], + ['Runtime:Result', '1'], + ], + }, + }, + }); + }); + + it('creates error mark and measure with global defaults', () => { + const error = new Error('test error'); + const { error: errorFn } = measureCtx({ + track: 'Global Track', + trackGroup: 'Global Track Group', + color: 'primary-dark', + properties: [['Global:Config', `Process ID ${process.pid}`]], + })('load-cfg'); + errorFn(error); + expect(performance.mark).toHaveBeenCalledWith('load-cfg:end', { + detail: { + devtools: { + dataType: 'track-entry', + track: 'Global Track', + trackGroup: 'Global Track Group', + color: 'error', + // Marks do not have EntryMeta as hover/click is rare + }, + }, + }); + expect(performance.measure).toHaveBeenCalledWith('load-cfg', { + start: 'load-cfg:start', + end: 'load-cfg:end', + detail: { + devtools: { + dataType: 'track-entry', + track: 'Global Track', + trackGroup: 'Global Track Group', + color: 'error', + properties: [ + ['Error Type', 'Error'], + ['Error Message', 'test error'], + ['Global:Config', `Process ID ${process.pid}`], + ], + }, + }, + }); + }); + + it('creates error mark and measure with local overrides and error handler', () => { + const error = new Error('test error'); + const { error: errorFn } = measureCtx({ + track: 'Global Track', + color: 'primary-dark', + properties: [['Global:Config', `Process ID ${process.pid}`]], + })('test', { + color: 'primary', + properties: [['Runtime:Config', `Thread ID ${threadId}`]], + error: (err: unknown) => ({ + properties: [ + ['Runtime:Error', `Stack Trace: ${String((err as Error)?.stack)}`], + ], + }), + }); + errorFn(error); + expect(performance.mark).toHaveBeenCalledWith('test:end', { + detail: { + devtools: { + dataType: 'track-entry', + track: 'Global Track', + color: 'error', + // Marks do not have EntryMeta as hover/click is rare + }, + }, + }); + expect(performance.measure).toHaveBeenCalledWith('test', { + start: 'test:start', + end: 'test:end', + detail: { + devtools: { + dataType: 'track-entry', + track: 'Global Track', + color: 'error', + properties: [ + ['Error Type', 'Error'], + ['Error Message', 'test error'], + [ + 'Runtime:Error', + `Stack Trace: ${String((error as Error)?.stack)}`, + ], + ['Global:Config', `Process ID ${process.pid}`], + ['Runtime:Config', `Thread ID ${threadId}`], + ], + }, + }, + }); + }); + + it('creates error mark and measure with no error handlers', () => { + const error = new Error('test error'); + const { error: errorFn } = measureCtx({ + track: 'Global Track', + properties: [['Global:Config', `Process ID ${process.pid}`]], + })('test'); + errorFn(error); + expect(performance.mark).toHaveBeenCalledWith('test:end', { + detail: { + devtools: { + dataType: 'track-entry', + track: 'Global Track', + color: 'error', + }, + }, + }); + expect(performance.measure).toHaveBeenCalledWith('test', { + start: 'test:start', + end: 'test:end', + detail: { + devtools: { + dataType: 'track-entry', + track: 'Global Track', + color: 'error', + properties: [ + ['Error Type', 'Error'], + ['Error Message', 'test error'], + ['Global:Config', `Process ID ${process.pid}`], + ], + }, + }, + }); + }); +}); From 4f1602b37cc8da09e699a844f03d8fd806d93563 Mon Sep 17 00:00:00 2001 From: John Doe Date: Mon, 12 Jan 2026 01:36:18 +0100 Subject: [PATCH 2/7] fix: fix lint --- .../user-timing-extensibility-api-utils.ts | 41 ++++++++----------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/packages/utils/src/lib/user-timing-extensibility-api-utils.ts b/packages/utils/src/lib/user-timing-extensibility-api-utils.ts index ed02634ba..befa8a03a 100644 --- a/packages/utils/src/lib/user-timing-extensibility-api-utils.ts +++ b/packages/utils/src/lib/user-timing-extensibility-api-utils.ts @@ -1,4 +1,5 @@ import { performance } from 'node:perf_hooks'; +import { objectToEntries } from './transform.js'; import type { DevToolsColor, DevToolsProperties, @@ -185,21 +186,18 @@ export type MergeResult< export function mergeDevtoolsPayload< const P extends readonly Partial[], >(...parts: P): MergeResult

{ - return parts.reduce( - (acc, cur) => ({ - ...acc, - ...cur, - ...(cur.properties || acc.properties - ? { - properties: mergePropertiesWithOverwrite( - acc.properties ?? [], - cur.properties ?? [], - ), - } - : {}), - }), - {} as Partial, - ) as MergeResult

; + return parts.reduce((acc, cur) => ({ + ...acc, + ...cur, + ...(cur.properties || acc.properties + ? { + properties: mergePropertiesWithOverwrite( + acc.properties ?? [], + cur.properties ?? [], + ), + } + : {}), + })) as MergeResult

; } export function mergeDevtoolsPayloadAction< @@ -220,14 +218,11 @@ export type ActionTrack = TrackEntryPayload & ActionColorPayload; export function setupTracks< const T extends Record>, const D extends ActionTrack, ->(defaults: D, tracks: T): Record { - return Object.entries(tracks).reduce( - (result, [key, track]) => ({ - ...result, - [key]: mergeDevtoolsPayload(defaults, track) as ActionTrack, - }), - {} as Record, - ); +>(defaults: D, tracks: T) { + return objectToEntries(tracks).reduce((result, [key, track]) => ({ + ...result, + [key]: mergeDevtoolsPayload(defaults, track), + })) as Record; } /** From 749cf546bba90533d8dc413d2fa21b738a9c00ed Mon Sep 17 00:00:00 2001 From: John Doe Date: Mon, 12 Jan 2026 03:14:11 +0100 Subject: [PATCH 3/7] fix: unit tests --- .../user-timing-extensibility-api-utils.ts | 40 +++++++++++-------- ...iming-extensibility-api-utils.unit.test.ts | 4 +- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/packages/utils/src/lib/user-timing-extensibility-api-utils.ts b/packages/utils/src/lib/user-timing-extensibility-api-utils.ts index befa8a03a..0d36c0060 100644 --- a/packages/utils/src/lib/user-timing-extensibility-api-utils.ts +++ b/packages/utils/src/lib/user-timing-extensibility-api-utils.ts @@ -186,18 +186,21 @@ export type MergeResult< export function mergeDevtoolsPayload< const P extends readonly Partial[], >(...parts: P): MergeResult

{ - return parts.reduce((acc, cur) => ({ - ...acc, - ...cur, - ...(cur.properties || acc.properties - ? { - properties: mergePropertiesWithOverwrite( - acc.properties ?? [], - cur.properties ?? [], - ), - } - : {}), - })) as MergeResult

; + return parts.reduce( + (acc, cur) => ({ + ...acc, + ...cur, + ...(cur.properties || acc.properties + ? { + properties: mergePropertiesWithOverwrite( + acc.properties ?? [], + cur.properties ?? [], + ), + } + : {}), + }), + {}, + ) as MergeResult

; } export function mergeDevtoolsPayloadAction< @@ -219,10 +222,15 @@ export function setupTracks< const T extends Record>, const D extends ActionTrack, >(defaults: D, tracks: T) { - return objectToEntries(tracks).reduce((result, [key, track]) => ({ - ...result, - [key]: mergeDevtoolsPayload(defaults, track), - })) as Record; + return objectToEntries(tracks).reduce( + (result, [key, track]) => ({ + ...result, + [key]: mergeDevtoolsPayload(defaults, track, { + dataType: dataTypeTrackEntry, + }), + }), + {} as Record, + ) as Record; } /** diff --git a/packages/utils/src/lib/user-timing-extensibility-api-utils.unit.test.ts b/packages/utils/src/lib/user-timing-extensibility-api-utils.unit.test.ts index 23c4a9a25..70fd3072c 100644 --- a/packages/utils/src/lib/user-timing-extensibility-api-utils.unit.test.ts +++ b/packages/utils/src/lib/user-timing-extensibility-api-utils.unit.test.ts @@ -367,7 +367,7 @@ describe('mergeDevtoolsPayload', () => { color: 'primary', properties: [['key1', 'value1']], }; - expect(mergeDevtoolsPayload(payload)).toBe(payload); + expect(mergeDevtoolsPayload(payload)).toStrictEqual(payload); }); it('should merge multiple track entry payloads', () => { @@ -425,7 +425,7 @@ describe('mergeDevtoolsPayload', () => { expect(mergeDevtoolsPayload(payload1, payload2)).toStrictEqual({ track: 'Test', - properties: [['key1', 'value1']], + properties: undefined, }); }); }); From e08c722bb472107fcd2acd82f6aa23471912bd13 Mon Sep 17 00:00:00 2001 From: John Doe Date: Mon, 12 Jan 2026 03:21:47 +0100 Subject: [PATCH 4/7] fix: lint --- packages/utils/src/lib/user-timing-extensibility-api-utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/utils/src/lib/user-timing-extensibility-api-utils.ts b/packages/utils/src/lib/user-timing-extensibility-api-utils.ts index 0d36c0060..16c79d58b 100644 --- a/packages/utils/src/lib/user-timing-extensibility-api-utils.ts +++ b/packages/utils/src/lib/user-timing-extensibility-api-utils.ts @@ -229,6 +229,7 @@ export function setupTracks< dataType: dataTypeTrackEntry, }), }), + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions {} as Record, ) as Record; } From f641ae048ee7a9aa8ac2d0ef358d819671f991d8 Mon Sep 17 00:00:00 2001 From: John Doe Date: Wed, 14 Jan 2026 05:20:24 +0100 Subject: [PATCH 5/7] refactor: wip --- .../user-timing-extensibility-api-utils.ts | 345 +++++++++++++++--- ...iming-extensibility-api-utils.unit.test.ts | 67 ++-- .../lib/user-timing-extensibility-api.type.ts | 22 +- 3 files changed, 354 insertions(+), 80 deletions(-) diff --git a/packages/utils/src/lib/user-timing-extensibility-api-utils.ts b/packages/utils/src/lib/user-timing-extensibility-api-utils.ts index 16c79d58b..4e0356802 100644 --- a/packages/utils/src/lib/user-timing-extensibility-api-utils.ts +++ b/packages/utils/src/lib/user-timing-extensibility-api-utils.ts @@ -1,6 +1,7 @@ import { performance } from 'node:perf_hooks'; -import { objectToEntries } from './transform.js'; +import { objectFromEntries, objectToEntries } from './transform.js'; import type { + ActionTrackEntryPayload, DevToolsColor, DevToolsProperties, EntryMeta, @@ -14,6 +15,12 @@ import type { const dataTypeTrackEntry = 'track-entry'; const dataTypeMarker = 'marker'; +/** + * Merges DevTools properties with override priority. + * @param baseProperties - Base properties array + * @param overrideProperties - Override properties array + * @returns Merged properties array + */ export function mergePropertiesWithOverwrite< const T extends DevToolsProperties, const U extends DevToolsProperties, @@ -22,14 +29,26 @@ export function mergePropertiesWithOverwrite< const T extends DevToolsProperties, >(baseProperties: T): T; export function mergePropertiesWithOverwrite( - baseProperties?: DevToolsProperties, + baseProperties: DevToolsProperties, overrideProperties?: DevToolsProperties, ): DevToolsProperties { - return [ - ...new Map([...(baseProperties ?? []), ...(overrideProperties ?? [])]), - ]; + return [...new Map([...baseProperties, ...(overrideProperties ?? [])])]; } +/** + * Creates a marker payload with default data type. + * @param options - Marker options excluding dataType + * @returns Complete marker payload + * @example + * ```ts + * const payload = markerPayload({ + * color: 'primary', + * tooltipText: 'User action completed', + * properties: [['action', 'save'], ['duration', 150]] + * }); + * // { dataType: 'marker', color: 'primary', tooltipText: 'User action completed', ... } + * ``` + */ export function markerPayload(options?: Omit) { return { dataType: dataTypeMarker, @@ -37,6 +56,22 @@ export function markerPayload(options?: Omit) { } satisfies MarkerPayload; } +/** + * Creates a track entry payload with default data type. + * @param options - Track entry options excluding dataType + * @returns Complete track entry payload + * @example + * ```ts + * const payload = trackEntryPayload({ + * track: 'user-interactions', + * trackGroup: 'frontend', + * color: 'secondary', + * tooltipText: 'Button click processed', + * properties: [['element', 'save-button'], ['response-time', 200]] + * }); + * // { dataType: 'track-entry', track: 'user-interactions', ... } + * ``` + */ export function trackEntryPayload( options: Omit, ) { @@ -48,6 +83,11 @@ export function trackEntryPayload( } satisfies TrackEntryPayload; } +/** + * Creates an error marker payload with red color. + * @param options - Marker options excluding dataType and color + * @returns Error marker payload + */ export function markerErrorPayload( options?: Omit, ) { @@ -58,6 +98,11 @@ export function markerErrorPayload( } satisfies MarkerPayload; } +/** + * Creates an error track entry payload with red color. + * @param options - Track entry options excluding color and dataType + * @returns Error track entry payload + */ export function trackEntryErrorPayload( options: Omit & { track: T; @@ -72,6 +117,11 @@ export function trackEntryErrorPayload( } satisfies TrackEntryPayload; } +/** + * Converts an error to DevTools properties array. + * @param e - Error object or value + * @returns Array of error properties for DevTools + */ export function errorToDevToolsProperties(e: unknown) { const name = e instanceof Error ? e.name : 'UnknownError'; const message = e instanceof Error ? e.message : String(e); @@ -81,6 +131,12 @@ export function errorToDevToolsProperties(e: unknown) { ] satisfies DevToolsProperties; } +/** + * Converts an error to entry metadata for DevTools. + * @param e - Error object or value + * @param options - Additional metadata options + * @returns Entry metadata with error properties + */ export function errorToEntryMeta( e: unknown, options?: { @@ -99,6 +155,12 @@ export function errorToEntryMeta( } satisfies EntryMeta; } +/** + * Converts an error to a track entry payload with error styling. + * @param error - Error object or value + * @param detail - Track entry details excluding color and dataType + * @returns Error track entry payload + */ export function errorToTrackEntryPayload( error: unknown, detail: Omit & { @@ -117,6 +179,12 @@ export function errorToTrackEntryPayload( } satisfies TrackEntryPayload; } +/** + * Converts an error to a marker payload with error styling. + * @param error - Error object or value + * @param detail - Marker details excluding color and dataType + * @returns Error marker payload + */ export function errorToMarkerPayload( error: unknown, detail?: Omit, @@ -132,6 +200,23 @@ export function errorToMarkerPayload( } satisfies MarkerPayload; } +/** + * Converts DevTools payload to performance API options format. + * @param devtools - DevTools payload or null + * @returns Performance API options with DevTools detail + * @example + * ```ts + * const marker = markerPayload({ color: 'primary', tooltipText: 'Start' }); + * performance.mark('start', asOptions(marker)); + * + * const trackEntry = trackEntryPayload({ track: 'operations', color: 'tertiary' }); + * performance.measure('operation', { + * start: 'start', + * end: 'end', + * ...asOptions(trackEntry) + * }); + * ``` + */ export function asOptions( devtools?: T | null, ): MarkOptionsWithDevtools; @@ -150,12 +235,23 @@ export function asOptions( return { detail: { devtools } }; } +/** + * Generates start, end, and measure names for performance tracking. + * @param base - Base name for the measurement + * @returns Object with startName, endName, and measureName + */ export type Names = { startName: `${N}:start`; endName: `${N}:end`; measureName: N; }; +/** + * Generates start, end, and measure names for performance tracking. + * @param base - Base name for the measurement + * @param prefix - Optional prefix for names + * @returns Object with startName, endName, and measureName + */ export function getNames(base: T): Names; export function getNames( base: T, @@ -170,22 +266,61 @@ export function getNames(base: string, prefix?: string) { } as const; } -type Simplify = { [K in keyof T]: T[K] } & object; +/** + * Removes undefined from a type, effectively filtering out undefined values. + */ +type Defined = T extends undefined ? never : T; + +/** + * Merges two objects with the specified overwrite semantics: + * - If B[K] is undefined → keep A[K] + * - If B[K] is defined → overwrite with Defined + * - Keys only in A → keep A[K] + * - Keys only in B → take Defined + */ +type MergeDefined = { + [K in keyof A | keyof B]: K extends keyof B + ? Defined extends never + ? K extends keyof A + ? A[K] + : never + : Defined + : K extends keyof A + ? A[K] + : never; +}; -type MergeObjects = T extends readonly [ - infer F extends object, - ...infer R extends readonly object[], +/** + * Recursively merges an array of objects using MergeDefined semantics. + * The first element is the base type, subsequent elements only overwrite with defined values. + */ +type MergeResult

= P extends readonly [ + infer A, + ...infer R, ] - ? Simplify> & MergeObjects> + ? MergeDefined> : object; -export type MergeResult< - P extends readonly Partial[], -> = MergeObjects

& { properties?: DevToolsProperties }; - +/** + * Merges multiple DevTools payloads into a single payload. + * The first payload establishes the base type, subsequent payloads only overwrite with defined values. + * @param parts - Array of payloads where first is complete and rest are partial + * @returns Merged payload with combined properties + * @example + * ```ts + * const payload = mergeDevtoolsPayload( + * trackEntryPayload({ track: 'user-interactions', color: 'secondary' }), + * { color: 'primary', tooltipText: 'User action completed' }, + * ); + * // { track: 'user-interactions', color: 'primary', tooltipText: 'User action completed' } + * ``` + */ export function mergeDevtoolsPayload< - const P extends readonly Partial[], ->(...parts: P): MergeResult

{ + const P extends readonly [ + TrackEntryPayload | MarkerPayload, + ...Partial[], + ], +>(...parts: P): MergeResult

& { properties?: DevToolsProperties } { return parts.reduce( (acc, cur) => ({ ...acc, @@ -199,70 +334,166 @@ export function mergeDevtoolsPayload< } : {}), }), - {}, - ) as MergeResult

; -} - -export function mergeDevtoolsPayloadAction< - const P extends readonly [ActionTrack, ...Partial[]], ->(...parts: P): MergeObjects

& { properties?: DevToolsProperties } { - return mergeDevtoolsPayload( - ...(parts as unknown as readonly Partial< - TrackEntryPayload | MarkerPayload - >[]), - ) as MergeObjects

& { properties?: DevToolsProperties }; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + {} as MergeResult

& { properties?: DevToolsProperties }, + ); } -export type ActionColorPayload = { - color?: DevToolsColor; -}; -export type ActionTrack = TrackEntryPayload & ActionColorPayload; - +/** + * Sets up tracks with default values merged into each track. + * This helps to avoid repetition when defining multiple tracks with common properties. + * @param defaults - Default action track configuration + * @param tracks - Track configurations to merge with defaults + * @returns Record with merged track configurations + */ export function setupTracks< - const T extends Record>, - const D extends ActionTrack, + const T extends Record>, + const D extends ActionTrackEntryPayload, >(defaults: D, tracks: T) { - return objectToEntries(tracks).reduce( - (result, [key, track]) => ({ - ...result, - [key]: mergeDevtoolsPayload(defaults, track, { - dataType: dataTypeTrackEntry, - }), - }), - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - {} as Record, - ) as Record; + return objectFromEntries( + objectToEntries(tracks).map(([key, track]) => [ + key, + mergeDevtoolsPayload(defaults, track), + ]), + ); } /** * This is a helper function used to ensure that the marks used to create a measure do not contain UI interaction properties. * @param devtools - The devtools payload to convert to mark options. - * @returns The mark options without tooltipText and properties. + * @returns The mark options without dataType, tooltipText and properties. */ -function toMarkMeasureOpts(devtools: TrackEntryPayload) { - const { tooltipText: _, properties: __, ...markDevtools } = devtools; +function toMarkMeasureOpts(devtools: T) { + const { + dataType: _, + tooltipText: __, + properties: ___, + ...markDevtools + } = devtools; return { detail: { devtools: markDevtools } }; } -export type MeasureOptions = Partial & { - success?: (result: unknown) => EntryMeta; - error?: (error: unknown) => EntryMeta; +/** + * Options for customizing measurement behavior and callbacks. + * Extends partial ActionTrackEntryPayload to allow overriding default track properties. + */ +export type MeasureOptions = Partial & { + /** + * Callback invoked when measurement completes successfully. + * @param result - The successful result value + * @returns Additional DevTools properties to merge for success state + */ + success?: (result: unknown) => Partial; + /** + * Callback invoked when measurement fails with an error. + * @param error - The error that occurred + * @returns Additional DevTools properties to merge for error state + */ + error?: (error: unknown) => Partial; }; -export type MeasureCtxOptions = ActionTrack & { +/** + * Configuration for creating a measurement context. + * Defines default behavior and appearance for all measurements in this context. + */ +export type MeasureCtxOptions = ActionTrackEntryPayload & { + /** + * Optional prefix for all measurement names to avoid conflicts. + * @example "api:" results in names like "api:request:start" + */ prefix?: string; } & { + /** + * Global error handler for all measurements in this context. + * Applied to all error states in addition to per-measurement error callbacks. + * @param error - The error that occurred + * @returns Additional DevTools metadata for error display + */ error?: (error: unknown) => EntryMeta; }; +/** + * Creates a measurement context for tracking performance events with consistent DevTools visualization. + * + * This function returns a higher-order function that generates measurement controllers for individual events. + * Each measurement creates start/end marks and a final measure in Chrome DevTools Performance panel. + * + * @param cfg - Configuration defining default track properties, optional prefix, and global error handling + * @returns Function that creates measurement controllers for specific events + * @example + * ```ts + * // Basic usage with defaults + * const measure = measureCtx({ + * track: 'api-calls', + * color: 'secondary', + * trackGroup: 'backend' + * }); + * + * const { start, success, error } = measure('fetch-user'); + * start(); // Creates "fetch-user:start" mark + * // ... async operation ... + * success({ userCount: 42 }); // Creates "fetch-user:end" mark and "fetch-user" measure + * ``` + * @example + * ```ts + * // Advanced usage with callbacks and error handling + * const measure = measureCtx({ + * track: 'user-actions', + * color: 'primary', + * error: (err) => ({ + * properties: [['error-type', err.name], ['error-message', err.message]] + * }) + * }); + * + * const { start, success, error } = measure('save-form', { + * success: (result) => ({ + * properties: [['items-saved', result.count]], + * tooltipText: `Saved ${result.count} items successfully` + * }), + * error: (err) => ({ + * properties: [['validation-errors', err.errors?.length ?? 0]] + * }) + * }); + * + * start(); + * try { + * const result = await saveFormData(formData); + * success(result); + * } catch (err) { + * error(err); // Applies both global and specific error metadata + * } + * ``` + * @example + * ```ts + * // onetime config of defaults + * const apiMeasure = measureCtx({ + * prefix: 'http:', + * track: 'api', + * }); + * + * cosnt {start, success, error} = apiMeasure('login'); + * + * start(); + * try { + * cosnt result = myWork(); + * success(result); + * return result; + * } catch(err) { + * error(err) + * } + * + * ``` + * @returns Object with measurement control methods: + * - `start()`: Marks the beginning of the measurement + * - `success(result?)`: Completes successful measurement with optional result metadata + * - `error(error)`: Completes failed measurement with error metadata + */ + export function measureCtx(cfg: MeasureCtxOptions) { const { prefix, error: globalErr, ...defaults } = cfg; return (event: string, opt?: MeasureOptions) => { const { success, error, ...measurePayload } = opt ?? {}; - const merged = mergeDevtoolsPayloadAction(defaults, measurePayload, { - dataType: dataTypeTrackEntry, - }) as TrackEntryPayload; - + const merged = mergeDevtoolsPayload(defaults, measurePayload); const { startName: s, endName: e, @@ -284,10 +515,10 @@ export function measureCtx(cfg: MeasureCtxOptions) { error: (err: unknown) => { const errorPayload = mergeDevtoolsPayload( + { ...merged, color: 'error' }, errorToEntryMeta(err), globalErr?.(err) ?? {}, error?.(err) ?? {}, - { ...merged, color: 'error' }, ); performance.mark(e, toMarkMeasureOpts(errorPayload)); performance.measure(m, { diff --git a/packages/utils/src/lib/user-timing-extensibility-api-utils.unit.test.ts b/packages/utils/src/lib/user-timing-extensibility-api-utils.unit.test.ts index 70fd3072c..be4a784c0 100644 --- a/packages/utils/src/lib/user-timing-extensibility-api-utils.unit.test.ts +++ b/packages/utils/src/lib/user-timing-extensibility-api-utils.unit.test.ts @@ -56,6 +56,12 @@ describe('mergePropertiesWithOverwrite', () => { ['key', 'value'], ]); }); + + it('should handle undefined base properties with override properties', () => { + expect(mergePropertiesWithOverwrite([], [['key', 'value']])).toStrictEqual([ + ['key', 'value'], + ]); + }); }); describe('markerPayload', () => { @@ -448,13 +454,11 @@ describe('setupTracks', () => { track: 'Main Track', color: 'primary', trackGroup: 'My Group', - dataType: 'track-entry', }, secondary: { track: 'Secondary Track', color: 'primary', trackGroup: 'My Group', - dataType: 'track-entry', }, }); }); @@ -534,7 +538,6 @@ describe('measureCtx', () => { expect(performance.mark).toHaveBeenCalledWith('utils:start', { detail: { devtools: { - dataType: 'track-entry', track: 'Global Track', color: 'primary', // local override wins }, @@ -546,7 +549,6 @@ describe('measureCtx', () => { expect(performance.mark).toHaveBeenLastCalledWith('utils:end', { detail: { devtools: { - dataType: 'track-entry', track: 'Global Track', color: 'primary', }, @@ -557,7 +559,6 @@ describe('measureCtx', () => { end: 'utils:end', detail: { devtools: { - dataType: 'track-entry', track: 'Global Track', color: 'primary', properties: [ @@ -589,7 +590,6 @@ describe('measureCtx', () => { expect(performance.mark).toHaveBeenCalledWith('load-cfg:start', { detail: { devtools: { - dataType: 'track-entry', track: 'Global Track', trackGroup: 'Global Track Group', color: 'primary-dark', @@ -611,7 +611,6 @@ describe('measureCtx', () => { expect(performance.mark).toHaveBeenCalledWith('load-cfg:start', { detail: { devtools: { - dataType: 'track-entry', track: 'Global Track', color: 'primary', // Marks do not have EntryMeta as hover/click is rare @@ -631,7 +630,6 @@ describe('measureCtx', () => { expect(performance.mark).toHaveBeenCalledWith('load-cfg:end', { detail: { devtools: { - dataType: 'track-entry', track: 'Global Track', trackGroup: 'Global Track Group', color: 'primary-dark', @@ -644,7 +642,6 @@ describe('measureCtx', () => { end: 'load-cfg:end', detail: { devtools: { - dataType: 'track-entry', track: 'Global Track', trackGroup: 'Global Track Group', color: 'primary-dark', @@ -670,7 +667,6 @@ describe('measureCtx', () => { expect(performance.mark).toHaveBeenCalledWith('test:end', { detail: { devtools: { - dataType: 'track-entry', track: 'Global Track', color: 'primary', // Marks do not have EntryMeta as hover/click is rare @@ -682,7 +678,6 @@ describe('measureCtx', () => { end: 'test:end', detail: { devtools: { - dataType: 'track-entry', track: 'Global Track', color: 'primary', properties: [ @@ -707,7 +702,6 @@ describe('measureCtx', () => { expect(performance.mark).toHaveBeenCalledWith('load-cfg:end', { detail: { devtools: { - dataType: 'track-entry', track: 'Global Track', trackGroup: 'Global Track Group', color: 'error', @@ -720,14 +714,13 @@ describe('measureCtx', () => { end: 'load-cfg:end', detail: { devtools: { - dataType: 'track-entry', track: 'Global Track', trackGroup: 'Global Track Group', color: 'error', properties: [ + ['Global:Config', `Process ID ${process.pid}`], ['Error Type', 'Error'], ['Error Message', 'test error'], - ['Global:Config', `Process ID ${process.pid}`], ], }, }, @@ -753,7 +746,6 @@ describe('measureCtx', () => { expect(performance.mark).toHaveBeenCalledWith('test:end', { detail: { devtools: { - dataType: 'track-entry', track: 'Global Track', color: 'error', // Marks do not have EntryMeta as hover/click is rare @@ -765,18 +757,17 @@ describe('measureCtx', () => { end: 'test:end', detail: { devtools: { - dataType: 'track-entry', track: 'Global Track', color: 'error', properties: [ + ['Global:Config', `Process ID ${process.pid}`], + ['Runtime:Config', `Thread ID ${threadId}`], ['Error Type', 'Error'], ['Error Message', 'test error'], [ 'Runtime:Error', `Stack Trace: ${String((error as Error)?.stack)}`, ], - ['Global:Config', `Process ID ${process.pid}`], - ['Runtime:Config', `Thread ID ${threadId}`], ], }, }, @@ -793,7 +784,6 @@ describe('measureCtx', () => { expect(performance.mark).toHaveBeenCalledWith('test:end', { detail: { devtools: { - dataType: 'track-entry', track: 'Global Track', color: 'error', }, @@ -804,13 +794,48 @@ describe('measureCtx', () => { end: 'test:end', detail: { devtools: { - dataType: 'track-entry', track: 'Global Track', color: 'error', properties: [ + ['Global:Config', `Process ID ${process.pid}`], ['Error Type', 'Error'], ['Error Message', 'test error'], - ['Global:Config', `Process ID ${process.pid}`], + ], + }, + }, + }); + }); + + it('creates error mark and measure with global error handler', () => { + const error = new Error('test error'); + const { error: errorFn } = measureCtx({ + track: 'Global Track', + error: (errorVal: unknown) => ({ + properties: [ + ['Global:Error', `Custom Global Error: ${String(errorVal)}`], + ], + }), + })('test'); + errorFn(error); + expect(performance.mark).toHaveBeenCalledWith('test:end', { + detail: { + devtools: { + track: 'Global Track', + color: 'error', + }, + }, + }); + expect(performance.measure).toHaveBeenCalledWith('test', { + start: 'test:start', + end: 'test:end', + detail: { + devtools: { + track: 'Global Track', + color: 'error', + properties: [ + ['Error Type', 'Error'], + ['Error Message', 'test error'], + ['Global:Error', 'Custom Global Error: Error: test error'], ], }, }, 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..6a35dbd8e 100644 --- a/packages/utils/src/lib/user-timing-extensibility-api.type.ts +++ b/packages/utils/src/lib/user-timing-extensibility-api.type.ts @@ -59,7 +59,7 @@ export type EntryMeta = { }; /** - * Styling options for track entries in DevTools. + * Styling options for track entries and marker in DevTools. * @property {DevToolsColor} [color] - rendered color of background and border, defaults to "primary" */ export type TrackStyle = { @@ -69,7 +69,7 @@ export type TrackStyle = { /** * Metadata for organizing track entries in DevTools. * @property {string} track - Name of the custom track - * @property {string} [trackGroup] - Group for organizing tracks + * @property {string} [trackGroup] - Group for organizing tracks. */ export type TrackMeta = { track: string; @@ -110,6 +110,24 @@ export type WithErrorColor = Omit< > & { color: 'error'; }; + +/** + * Action color payload. + * @param color - The color of the action + * @returns The action color payload + */ +export type ActionColorPayload = { + color?: DevToolsActionColor; +}; + +/** + * Action track payload. + * @param TrackEntryPayload - The track entry payload + * @param ActionColorPayload - The action color payload + * @returns The action track payload + */ +export type ActionTrackEntryPayload = TrackEntryPayload & ActionColorPayload; + /** * Utility type that adds an optional devtools payload property. */ From 79368e4020bc24ba03dcab4163a09ac5fffd352d Mon Sep 17 00:00:00 2001 From: Michael Hladky <10064416+BioPhoton@users.noreply.github.com> Date: Wed, 14 Jan 2026 17:26:21 +0100 Subject: [PATCH 6/7] Update packages/utils/src/lib/user-timing-extensibility-api-utils.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Matěj Chalk <34691111+matejchalk@users.noreply.github.com> --- .../utils/src/lib/user-timing-extensibility-api-utils.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/utils/src/lib/user-timing-extensibility-api-utils.ts b/packages/utils/src/lib/user-timing-extensibility-api-utils.ts index 4e0356802..ee76cc30a 100644 --- a/packages/utils/src/lib/user-timing-extensibility-api-utils.ts +++ b/packages/utils/src/lib/user-timing-extensibility-api-utils.ts @@ -470,13 +470,13 @@ export type MeasureCtxOptions = ActionTrackEntryPayload & { * track: 'api', * }); * - * cosnt {start, success, error} = apiMeasure('login'); + * const { start, success, error } = apiMeasure('login'); * * start(); * try { - * cosnt result = myWork(); - * success(result); - * return result; + * const result = myWork(); + * success(result); + * return result; * } catch(err) { * error(err) * } From aa05123ec2670daa39be4bf1e7eb8152723dd0cd Mon Sep 17 00:00:00 2001 From: John Doe Date: Wed, 14 Jan 2026 17:38:40 +0100 Subject: [PATCH 7/7] refactor: impl feedback --- .../lib/user-timing-extensibility-api-utils.ts | 15 --------------- ...er-timing-extensibility-api-utils.unit.test.ts | 11 +++++------ 2 files changed, 5 insertions(+), 21 deletions(-) diff --git a/packages/utils/src/lib/user-timing-extensibility-api-utils.ts b/packages/utils/src/lib/user-timing-extensibility-api-utils.ts index ee76cc30a..6a5cb7484 100644 --- a/packages/utils/src/lib/user-timing-extensibility-api-utils.ts +++ b/packages/utils/src/lib/user-timing-extensibility-api-utils.ts @@ -40,14 +40,12 @@ export function mergePropertiesWithOverwrite( * @param options - Marker options excluding dataType * @returns Complete marker payload * @example - * ```ts * const payload = markerPayload({ * color: 'primary', * tooltipText: 'User action completed', * properties: [['action', 'save'], ['duration', 150]] * }); * // { dataType: 'marker', color: 'primary', tooltipText: 'User action completed', ... } - * ``` */ export function markerPayload(options?: Omit) { return { @@ -61,7 +59,6 @@ export function markerPayload(options?: Omit) { * @param options - Track entry options excluding dataType * @returns Complete track entry payload * @example - * ```ts * const payload = trackEntryPayload({ * track: 'user-interactions', * trackGroup: 'frontend', @@ -70,7 +67,6 @@ export function markerPayload(options?: Omit) { * properties: [['element', 'save-button'], ['response-time', 200]] * }); * // { dataType: 'track-entry', track: 'user-interactions', ... } - * ``` */ export function trackEntryPayload( options: Omit, @@ -205,7 +201,6 @@ export function errorToMarkerPayload( * @param devtools - DevTools payload or null * @returns Performance API options with DevTools detail * @example - * ```ts * const marker = markerPayload({ color: 'primary', tooltipText: 'Start' }); * performance.mark('start', asOptions(marker)); * @@ -215,7 +210,6 @@ export function errorToMarkerPayload( * end: 'end', * ...asOptions(trackEntry) * }); - * ``` */ export function asOptions( devtools?: T | null, @@ -307,13 +301,11 @@ type MergeResult

= P extends readonly [ * @param parts - Array of payloads where first is complete and rest are partial * @returns Merged payload with combined properties * @example - * ```ts * const payload = mergeDevtoolsPayload( * trackEntryPayload({ track: 'user-interactions', color: 'secondary' }), * { color: 'primary', tooltipText: 'User action completed' }, * ); * // { track: 'user-interactions', color: 'primary', tooltipText: 'User action completed' } - * ``` */ export function mergeDevtoolsPayload< const P extends readonly [ @@ -420,7 +412,6 @@ export type MeasureCtxOptions = ActionTrackEntryPayload & { * @param cfg - Configuration defining default track properties, optional prefix, and global error handling * @returns Function that creates measurement controllers for specific events * @example - * ```ts * // Basic usage with defaults * const measure = measureCtx({ * track: 'api-calls', @@ -432,9 +423,7 @@ export type MeasureCtxOptions = ActionTrackEntryPayload & { * start(); // Creates "fetch-user:start" mark * // ... async operation ... * success({ userCount: 42 }); // Creates "fetch-user:end" mark and "fetch-user" measure - * ``` * @example - * ```ts * // Advanced usage with callbacks and error handling * const measure = measureCtx({ * track: 'user-actions', @@ -461,9 +450,7 @@ export type MeasureCtxOptions = ActionTrackEntryPayload & { * } catch (err) { * error(err); // Applies both global and specific error metadata * } - * ``` * @example - * ```ts * // onetime config of defaults * const apiMeasure = measureCtx({ * prefix: 'http:', @@ -480,8 +467,6 @@ export type MeasureCtxOptions = ActionTrackEntryPayload & { * } catch(err) { * error(err) * } - * - * ``` * @returns Object with measurement control methods: * - `start()`: Marks the beginning of the measurement * - `success(result?)`: Completes successful measurement with optional result metadata diff --git a/packages/utils/src/lib/user-timing-extensibility-api-utils.unit.test.ts b/packages/utils/src/lib/user-timing-extensibility-api-utils.unit.test.ts index be4a784c0..058301a2a 100644 --- a/packages/utils/src/lib/user-timing-extensibility-api-utils.unit.test.ts +++ b/packages/utils/src/lib/user-timing-extensibility-api-utils.unit.test.ts @@ -20,6 +20,7 @@ import { trackEntryPayload, } from './user-timing-extensibility-api-utils.js'; import type { + ActionTrackEntryPayload, EntryMeta, TrackEntryPayload, TrackMeta, @@ -362,10 +363,6 @@ describe('getNames', () => { }); describe('mergeDevtoolsPayload', () => { - it('should return empty object when no payloads provided', () => { - expect(mergeDevtoolsPayload()).toStrictEqual({}); - }); - it('should return the same payload when single payload provided', () => { const payload: TrackEntryPayload = { dataType: 'track-entry', @@ -403,7 +400,8 @@ describe('mergeDevtoolsPayload', () => { }); it('should merge multiple property payloads with overwrite behavior', () => { - const payload1: EntryMeta = { + const payload1: ActionTrackEntryPayload = { + track: 'Test Track', properties: [['key1', 'value1']], }; const payload2: EntryMeta = { @@ -414,6 +412,7 @@ describe('mergeDevtoolsPayload', () => { }; expect(mergeDevtoolsPayload(payload1, payload2)).toStrictEqual({ + track: 'Test Track', properties: [ ['key1', 'overwrite'], ['key2', 'value2'], @@ -438,7 +437,7 @@ describe('mergeDevtoolsPayload', () => { describe('setupTracks', () => { it('should create track definitions with defaults as base', () => { - const defaults: TrackEntryPayload = { + const defaults: ActionTrackEntryPayload = { track: 'Main Track', color: 'primary', trackGroup: 'My Group',