From 76404c40d463fbc45cff593654c4b25be98c8d3f Mon Sep 17 00:00:00 2001 From: John Doe Date: Wed, 14 Jan 2026 19:02:36 +0100 Subject: [PATCH 01/16] feat: add profiler class and measure API --- packages/utils/src/lib/profiler/constants.ts | 1 + .../src/lib/profiler/profiler.int.test.ts | 285 ++++++++++++++++++ packages/utils/src/lib/profiler/profiler.ts | 283 +++++++++++++++++ .../src/lib/profiler/profiler.unit.test.ts | 210 +++++++++++++ 4 files changed, 779 insertions(+) create mode 100644 packages/utils/src/lib/profiler/constants.ts create mode 100644 packages/utils/src/lib/profiler/profiler.int.test.ts create mode 100644 packages/utils/src/lib/profiler/profiler.ts create mode 100644 packages/utils/src/lib/profiler/profiler.unit.test.ts diff --git a/packages/utils/src/lib/profiler/constants.ts b/packages/utils/src/lib/profiler/constants.ts new file mode 100644 index 000000000..d52e31d90 --- /dev/null +++ b/packages/utils/src/lib/profiler/constants.ts @@ -0,0 +1 @@ +export const PROFILER_ENABLED = 'CP_PROFILING'; diff --git a/packages/utils/src/lib/profiler/profiler.int.test.ts b/packages/utils/src/lib/profiler/profiler.int.test.ts new file mode 100644 index 000000000..542849a1f --- /dev/null +++ b/packages/utils/src/lib/profiler/profiler.int.test.ts @@ -0,0 +1,285 @@ +import { performance } from 'node:perf_hooks'; +import { beforeEach, describe, expect, it } from 'vitest'; +import type { ActionTrackEntryPayload } from '../user-timing-extensibility-api.type.js'; +import { Profiler } from './profiler.js'; + +describe('Profiler Integration', () => { + let profiler: Profiler>; + + beforeEach(() => { + // Clear all performance entries before each test + performance.clearMarks(); + performance.clearMeasures(); + + profiler = new Profiler({ + prefix: 'test', + track: 'integration-tests', + color: 'primary', + tracks: { + async: { track: 'async-ops', color: 'secondary' }, + sync: { track: 'sync-ops', color: 'tertiary' }, + }, + enabled: true, // Explicitly enable for integration tests + }); + }); + + it('should create complete performance timeline for sync operation', () => { + const result = profiler.measure('sync-test', () => + Array.from({ length: 1000 }, (_, i) => i).reduce( + (sum, num) => sum + num, + 0, + ), + ); + + expect(result).toBe(499_500); + + // Verify performance entries were created + const marks = performance.getEntriesByType('mark'); + const measures = performance.getEntriesByType('measure'); + + expect(marks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'test:sync-test:start' }), + expect.objectContaining({ name: 'test:sync-test:end' }), + ]), + ); + + expect(measures).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'test:sync-test', + duration: expect.any(Number), + }), + ]), + ); + }); + + it('should create complete performance timeline for async operation', async () => { + const result = await profiler.measureAsync('async-test', async () => { + // Simulate async work + await new Promise(resolve => setTimeout(resolve, 10)); + return 'async-result'; + }); + + expect(result).toBe('async-result'); + + // Verify performance entries were created + const marks = performance.getEntriesByType('mark'); + const measures = performance.getEntriesByType('measure'); + + expect(marks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'test:async-test:start' }), + expect.objectContaining({ name: 'test:async-test:end' }), + ]), + ); + + expect(measures).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'test:async-test', + duration: expect.any(Number), + }), + ]), + ); + }); + + it('should handle nested measurements correctly', () => { + profiler.measure('outer', () => { + profiler.measure('inner', () => 'inner-result'); + return 'outer-result'; + }); + + const marks = performance.getEntriesByType('mark'); + const measures = performance.getEntriesByType('measure'); + + expect(marks).toHaveLength(4); // 2 for outer + 2 for inner + expect(measures).toHaveLength(2); // 1 for outer + 1 for inner + + // Check all marks exist + const markNames = marks.map(m => m.name); + expect(markNames).toStrictEqual( + expect.arrayContaining([ + 'test:outer:start', + 'test:outer:end', + 'test:inner:start', + 'test:inner:end', + ]), + ); + + // Check all measures exist + const measureNames = measures.map(m => m.name); + expect(measureNames).toStrictEqual( + expect.arrayContaining(['test:outer', 'test:inner']), + ); + }); + + it('should create markers with proper metadata', () => { + profiler.marker('test-marker', { + color: 'warning', + tooltipText: 'Test marker tooltip', + properties: [ + ['event', 'test-event'], + ['timestamp', Date.now()], + ], + }); + + const marks = performance.getEntriesByType('mark'); + expect(marks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'test-marker', + detail: { + devtools: expect.objectContaining({ + dataType: 'marker', + color: 'warning', + tooltipText: 'Test marker tooltip', + properties: [ + ['event', 'test-event'], + ['timestamp', expect.any(Number)], + ], + }), + }, + }), + ]), + ); + }); + + it('should create proper DevTools payloads for tracks', () => { + profiler.measure('track-test', () => 'result', { + success: result => ({ + properties: [['result', result]], + tooltipText: 'Track test completed', + }), + }); + + const measures = performance.getEntriesByType('measure'); + expect(measures).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + detail: { + devtools: expect.objectContaining({ + dataType: 'track-entry', + track: 'integration-tests', + color: 'primary', + properties: [['result', 'result']], + tooltipText: 'Track test completed', + }), + }, + }), + ]), + ); + }); + + it('should merge track defaults with measurement options', () => { + // Use the sync track from our configuration + profiler.measure('sync-op', () => 'sync-result', { + success: result => ({ + properties: [ + ['operation', 'sync'], + ['result', result], + ], + }), + }); + + const measures = performance.getEntriesByType('measure'); + expect(measures).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + detail: { + devtools: expect.objectContaining({ + dataType: 'track-entry', + track: 'integration-tests', // default track + color: 'primary', // default color + properties: [ + ['operation', 'sync'], + ['result', 'sync-result'], + ], + }), + }, + }), + ]), + ); + }); + + it('should mark errors with red color in DevTools', () => { + const error = new Error('Test error'); + + expect(() => { + profiler.measure('error-test', () => { + throw error; + }); + }).toThrow(error); + + const measures = performance.getEntriesByType('measure'); + expect(measures).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + detail: { + devtools: expect.objectContaining({ + color: 'error', + properties: expect.arrayContaining([ + ['Error Type', 'Error'], + ['Error Message', 'Test error'], + ]), + }), + }, + }), + ]), + ); + }); + + it('should include error metadata in DevTools properties', () => { + const customError = new TypeError('Custom type error'); + + expect(() => { + profiler.measure('custom-error-test', () => { + throw customError; + }); + }).toThrow(customError); + + const measures = performance.getEntriesByType('measure'); + expect(measures).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + detail: { + devtools: expect.objectContaining({ + properties: expect.arrayContaining([ + ['Error Type', 'TypeError'], + ['Error Message', 'Custom type error'], + ]), + }), + }, + }), + ]), + ); + }); + + it('should not create performance entries when disabled', async () => { + const disabledProfiler = new Profiler({ + prefix: 'disabled', + track: 'disabled-tests', + color: 'primary', + tracks: {}, + enabled: false, + }); + + // Test sync measurement + const syncResult = disabledProfiler.measure('disabled-sync', () => 'sync'); + expect(syncResult).toBe('sync'); + + // Test async measurement + const asyncResult = disabledProfiler.measureAsync( + 'disabled-async', + async () => 'async', + ); + await expect(asyncResult).resolves.toBe('async'); + + // Test marker + disabledProfiler.marker('disabled-marker'); + + // Verify no performance entries were created + expect(performance.getEntriesByType('mark')).toHaveLength(0); + expect(performance.getEntriesByType('measure')).toHaveLength(0); + }); +}); diff --git a/packages/utils/src/lib/profiler/profiler.ts b/packages/utils/src/lib/profiler/profiler.ts new file mode 100644 index 000000000..491b9824b --- /dev/null +++ b/packages/utils/src/lib/profiler/profiler.ts @@ -0,0 +1,283 @@ +import process from 'node:process'; +import { isEnvVarEnabled } from '../env.js'; +import { + type MeasureOptions, + asOptions, + markerPayload, + measureCtx, + setupTracks, +} from '../user-timing-extensibility-api-utils.js'; +import type { + ActionColorPayload, + ActionTrackEntryPayload, + DevToolsColor, + EntryMeta, + TrackMeta, +} from '../user-timing-extensibility-api.type.js'; +import { PROFILER_ENABLED } from './constants.js'; + +/** Default track configuration combining metadata and color options. */ +type DefaultTrackOptions = TrackMeta & ActionColorPayload; + +/** + * Configuration options for creating a Profiler instance. + * + * @template T - Record type defining available track names and their configurations + */ +type ProfilerMeasureOptions> = + DefaultTrackOptions & { + /** Custom track configurations that will be merged with default settings */ + tracks: Record>; + /** Whether profiling should be enabled (defaults to CP_PROFILING env var) */ + enabled?: boolean; + /** Prefix for all performance measurement names to avoid conflicts */ + prefix: string; + }; + +/** + * Options for configuring a Profiler instance. + * + * This is an alias for ProfilerMeasureOptions for backward compatibility. + * + * @template T - Record type defining available track names and their configurations + */ +export type ProfilerOptions> = + ProfilerMeasureOptions; + +/** + * Performance profiler that creates structured timing measurements with DevTools visualization. + * + * This class provides high-level APIs for performance monitoring with automatic DevTools + * integration for Chrome DevTools Performance panel. It supports both synchronous and + * asynchronous operations with customizable track visualization. + * + * @example + * ```typescript + * const profiler = new Profiler({ + * prefix: 'api', + * track: 'backend-calls', + * trackGroup: 'api', + * color: 'secondary', + * tracks: { + * database: { track: 'database', color: 'tertiary' }, + * external: { track: 'external-apis', color: 'primary' } + * } + * }); + * + * // Measure synchronous operation + * const result = profiler.measure('fetch-user', () => api.getUser(id)); + * + * // Measure async operation + * const asyncResult = await profiler.measureAsync('save-data', + * () => api.saveData(data) + * ); + * + * // Add marker + * profiler.marker('cache-invalidated', { + * color: 'warning', + * tooltipText: 'Cache cleared due to stale data' + * }); + * ``` + */ +export class Profiler> { + #enabled: boolean; + private readonly defaults: ActionTrackEntryPayload; + readonly tracks: Record; + private readonly ctxOf: ReturnType; + + /** + * Creates a new Profiler instance with the specified configuration. + * + * @param options - Configuration options for the profiler + * @param options.tracks - Custom track configurations merged with defaults + * @param options.prefix - Prefix for all measurement names + * @param options.track - Default track name for measurements + * @param options.trackGroup - Default track group for organization + * @param options.color - Default color for track entries + * @param options.enabled - Whether profiling is enabled (defaults to CP_PROFILING env var) + * + * @example + * ```typescript + * const profiler = new Profiler({ + * prefix: 'api', + * track: 'backend-calls', + * trackGroup: 'api', + * color: 'secondary', + * enabled: true, + * tracks: { + * database: { track: 'database', color: 'tertiary' }, + * cache: { track: 'cache', color: 'primary' } + * } + * }); + * ``` + */ + constructor(options: ProfilerOptions) { + const { tracks, prefix, enabled, ...defaults } = options; + const dataType = 'track-entry'; + + this.#enabled = enabled ?? isEnvVarEnabled(PROFILER_ENABLED); + this.defaults = { ...defaults, dataType }; + this.tracks = setupTracks({ ...defaults, dataType }, tracks); + this.ctxOf = measureCtx({ + ...defaults, + dataType, + prefix, + }); + } + + /** + * Sets enabled state for this profiler. + * + * Also sets the `CP_PROFILING` environment variable. + * This means any future {@link Profiler} instantiations (including child processes) will use the same enabled state. + * + * @param enabled - Whether profiling should be enabled + */ + setEnabled(enabled: boolean): void { + process.env[PROFILER_ENABLED] = `${enabled}`; + this.#enabled = enabled; + } + + /** + * Is profiling enabled? + * + * Profiling is enabled by {@link setEnabled} call or `CP_PROFILING` environment variable. + * + * @returns Whether profiling is currently enabled + */ + isEnabled(): boolean { + return this.#enabled; + } + + /** + * Creates a performance marker in the DevTools Performance panel. + * + * Markers appear as vertical lines spanning all tracks and can include custom metadata + * for debugging and performance analysis. When profiling is disabled, this method + * returns immediately without creating any performance entries. + * + * @param name - Unique name for the marker + * @param opt - Optional metadata and styling for the marker + * @param opt.color - Color of the marker line (defaults to profiler default) + * @param opt.tooltipText - Text shown on hover + * @param opt.properties - Key-value pairs for detailed view + * + * @example + * ```typescript + * profiler.marker('user-action-start', { + * color: 'primary', + * tooltipText: 'User clicked save button', + * properties: [ + * ['action', 'save'], + * ['elementId', 'save-btn'] + * ] + * }); + * ``` + */ + marker(name: string, opt?: EntryMeta & { color: DevToolsColor }) { + if (!this.#enabled) { + return; + } + + performance.mark( + name, + asOptions( + markerPayload({ + // marker only supports color no TrackMeta + ...(this.defaults.color ? { color: this.defaults.color } : {}), + ...opt, + }), + ), + ); + } + + /** + * Measures the execution time of a synchronous operation. + * + * Creates start/end marks and a final measure entry in the performance timeline. + * The operation appears in the configured track with proper DevTools visualization. + * When profiling is disabled, executes the work function directly without overhead. + * + * @template R - The return type of the work function + * @param event - Name for this measurement event + * @param work - Function to execute and measure + * @param options - Optional measurement configuration overrides + * @returns The result of the work function + * + * @example + * ```typescript + * const user = profiler.measure('fetch-user', () => { + * return api.getUser(userId); + * }, { + * success: (result) => ({ + * properties: [['userId', result.id], ['loadTime', Date.now()]] + * }) + * }); + * ``` + */ + measure(event: string, work: () => R, options?: MeasureOptions): R { + if (!this.#enabled) { + return work(); + } + + const { start, success, error } = this.ctxOf(event, options); + start(); + try { + const r = work(); + success(r); + return r; + } catch (error_) { + error(error_); + throw error_; + } + } + + /** + * Measures the execution time of an asynchronous operation. + * + * Creates start/end marks and a final measure entry in the performance timeline. + * The operation appears in the configured track with proper DevTools visualization. + * When profiling is disabled, executes and awaits the work function directly without overhead. + * + * @template R - The resolved type of the work promise + * @param event - Name for this measurement event + * @param work - Function returning a promise to execute and measure + * @param options - Optional measurement configuration overrides + * @returns Promise that resolves to the result of the work function + * + * @example + * ```typescript + * const data = await profiler.measureAsync('save-form', async () => { + * const result = await api.saveForm(formData); + * return result; + * }, { + * success: (result) => ({ + * properties: [['recordsSaved', result.count]] + * }), + * error: (err) => ({ + * properties: [['errorType', err.name]] + * }) + * }); + * ``` + */ + async measureAsync( + event: string, + work: () => Promise, + options?: MeasureOptions, + ): Promise { + if (!this.#enabled) { + return await work(); + } + + const { start, success, error } = this.ctxOf(event, options); + start(); + try { + const r = work(); + success(r); + return await r; + } catch (error_) { + error(error_); + throw error_; + } + } +} diff --git a/packages/utils/src/lib/profiler/profiler.unit.test.ts b/packages/utils/src/lib/profiler/profiler.unit.test.ts new file mode 100644 index 000000000..0965139e7 --- /dev/null +++ b/packages/utils/src/lib/profiler/profiler.unit.test.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ActionTrackEntryPayload } from '../user-timing-extensibility-api.type.js'; +import { Profiler } from './profiler.js'; + +describe('Profiler', () => { + let profiler: Profiler>; + + beforeEach(() => { + // Environment variables are mocked in individual tests using vi.stubEnv + + profiler = new Profiler({ + prefix: 'test', + track: 'test-track', + color: 'primary', + tracks: {}, + }); + }); + + it('should initialize with default enabled state from env', () => { + vi.stubEnv('CP_PROFILING', 'true'); + const profilerWithEnv = new Profiler({ + prefix: 'test', + track: 'test-track', + color: 'primary', + tracks: {}, + }); + + expect(profilerWithEnv.isEnabled()).toBe(true); + }); + + it('should override enabled state from options', () => { + vi.stubEnv('CP_PROFILING', 'false'); + const profilerWithOverride = new Profiler({ + prefix: 'test', + track: 'test-track', + color: 'primary', + tracks: {}, + enabled: true, + }); + + expect(profilerWithOverride.isEnabled()).toBe(true); + }); + + it('should setup tracks with defaults merged', () => { + const profilerWithTracks = new Profiler({ + prefix: 'test', + track: 'default-track', + trackGroup: 'default-group', + color: 'primary', + tracks: { + custom: { track: 'custom-track', color: 'secondary' }, + partial: { color: 'tertiary' }, // partial override + }, + }); + + expect(profilerWithTracks.tracks.custom).toEqual({ + track: 'custom-track', + trackGroup: 'default-group', + color: 'secondary', + dataType: 'track-entry', + }); + + expect(profilerWithTracks.tracks.partial).toEqual({ + track: 'default-track', // inherited from defaults + trackGroup: 'default-group', + color: 'tertiary', // overridden + dataType: 'track-entry', + }); + }); + + it('should set and get enabled state', () => { + expect(profiler.isEnabled()).toBe(false); + + profiler.setEnabled(true); + expect(profiler.isEnabled()).toBe(true); + + profiler.setEnabled(false); + expect(profiler.isEnabled()).toBe(false); + }); + + it('should update environment variable', () => { + profiler.setEnabled(true); + expect(process.env.CP_PROFILING).toBe('true'); + + profiler.setEnabled(false); + expect(process.env.CP_PROFILING).toBe('false'); + }); + + it('should execute marker without error when enabled', () => { + profiler.setEnabled(true); + + expect(() => { + profiler.marker('test-marker', { + color: 'primary', + tooltipText: 'Test marker', + properties: [['key', 'value']], + }); + }).not.toThrow(); + }); + + it('should execute marker without error when disabled', () => { + profiler.setEnabled(false); + + expect(() => { + profiler.marker('test-marker'); + }).not.toThrow(); + }); + + it('should execute work and return result when measure enabled', () => { + profiler.setEnabled(true); + + const workFn = vi.fn(() => 'result'); + const result = profiler.measure('test-event', workFn); + + expect(result).toBe('result'); + expect(workFn).toHaveBeenCalled(); + }); + + it('should execute work directly when measure disabled', () => { + profiler.setEnabled(false); + + const workFn = vi.fn(() => 'result'); + const result = profiler.measure('test-event', workFn); + + expect(result).toBe('result'); + expect(workFn).toHaveBeenCalled(); + }); + + it('should propagate errors when measure enabled', () => { + profiler.setEnabled(true); + + const error = new Error('Test error'); + const workFn = vi.fn(() => { + throw error; + }); + + expect(() => profiler.measure('test-event', workFn)).toThrow(error); + expect(workFn).toHaveBeenCalled(); + }); + + it('should propagate errors when measure disabled', () => { + profiler.setEnabled(false); + + const error = new Error('Test error'); + const workFn = vi.fn(() => { + throw error; + }); + + expect(() => profiler.measure('test-event', workFn)).toThrow(error); + expect(workFn).toHaveBeenCalled(); + }); + + it('should handle async operations correctly when enabled', async () => { + profiler.setEnabled(true); + + const workFn = vi.fn(async () => { + await Promise.resolve(); + return 'async-result'; + }); + + const result = await profiler.measureAsync('test-async-event', workFn); + + expect(result).toBe('async-result'); + expect(workFn).toHaveBeenCalled(); + }); + + it('should execute async work directly when disabled', async () => { + profiler.setEnabled(false); + + const workFn = vi.fn(async () => { + await Promise.resolve(); + return 'async-result'; + }); + + const result = await profiler.measureAsync('test-async-event', workFn); + + expect(result).toBe('async-result'); + expect(workFn).toHaveBeenCalled(); + }); + + it('should propagate async errors when enabled', async () => { + profiler.setEnabled(true); + + const error = new Error('Async test error'); + const workFn = vi.fn(async () => { + await Promise.resolve(); + throw error; + }); + + await expect( + profiler.measureAsync('test-async-event', workFn), + ).rejects.toThrow(error); + expect(workFn).toHaveBeenCalled(); + }); + + it('should propagate async errors when disabled', async () => { + profiler.setEnabled(false); + + const error = new Error('Async test error'); + const workFn = vi.fn(async () => { + await Promise.resolve(); + throw error; + }); + + await expect( + profiler.measureAsync('test-async-event', workFn), + ).rejects.toThrow(error); + expect(workFn).toHaveBeenCalled(); + }); +}); From 923659b07677c663b364b3f0e90315d6664c587e Mon Sep 17 00:00:00 2001 From: John Doe Date: Wed, 14 Jan 2026 20:35:10 +0100 Subject: [PATCH 02/16] feat: add profiler class --- .../src/lib/profiler/profiler.int.test.ts | 41 ++- packages/utils/src/lib/profiler/profiler.ts | 105 ++------ .../src/lib/profiler/profiler.unit.test.ts | 240 +++++++++++++++--- .../user-timing-extensibility-api-utils.ts | 10 +- 4 files changed, 244 insertions(+), 152 deletions(-) diff --git a/packages/utils/src/lib/profiler/profiler.int.test.ts b/packages/utils/src/lib/profiler/profiler.int.test.ts index 542849a1f..5feebcdcc 100644 --- a/packages/utils/src/lib/profiler/profiler.int.test.ts +++ b/packages/utils/src/lib/profiler/profiler.int.test.ts @@ -7,7 +7,6 @@ describe('Profiler Integration', () => { let profiler: Profiler>; beforeEach(() => { - // Clear all performance entries before each test performance.clearMarks(); performance.clearMeasures(); @@ -19,7 +18,7 @@ describe('Profiler Integration', () => { async: { track: 'async-ops', color: 'secondary' }, sync: { track: 'sync-ops', color: 'tertiary' }, }, - enabled: true, // Explicitly enable for integration tests + enabled: true, }); }); @@ -33,18 +32,17 @@ describe('Profiler Integration', () => { expect(result).toBe(499_500); - // Verify performance entries were created const marks = performance.getEntriesByType('mark'); const measures = performance.getEntriesByType('measure'); - expect(marks).toEqual( + expect(marks).toStrictEqual( expect.arrayContaining([ expect.objectContaining({ name: 'test:sync-test:start' }), expect.objectContaining({ name: 'test:sync-test:end' }), ]), ); - expect(measures).toEqual( + expect(measures).toStrictEqual( expect.arrayContaining([ expect.objectContaining({ name: 'test:sync-test', @@ -56,25 +54,23 @@ describe('Profiler Integration', () => { it('should create complete performance timeline for async operation', async () => { const result = await profiler.measureAsync('async-test', async () => { - // Simulate async work await new Promise(resolve => setTimeout(resolve, 10)); return 'async-result'; }); expect(result).toBe('async-result'); - // Verify performance entries were created const marks = performance.getEntriesByType('mark'); const measures = performance.getEntriesByType('measure'); - expect(marks).toEqual( + expect(marks).toStrictEqual( expect.arrayContaining([ expect.objectContaining({ name: 'test:async-test:start' }), expect.objectContaining({ name: 'test:async-test:end' }), ]), ); - expect(measures).toEqual( + expect(measures).toStrictEqual( expect.arrayContaining([ expect.objectContaining({ name: 'test:async-test', @@ -93,10 +89,9 @@ describe('Profiler Integration', () => { const marks = performance.getEntriesByType('mark'); const measures = performance.getEntriesByType('measure'); - expect(marks).toHaveLength(4); // 2 for outer + 2 for inner - expect(measures).toHaveLength(2); // 1 for outer + 1 for inner + expect(marks).toHaveLength(4); + expect(measures).toHaveLength(2); - // Check all marks exist const markNames = marks.map(m => m.name); expect(markNames).toStrictEqual( expect.arrayContaining([ @@ -107,7 +102,6 @@ describe('Profiler Integration', () => { ]), ); - // Check all measures exist const measureNames = measures.map(m => m.name); expect(measureNames).toStrictEqual( expect.arrayContaining(['test:outer', 'test:inner']), @@ -125,7 +119,7 @@ describe('Profiler Integration', () => { }); const marks = performance.getEntriesByType('mark'); - expect(marks).toEqual( + expect(marks).toStrictEqual( expect.arrayContaining([ expect.objectContaining({ name: 'test-marker', @@ -146,7 +140,7 @@ describe('Profiler Integration', () => { }); it('should create proper DevTools payloads for tracks', () => { - profiler.measure('track-test', () => 'result', { + profiler.measure('track-test', (): string => 'result', { success: result => ({ properties: [['result', result]], tooltipText: 'Track test completed', @@ -154,7 +148,7 @@ describe('Profiler Integration', () => { }); const measures = performance.getEntriesByType('measure'); - expect(measures).toEqual( + expect(measures).toStrictEqual( expect.arrayContaining([ expect.objectContaining({ detail: { @@ -172,7 +166,6 @@ describe('Profiler Integration', () => { }); it('should merge track defaults with measurement options', () => { - // Use the sync track from our configuration profiler.measure('sync-op', () => 'sync-result', { success: result => ({ properties: [ @@ -183,14 +176,14 @@ describe('Profiler Integration', () => { }); const measures = performance.getEntriesByType('measure'); - expect(measures).toEqual( + expect(measures).toStrictEqual( expect.arrayContaining([ expect.objectContaining({ detail: { devtools: expect.objectContaining({ dataType: 'track-entry', - track: 'integration-tests', // default track - color: 'primary', // default color + track: 'integration-tests', + color: 'primary', properties: [ ['operation', 'sync'], ['result', 'sync-result'], @@ -212,7 +205,7 @@ describe('Profiler Integration', () => { }).toThrow(error); const measures = performance.getEntriesByType('measure'); - expect(measures).toEqual( + expect(measures).toStrictEqual( expect.arrayContaining([ expect.objectContaining({ detail: { @@ -239,7 +232,7 @@ describe('Profiler Integration', () => { }).toThrow(customError); const measures = performance.getEntriesByType('measure'); - expect(measures).toEqual( + expect(measures).toStrictEqual( expect.arrayContaining([ expect.objectContaining({ detail: { @@ -264,21 +257,17 @@ describe('Profiler Integration', () => { enabled: false, }); - // Test sync measurement const syncResult = disabledProfiler.measure('disabled-sync', () => 'sync'); expect(syncResult).toBe('sync'); - // Test async measurement const asyncResult = disabledProfiler.measureAsync( 'disabled-async', async () => 'async', ); await expect(asyncResult).resolves.toBe('async'); - // Test marker disabledProfiler.marker('disabled-marker'); - // Verify no performance entries were created expect(performance.getEntriesByType('mark')).toHaveLength(0); expect(performance.getEntriesByType('measure')).toHaveLength(0); }); diff --git a/packages/utils/src/lib/profiler/profiler.ts b/packages/utils/src/lib/profiler/profiler.ts index 491b9824b..cc0cf2ad6 100644 --- a/packages/utils/src/lib/profiler/profiler.ts +++ b/packages/utils/src/lib/profiler/profiler.ts @@ -1,6 +1,7 @@ import process from 'node:process'; import { isEnvVarEnabled } from '../env.js'; import { + type MeasureCtxOptions, type MeasureOptions, asOptions, markerPayload, @@ -8,30 +9,23 @@ import { setupTracks, } from '../user-timing-extensibility-api-utils.js'; import type { - ActionColorPayload, ActionTrackEntryPayload, DevToolsColor, EntryMeta, - TrackMeta, } from '../user-timing-extensibility-api.type.js'; import { PROFILER_ENABLED } from './constants.js'; -/** Default track configuration combining metadata and color options. */ -type DefaultTrackOptions = TrackMeta & ActionColorPayload; - /** * Configuration options for creating a Profiler instance. * * @template T - Record type defining available track names and their configurations */ type ProfilerMeasureOptions> = - DefaultTrackOptions & { + MeasureCtxOptions & { /** Custom track configurations that will be merged with default settings */ - tracks: Record>; + tracks?: Record>; /** Whether profiling should be enabled (defaults to CP_PROFILING env var) */ enabled?: boolean; - /** Prefix for all performance measurement names to avoid conflicts */ - prefix: string; }; /** @@ -51,38 +45,11 @@ export type ProfilerOptions> = * integration for Chrome DevTools Performance panel. It supports both synchronous and * asynchronous operations with customizable track visualization. * - * @example - * ```typescript - * const profiler = new Profiler({ - * prefix: 'api', - * track: 'backend-calls', - * trackGroup: 'api', - * color: 'secondary', - * tracks: { - * database: { track: 'database', color: 'tertiary' }, - * external: { track: 'external-apis', color: 'primary' } - * } - * }); - * - * // Measure synchronous operation - * const result = profiler.measure('fetch-user', () => api.getUser(id)); - * - * // Measure async operation - * const asyncResult = await profiler.measureAsync('save-data', - * () => api.saveData(data) - * ); - * - * // Add marker - * profiler.marker('cache-invalidated', { - * color: 'warning', - * tooltipText: 'Cache cleared due to stale data' - * }); - * ``` */ export class Profiler> { #enabled: boolean; private readonly defaults: ActionTrackEntryPayload; - readonly tracks: Record; + readonly tracks: Record | undefined; private readonly ctxOf: ReturnType; /** @@ -96,20 +63,6 @@ export class Profiler> { * @param options.color - Default color for track entries * @param options.enabled - Whether profiling is enabled (defaults to CP_PROFILING env var) * - * @example - * ```typescript - * const profiler = new Profiler({ - * prefix: 'api', - * track: 'backend-calls', - * trackGroup: 'api', - * color: 'secondary', - * enabled: true, - * tracks: { - * database: { track: 'database', color: 'tertiary' }, - * cache: { track: 'cache', color: 'primary' } - * } - * }); - * ``` */ constructor(options: ProfilerOptions) { const { tracks, prefix, enabled, ...defaults } = options; @@ -117,7 +70,9 @@ export class Profiler> { this.#enabled = enabled ?? isEnvVarEnabled(PROFILER_ENABLED); this.defaults = { ...defaults, dataType }; - this.tracks = setupTracks({ ...defaults, dataType }, tracks); + this.tracks = tracks + ? setupTracks({ ...defaults, dataType }, tracks) + : undefined; this.ctxOf = measureCtx({ ...defaults, dataType, @@ -157,13 +112,12 @@ export class Profiler> { * returns immediately without creating any performance entries. * * @param name - Unique name for the marker - * @param opt - Optional metadata and styling for the marker + * @param opt - Metadata and styling for the marker * @param opt.color - Color of the marker line (defaults to profiler default) * @param opt.tooltipText - Text shown on hover * @param opt.properties - Key-value pairs for detailed view * * @example - * ```typescript * profiler.marker('user-action-start', { * color: 'primary', * tooltipText: 'User clicked save button', @@ -172,7 +126,6 @@ export class Profiler> { * ['elementId', 'save-btn'] * ] * }); - * ``` */ marker(name: string, opt?: EntryMeta & { color: DevToolsColor }) { if (!this.#enabled) { @@ -201,26 +154,19 @@ export class Profiler> { * @template R - The return type of the work function * @param event - Name for this measurement event * @param work - Function to execute and measure - * @param options - Optional measurement configuration overrides + * @param options - Measurement configuration overrides * @returns The result of the work function * - * @example - * ```typescript - * const user = profiler.measure('fetch-user', () => { - * return api.getUser(userId); - * }, { - * success: (result) => ({ - * properties: [['userId', result.id], ['loadTime', Date.now()]] - * }) - * }); - * ``` */ - measure(event: string, work: () => R, options?: MeasureOptions): R { + measure(event: string, work: () => R, options?: MeasureOptions): R { if (!this.#enabled) { return work(); } - const { start, success, error } = this.ctxOf(event, options); + const { start, success, error } = this.ctxOf( + event, + options as MeasureOptions, + ); start(); try { const r = work(); @@ -242,34 +188,23 @@ export class Profiler> { * @template R - The resolved type of the work promise * @param event - Name for this measurement event * @param work - Function returning a promise to execute and measure - * @param options - Optional measurement configuration overrides + * @param options - Measurement configuration overrides * @returns Promise that resolves to the result of the work function * - * @example - * ```typescript - * const data = await profiler.measureAsync('save-form', async () => { - * const result = await api.saveForm(formData); - * return result; - * }, { - * success: (result) => ({ - * properties: [['recordsSaved', result.count]] - * }), - * error: (err) => ({ - * properties: [['errorType', err.name]] - * }) - * }); - * ``` */ async measureAsync( event: string, work: () => Promise, - options?: MeasureOptions, + options?: MeasureOptions, ): Promise { if (!this.#enabled) { return await work(); } - const { start, success, error } = this.ctxOf(event, options); + const { start, success, error } = this.ctxOf( + event, + options as MeasureOptions, + ); start(); try { const r = work(); diff --git a/packages/utils/src/lib/profiler/profiler.unit.test.ts b/packages/utils/src/lib/profiler/profiler.unit.test.ts index 0965139e7..a96beb162 100644 --- a/packages/utils/src/lib/profiler/profiler.unit.test.ts +++ b/packages/utils/src/lib/profiler/profiler.unit.test.ts @@ -1,3 +1,4 @@ +import { performance } from 'node:perf_hooks'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ActionTrackEntryPayload } from '../user-timing-extensibility-api.type.js'; import { Profiler } from './profiler.js'; @@ -6,34 +7,33 @@ describe('Profiler', () => { let profiler: Profiler>; beforeEach(() => { - // Environment variables are mocked in individual tests using vi.stubEnv + performance.clearMarks(); + performance.clearMeasures(); profiler = new Profiler({ - prefix: 'test', + prefix: 'cp', track: 'test-track', color: 'primary', tracks: {}, }); }); - it('should initialize with default enabled state from env', () => { + it('constructor should initialize with default enabled state from env', () => { vi.stubEnv('CP_PROFILING', 'true'); const profilerWithEnv = new Profiler({ - prefix: 'test', + prefix: 'cp', track: 'test-track', - color: 'primary', tracks: {}, }); expect(profilerWithEnv.isEnabled()).toBe(true); }); - it('should override enabled state from options', () => { + it('constructor should override enabled state from options', () => { vi.stubEnv('CP_PROFILING', 'false'); const profilerWithOverride = new Profiler({ - prefix: 'test', + prefix: 'cp', track: 'test-track', - color: 'primary', tracks: {}, enabled: true, }); @@ -41,34 +41,93 @@ describe('Profiler', () => { expect(profilerWithOverride.isEnabled()).toBe(true); }); - it('should setup tracks with defaults merged', () => { + it('constructor should use defaults for measure', () => { + const customProfiler = new Profiler({ + prefix: 'custom', + track: 'custom-track', + trackGroup: 'custom-group', + color: 'secondary', + }); + + customProfiler.setEnabled(true); + + const result = customProfiler.measure('test-operation', () => 'success'); + + expect(result).toBe('success'); + + const marks = performance.getEntriesByType('mark'); + const measures = performance.getEntriesByType('measure'); + + expect(marks).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'custom:test-operation:start', + detail: { + devtools: expect.objectContaining({ + dataType: 'track-entry', + track: 'custom-track', + trackGroup: 'custom-group', + color: 'secondary', + }), + }, + }), + expect.objectContaining({ + name: 'custom:test-operation:end', + detail: { + devtools: expect.objectContaining({ + dataType: 'track-entry', + track: 'custom-track', + trackGroup: 'custom-group', + color: 'secondary', + }), + }, + }), + ]), + ); + expect(measures).toStrictEqual([ + expect.objectContaining({ + name: 'custom:test-operation', + detail: { + devtools: expect.objectContaining({ + dataType: 'track-entry', + track: 'custom-track', + trackGroup: 'custom-group', + color: 'secondary', + }), + }, + }), + ]); + }); + + it('constructor should setup tracks with defaults merged', () => { const profilerWithTracks = new Profiler({ - prefix: 'test', + prefix: 'cp', track: 'default-track', trackGroup: 'default-group', color: 'primary', tracks: { custom: { track: 'custom-track', color: 'secondary' }, - partial: { color: 'tertiary' }, // partial override + partial: { color: 'tertiary' }, }, }); - expect(profilerWithTracks.tracks.custom).toEqual({ - track: 'custom-track', - trackGroup: 'default-group', - color: 'secondary', - dataType: 'track-entry', - }); - - expect(profilerWithTracks.tracks.partial).toEqual({ - track: 'default-track', // inherited from defaults - trackGroup: 'default-group', - color: 'tertiary', // overridden - dataType: 'track-entry', + expect(profilerWithTracks.tracks).toStrictEqual({ + custom: { + track: 'custom-track', + trackGroup: 'default-group', + color: 'secondary', + dataType: 'track-entry', + }, + partial: { + track: 'default-track', + trackGroup: 'default-group', + color: 'tertiary', + dataType: 'track-entry', + }, }); }); - it('should set and get enabled state', () => { + it('isEnabled should set and get enabled state', () => { expect(profiler.isEnabled()).toBe(false); profiler.setEnabled(true); @@ -78,7 +137,7 @@ describe('Profiler', () => { expect(profiler.isEnabled()).toBe(false); }); - it('should update environment variable', () => { + it('isEnabled should update environment variable', () => { profiler.setEnabled(true); expect(process.env.CP_PROFILING).toBe('true'); @@ -86,7 +145,7 @@ describe('Profiler', () => { expect(process.env.CP_PROFILING).toBe('false'); }); - it('should execute marker without error when enabled', () => { + it('marker should execute without error when enabled', () => { profiler.setEnabled(true); expect(() => { @@ -96,17 +155,35 @@ describe('Profiler', () => { properties: [['key', 'value']], }); }).not.toThrow(); + + const marks = performance.getEntriesByType('mark'); + expect(marks).toStrictEqual([ + expect.objectContaining({ + name: 'test-marker', + detail: { + devtools: expect.objectContaining({ + dataType: 'marker', + color: 'primary', + tooltipText: 'Test marker', + properties: [['key', 'value']], + }), + }, + }), + ]); }); - it('should execute marker without error when disabled', () => { + it('marker should execute without error when disabled', () => { profiler.setEnabled(false); expect(() => { profiler.marker('test-marker'); }).not.toThrow(); + + const marks = performance.getEntriesByType('mark'); + expect(marks).toHaveLength(0); }); - it('should execute work and return result when measure enabled', () => { + it('measure should execute work and return result when enabled', () => { profiler.setEnabled(true); const workFn = vi.fn(() => 'result'); @@ -114,19 +191,64 @@ describe('Profiler', () => { expect(result).toBe('result'); expect(workFn).toHaveBeenCalled(); + + const marks = performance.getEntriesByType('mark'); + const measures = performance.getEntriesByType('measure'); + + expect(marks).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'cp:test-event:start', + detail: { + devtools: expect.objectContaining({ + dataType: 'track-entry', + track: 'test-track', + color: 'primary', + }), + }, + }), + expect.objectContaining({ + name: 'cp:test-event:end', + detail: { + devtools: expect.objectContaining({ + dataType: 'track-entry', + track: 'test-track', + color: 'primary', + }), + }, + }), + ]), + ); + expect(measures).toStrictEqual([ + expect.objectContaining({ + name: 'cp:test-event', + detail: { + devtools: expect.objectContaining({ + dataType: 'track-entry', + track: 'test-track', + color: 'primary', + }), + }, + }), + ]); }); - it('should execute work directly when measure disabled', () => { + it('measure should execute work directly when disabled', () => { profiler.setEnabled(false); - const workFn = vi.fn(() => 'result'); const result = profiler.measure('test-event', workFn); expect(result).toBe('result'); expect(workFn).toHaveBeenCalled(); + + const marks = performance.getEntriesByType('mark'); + const measures = performance.getEntriesByType('measure'); + + expect(marks).toHaveLength(0); + expect(measures).toHaveLength(0); }); - it('should propagate errors when measure enabled', () => { + it('measure should propagate errors when enabled', () => { profiler.setEnabled(true); const error = new Error('Test error'); @@ -138,7 +260,7 @@ describe('Profiler', () => { expect(workFn).toHaveBeenCalled(); }); - it('should propagate errors when measure disabled', () => { + it('measure should propagate errors when disabled', () => { profiler.setEnabled(false); const error = new Error('Test error'); @@ -150,7 +272,7 @@ describe('Profiler', () => { expect(workFn).toHaveBeenCalled(); }); - it('should handle async operations correctly when enabled', async () => { + it('measureAsync should handle async operations correctly when enabled', async () => { profiler.setEnabled(true); const workFn = vi.fn(async () => { @@ -162,9 +284,49 @@ describe('Profiler', () => { expect(result).toBe('async-result'); expect(workFn).toHaveBeenCalled(); + + const marks = performance.getEntriesByType('mark'); + const measures = performance.getEntriesByType('measure'); + + expect(marks).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'cp:test-async-event:start', + detail: { + devtools: expect.objectContaining({ + dataType: 'track-entry', + track: 'test-track', + color: 'primary', + }), + }, + }), + expect.objectContaining({ + name: 'cp:test-async-event:end', + detail: { + devtools: expect.objectContaining({ + dataType: 'track-entry', + track: 'test-track', + color: 'primary', + }), + }, + }), + ]), + ); + expect(measures).toStrictEqual([ + expect.objectContaining({ + name: 'cp:test-async-event', + detail: { + devtools: expect.objectContaining({ + dataType: 'track-entry', + track: 'test-track', + color: 'primary', + }), + }, + }), + ]); }); - it('should execute async work directly when disabled', async () => { + it('measureAsync should execute async work directly when disabled', async () => { profiler.setEnabled(false); const workFn = vi.fn(async () => { @@ -176,9 +338,15 @@ describe('Profiler', () => { expect(result).toBe('async-result'); expect(workFn).toHaveBeenCalled(); + + const marks = performance.getEntriesByType('mark'); + const measures = performance.getEntriesByType('measure'); + + expect(marks).toHaveLength(0); + expect(measures).toHaveLength(0); }); - it('should propagate async errors when enabled', async () => { + it('measureAsync should propagate async errors when enabled', async () => { profiler.setEnabled(true); const error = new Error('Async test error'); @@ -193,7 +361,7 @@ describe('Profiler', () => { expect(workFn).toHaveBeenCalled(); }); - it('should propagate async errors when disabled', async () => { + it('measureAsync should propagate async errors when disabled', async () => { profiler.setEnabled(false); const error = new Error('Async test error'); 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 6a5cb7484..cf974af0f 100644 --- a/packages/utils/src/lib/user-timing-extensibility-api-utils.ts +++ b/packages/utils/src/lib/user-timing-extensibility-api-utils.ts @@ -369,19 +369,19 @@ function toMarkMeasureOpts(devtools: T) { * Options for customizing measurement behavior and callbacks. * Extends partial ActionTrackEntryPayload to allow overriding default track properties. */ -export type MeasureOptions = Partial & { +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; + success?: (result: T) => EntryMeta; /** * 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; + error?: (error: unknown) => EntryMeta; }; /** @@ -473,7 +473,7 @@ export type MeasureCtxOptions = ActionTrackEntryPayload & { * - `error(error)`: Completes failed measurement with error metadata */ -export function measureCtx(cfg: MeasureCtxOptions) { +export function measureCtx(cfg: MeasureCtxOptions) { const { prefix, error: globalErr, ...defaults } = cfg; return (event: string, opt?: MeasureOptions) => { @@ -488,7 +488,7 @@ export function measureCtx(cfg: MeasureCtxOptions) { return { start: () => performance.mark(s, toMarkMeasureOpts(merged)), - success: (r: unknown) => { + success: (r: T) => { const successPayload = mergeDevtoolsPayload(merged, success?.(r) ?? {}); performance.mark(e, toMarkMeasureOpts(successPayload)); performance.measure(m, { From 2cec4245871decc461d6b58dc80204c684aa57c7 Mon Sep 17 00:00:00 2001 From: John Doe Date: Wed, 14 Jan 2026 20:47:21 +0100 Subject: [PATCH 03/16] refactor: fix unit tests --- .../src/lib/profiler/profiler.unit.test.ts | 78 +++++++++++++++++-- .../user-timing-extensibility-api-utils.ts | 7 +- .../src/lib/utils/perf-hooks.mock.ts | 31 +++++--- 3 files changed, 92 insertions(+), 24 deletions(-) diff --git a/packages/utils/src/lib/profiler/profiler.unit.test.ts b/packages/utils/src/lib/profiler/profiler.unit.test.ts index a96beb162..0705839c8 100644 --- a/packages/utils/src/lib/profiler/profiler.unit.test.ts +++ b/packages/utils/src/lib/profiler/profiler.unit.test.ts @@ -9,11 +9,11 @@ describe('Profiler', () => { beforeEach(() => { performance.clearMarks(); performance.clearMeasures(); + delete process.env.CP_PROFILING; profiler = new Profiler({ prefix: 'cp', track: 'test-track', - color: 'primary', tracks: {}, }); }); @@ -183,11 +183,78 @@ describe('Profiler', () => { expect(marks).toHaveLength(0); }); + it('marker should execute without error when enabled with default color', () => { + performance.clearMarks(); + + const profilerWithColor = new Profiler({ + prefix: 'cp', + track: 'test-track', + color: 'primary', + tracks: {}, + }); + profilerWithColor.setEnabled(true); + + expect(() => { + profilerWithColor.marker('test-marker-default-color', { + tooltipText: 'Test marker with default color', + }); + }).not.toThrow(); + + const marks = performance.getEntriesByType('mark'); + expect(marks).toStrictEqual([ + expect.objectContaining({ + name: 'test-marker-default-color', + detail: { + devtools: expect.objectContaining({ + dataType: 'marker', + color: 'primary', // Should use default color + tooltipText: 'Test marker with default color', + }), + }, + }), + ]); + }); + + it('marker should execute without error when enabled with no default color', () => { + const profilerNoColor = new Profiler({ + prefix: 'cp', + track: 'test-track', + tracks: {}, + }); + profilerNoColor.setEnabled(true); + + expect(() => { + profilerNoColor.marker('test-marker-no-color', { + color: 'secondary', + tooltipText: 'Test marker without default color', + properties: [['key', 'value']], + }); + }).not.toThrow(); + + const marks = performance.getEntriesByType('mark'); + expect(marks).toStrictEqual([ + expect.objectContaining({ + name: 'test-marker-no-color', + detail: { + devtools: expect.objectContaining({ + dataType: 'marker', + color: 'secondary', + tooltipText: 'Test marker without default color', + properties: [['key', 'value']], + }), + }, + }), + ]); + }); + it('measure should execute work and return result when enabled', () => { + performance.clearMarks(); + performance.clearMeasures(); + profiler.setEnabled(true); const workFn = vi.fn(() => 'result'); - const result = profiler.measure('test-event', workFn); + const result = profiler.measure('test-event', workFn, { color: 'primary' }); expect(result).toBe('result'); expect(workFn).toHaveBeenCalled(); @@ -203,7 +270,6 @@ describe('Profiler', () => { devtools: expect.objectContaining({ dataType: 'track-entry', track: 'test-track', - color: 'primary', }), }, }), @@ -213,7 +279,6 @@ describe('Profiler', () => { devtools: expect.objectContaining({ dataType: 'track-entry', track: 'test-track', - color: 'primary', }), }, }), @@ -226,7 +291,6 @@ describe('Profiler', () => { devtools: expect.objectContaining({ dataType: 'track-entry', track: 'test-track', - color: 'primary', }), }, }), @@ -280,7 +344,9 @@ describe('Profiler', () => { return 'async-result'; }); - const result = await profiler.measureAsync('test-async-event', workFn); + const result = await profiler.measureAsync('test-async-event', workFn, { + color: 'primary', + }); expect(result).toBe('async-result'); expect(workFn).toHaveBeenCalled(); 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 cf974af0f..423909cf1 100644 --- a/packages/utils/src/lib/user-timing-extensibility-api-utils.ts +++ b/packages/utils/src/lib/user-timing-extensibility-api-utils.ts @@ -356,12 +356,7 @@ export function setupTracks< * @returns The mark options without dataType, tooltipText and properties. */ function toMarkMeasureOpts(devtools: T) { - const { - dataType: _, - tooltipText: __, - properties: ___, - ...markDevtools - } = devtools; + const { tooltipText: _, properties: __, ...markDevtools } = devtools; return { detail: { devtools: markDevtools } }; } diff --git a/testing/test-utils/src/lib/utils/perf-hooks.mock.ts b/testing/test-utils/src/lib/utils/perf-hooks.mock.ts index b22e88bd5..7bcea1cb5 100644 --- a/testing/test-utils/src/lib/utils/perf-hooks.mock.ts +++ b/testing/test-utils/src/lib/utils/perf-hooks.mock.ts @@ -33,27 +33,34 @@ export const createPerformanceMock = (timeOrigin = 500_000) => ({ now: vi.fn(() => nowMs), - mark: vi.fn((name: string) => { + mark: vi.fn((name: string, options?: { detail?: unknown }) => { entries.push({ name, entryType: 'mark', startTime: nowMs, duration: 0, + ...(options?.detail ? { detail: options.detail } : {}), } as PerformanceEntry); MockPerformanceObserver.globalEntries = entries; }), - measure: vi.fn((name: string, startMark?: string, endMark?: string) => { - const entry = { - name, - entryType: 'measure', - startTime: nowMs, - duration: nowMs, - } as PerformanceEntry; - entries.push(entry); - MockPerformanceObserver.globalEntries = entries; - triggerObservers([entry]); - }), + measure: vi.fn( + ( + name: string, + options?: { start?: string; end?: string; detail?: unknown }, + ) => { + const entry = { + name, + entryType: 'measure', + startTime: nowMs, + duration: nowMs, + ...(options?.detail ? { detail: options.detail } : {}), + } as PerformanceEntry; + entries.push(entry); + MockPerformanceObserver.globalEntries = entries; + triggerObservers([entry]); + }, + ), getEntries: vi.fn(() => entries.slice()), From 64457f354348944fac242d220040450e6195176b Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Wed, 14 Jan 2026 23:06:07 +0100 Subject: [PATCH 04/16] refactor: wip --- packages/utils/src/lib/profiler/profiler.ts | 10 +++- .../src/lib/profiler/profiler.unit.test.ts | 58 +++++++------------ 2 files changed, 27 insertions(+), 41 deletions(-) diff --git a/packages/utils/src/lib/profiler/profiler.ts b/packages/utils/src/lib/profiler/profiler.ts index cc0cf2ad6..d56ab5718 100644 --- a/packages/utils/src/lib/profiler/profiler.ts +++ b/packages/utils/src/lib/profiler/profiler.ts @@ -35,8 +35,12 @@ type ProfilerMeasureOptions> = * * @template T - Record type defining available track names and their configurations */ -export type ProfilerOptions> = - ProfilerMeasureOptions; +export type ProfilerOptions< + T extends Record = Record< + string, + ActionTrackEntryPayload + >, +> = ProfilerMeasureOptions; /** * Performance profiler that creates structured timing measurements with DevTools visualization. @@ -127,7 +131,7 @@ export class Profiler> { * ] * }); */ - marker(name: string, opt?: EntryMeta & { color: DevToolsColor }) { + marker(name: string, opt?: EntryMeta & { color?: DevToolsColor }) { if (!this.#enabled) { return; } diff --git a/packages/utils/src/lib/profiler/profiler.unit.test.ts b/packages/utils/src/lib/profiler/profiler.unit.test.ts index 0705839c8..0e285deb2 100644 --- a/packages/utils/src/lib/profiler/profiler.unit.test.ts +++ b/packages/utils/src/lib/profiler/profiler.unit.test.ts @@ -1,30 +1,30 @@ import { performance } from 'node:perf_hooks'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ActionTrackEntryPayload } from '../user-timing-extensibility-api.type.js'; -import { Profiler } from './profiler.js'; +import { Profiler, type ProfilerOptions } from './profiler.js'; describe('Profiler', () => { + const getProfiler = (overrides?: Partial) => + new Profiler({ + prefix: 'cp', + track: 'test-track', + ...overrides, + }); + let profiler: Profiler>; beforeEach(() => { performance.clearMarks(); performance.clearMeasures(); + // eslint-disable-next-line functional/immutable-data delete process.env.CP_PROFILING; - profiler = new Profiler({ - prefix: 'cp', - track: 'test-track', - tracks: {}, - }); + profiler = getProfiler(); }); it('constructor should initialize with default enabled state from env', () => { vi.stubEnv('CP_PROFILING', 'true'); - const profilerWithEnv = new Profiler({ - prefix: 'cp', - track: 'test-track', - tracks: {}, - }); + const profilerWithEnv = getProfiler(); expect(profilerWithEnv.isEnabled()).toBe(true); }); @@ -34,7 +34,6 @@ describe('Profiler', () => { const profilerWithOverride = new Profiler({ prefix: 'cp', track: 'test-track', - tracks: {}, enabled: true, }); @@ -42,12 +41,7 @@ describe('Profiler', () => { }); it('constructor should use defaults for measure', () => { - const customProfiler = new Profiler({ - prefix: 'custom', - track: 'custom-track', - trackGroup: 'custom-group', - color: 'secondary', - }); + const customProfiler = getProfiler({ color: 'secondary' }); customProfiler.setEnabled(true); @@ -61,23 +55,21 @@ describe('Profiler', () => { expect(marks).toStrictEqual( expect.arrayContaining([ expect.objectContaining({ - name: 'custom:test-operation:start', + name: 'cp:test-operation:start', detail: { devtools: expect.objectContaining({ dataType: 'track-entry', - track: 'custom-track', - trackGroup: 'custom-group', + track: 'test-track', color: 'secondary', }), }, }), expect.objectContaining({ - name: 'custom:test-operation:end', + name: 'cp:test-operation:end', detail: { devtools: expect.objectContaining({ dataType: 'track-entry', - track: 'custom-track', - trackGroup: 'custom-group', + track: 'test-track', color: 'secondary', }), }, @@ -86,12 +78,11 @@ describe('Profiler', () => { ); expect(measures).toStrictEqual([ expect.objectContaining({ - name: 'custom:test-operation', + name: 'cp:test-operation', detail: { devtools: expect.objectContaining({ dataType: 'track-entry', - track: 'custom-track', - trackGroup: 'custom-group', + track: 'test-track', color: 'secondary', }), }, @@ -186,12 +177,7 @@ describe('Profiler', () => { it('marker should execute without error when enabled with default color', () => { performance.clearMarks(); - const profilerWithColor = new Profiler({ - prefix: 'cp', - track: 'test-track', - color: 'primary', - tracks: {}, - }); + const profilerWithColor = getProfiler({ color: 'primary' }); profilerWithColor.setEnabled(true); expect(() => { @@ -216,11 +202,7 @@ describe('Profiler', () => { }); it('marker should execute without error when enabled with no default color', () => { - const profilerNoColor = new Profiler({ - prefix: 'cp', - track: 'test-track', - tracks: {}, - }); + const profilerNoColor = getProfiler(); profilerNoColor.setEnabled(true); expect(() => { From 2e51747aead3afd0387c171db20db1905107c51d Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Fri, 16 Jan 2026 15:38:32 +0100 Subject: [PATCH 05/16] refactor: wip --- packages/utils/src/lib/profiler/profiler.ts | 28 +++++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/utils/src/lib/profiler/profiler.ts b/packages/utils/src/lib/profiler/profiler.ts index d56ab5718..e50c589f4 100644 --- a/packages/utils/src/lib/profiler/profiler.ts +++ b/packages/utils/src/lib/profiler/profiler.ts @@ -34,6 +34,13 @@ type ProfilerMeasureOptions> = * This is an alias for ProfilerMeasureOptions for backward compatibility. * * @template T - Record type defining available track names and their configurations + * + * @property enabled - Whether profiling is enabled (defaults to CP_PROFILING env var) + * @property prefix - Prefix for all measurement names + * @property track - Default track name for measurements + * @property trackGroup - Default track group for organization + * @property color - Default color for track entries + * @property tracks - Custom track configurations merged with defaults */ export type ProfilerOptions< T extends Record = Record< @@ -43,11 +50,10 @@ export type ProfilerOptions< > = ProfilerMeasureOptions; /** - * Performance profiler that creates structured timing measurements with DevTools visualization. + * Performance profiler that creates structured timing measurements with Chrome DevTools Extensibility API payloads. * - * This class provides high-level APIs for performance monitoring with automatic DevTools - * integration for Chrome DevTools Performance panel. It supports both synchronous and - * asynchronous operations with customizable track visualization. + * This class provides high-level APIs for performance monitoring focused on Chrome DevTools Extensibility API data. + * It supports both synchronous and asynchronous operations with all having smart defaults for custom track data. * */ export class Profiler> { @@ -109,7 +115,7 @@ export class Profiler> { } /** - * Creates a performance marker in the DevTools Performance panel. + * Creates a performance mark including payload for a Chrome DevTools 'marker' item. * * Markers appear as vertical lines spanning all tracks and can include custom metadata * for debugging and performance analysis. When profiling is disabled, this method @@ -119,7 +125,7 @@ export class Profiler> { * @param opt - Metadata and styling for the marker * @param opt.color - Color of the marker line (defaults to profiler default) * @param opt.tooltipText - Text shown on hover - * @param opt.properties - Key-value pairs for detailed view + * @param opt.properties - Key-value pairs for detailed view show on click * * @example * profiler.marker('user-action-start', { @@ -140,7 +146,7 @@ export class Profiler> { name, asOptions( markerPayload({ - // marker only supports color no TrackMeta + // marker only takes default color, no TrackMeta ...(this.defaults.color ? { color: this.defaults.color } : {}), ...opt, }), @@ -151,8 +157,8 @@ export class Profiler> { /** * Measures the execution time of a synchronous operation. * - * Creates start/end marks and a final measure entry in the performance timeline. - * The operation appears in the configured track with proper DevTools visualization. + * Creates performance start/end marks and a final measure. + * All entries have Chrome DevTools Extensibility API payload and are visualized under custom tracks. * When profiling is disabled, executes the work function directly without overhead. * * @template R - The return type of the work function @@ -185,8 +191,8 @@ export class Profiler> { /** * Measures the execution time of an asynchronous operation. * - * Creates start/end marks and a final measure entry in the performance timeline. - * The operation appears in the configured track with proper DevTools visualization. + * Creates performance start/end marks and a final measure. + * All entries have Chrome DevTools Extensibility API payload and are visualized under custom tracks. * When profiling is disabled, executes and awaits the work function directly without overhead. * * @template R - The resolved type of the work promise From 381790be7ca7fbf6067d2720403bcef1437e50e3 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Fri, 16 Jan 2026 15:51:16 +0100 Subject: [PATCH 06/16] refactor: adjust int tests --- .../src/lib/profiler/profiler.int.test.ts | 85 ++++++++++++------- 1 file changed, 55 insertions(+), 30 deletions(-) diff --git a/packages/utils/src/lib/profiler/profiler.int.test.ts b/packages/utils/src/lib/profiler/profiler.int.test.ts index 5feebcdcc..949f66649 100644 --- a/packages/utils/src/lib/profiler/profiler.int.test.ts +++ b/packages/utils/src/lib/profiler/profiler.int.test.ts @@ -11,12 +11,13 @@ describe('Profiler Integration', () => { performance.clearMeasures(); profiler = new Profiler({ - prefix: 'test', - track: 'integration-tests', - color: 'primary', + prefix: 'cp', + track: 'CLI', + trackGroup: 'Code Pushup', + color: 'primary-dark', tracks: { - async: { track: 'async-ops', color: 'secondary' }, - sync: { track: 'sync-ops', color: 'tertiary' }, + utils: { track: 'Utils', color: 'primary' }, + core: { track: 'Core', color: 'primary-light' }, }, enabled: true, }); @@ -37,16 +38,29 @@ describe('Profiler Integration', () => { expect(marks).toStrictEqual( expect.arrayContaining([ - expect.objectContaining({ name: 'test:sync-test:start' }), - expect.objectContaining({ name: 'test:sync-test:end' }), + expect.objectContaining({ + name: 'cp:sync-test:start', + detail: expect.objectContaining({ + devtools: expect.objectContaining({ dataType: 'track-entry' }), + }), + }), + expect.objectContaining({ + name: 'cp:sync-test:end', + detail: expect.objectContaining({ + devtools: expect.objectContaining({ dataType: 'track-entry' }), + }), + }), ]), ); expect(measures).toStrictEqual( expect.arrayContaining([ expect.objectContaining({ - name: 'test:sync-test', + name: 'cp:sync-test', duration: expect.any(Number), + detail: expect.objectContaining({ + devtools: expect.objectContaining({ dataType: 'track-entry' }), + }), }), ]), ); @@ -65,16 +79,29 @@ describe('Profiler Integration', () => { expect(marks).toStrictEqual( expect.arrayContaining([ - expect.objectContaining({ name: 'test:async-test:start' }), - expect.objectContaining({ name: 'test:async-test:end' }), + expect.objectContaining({ + name: 'cp:async-test:start', + detail: expect.objectContaining({ + devtools: expect.objectContaining({ dataType: 'track-entry' }), + }), + }), + expect.objectContaining({ + name: 'cp:async-test:end', + detail: expect.objectContaining({ + devtools: expect.objectContaining({ dataType: 'track-entry' }), + }), + }), ]), ); expect(measures).toStrictEqual( expect.arrayContaining([ expect.objectContaining({ - name: 'test:async-test', + name: 'cp:async-test', duration: expect.any(Number), + detail: expect.objectContaining({ + devtools: expect.objectContaining({ dataType: 'track-entry' }), + }), }), ]), ); @@ -95,16 +122,16 @@ describe('Profiler Integration', () => { const markNames = marks.map(m => m.name); expect(markNames).toStrictEqual( expect.arrayContaining([ - 'test:outer:start', - 'test:outer:end', - 'test:inner:start', - 'test:inner:end', + 'cp:outer:start', + 'cp:outer:end', + 'cp:inner:start', + 'cp:inner:end', ]), ); const measureNames = measures.map(m => m.name); expect(measureNames).toStrictEqual( - expect.arrayContaining(['test:outer', 'test:inner']), + expect.arrayContaining(['cp:outer', 'cp:inner']), ); }); @@ -151,11 +178,13 @@ describe('Profiler Integration', () => { expect(measures).toStrictEqual( expect.arrayContaining([ expect.objectContaining({ + name: 'cp:track-test', detail: { devtools: expect.objectContaining({ dataType: 'track-entry', - track: 'integration-tests', - color: 'primary', + track: 'CLI', + trackGroup: 'Code Pushup', + color: 'primary-dark', properties: [['result', 'result']], tooltipText: 'Track test completed', }), @@ -179,11 +208,13 @@ describe('Profiler Integration', () => { expect(measures).toStrictEqual( expect.arrayContaining([ expect.objectContaining({ + name: 'cp:sync-op', detail: { devtools: expect.objectContaining({ dataType: 'track-entry', - track: 'integration-tests', - color: 'primary', + track: 'CLI', + trackGroup: 'Code Pushup', + color: 'primary-dark', properties: [ ['operation', 'sync'], ['result', 'sync-result'], @@ -249,24 +280,18 @@ describe('Profiler Integration', () => { }); it('should not create performance entries when disabled', async () => { - const disabledProfiler = new Profiler({ - prefix: 'disabled', - track: 'disabled-tests', - color: 'primary', - tracks: {}, - enabled: false, - }); + profiler.setEnabled(false); - const syncResult = disabledProfiler.measure('disabled-sync', () => 'sync'); + const syncResult = profiler.measure('disabled-sync', () => 'sync'); expect(syncResult).toBe('sync'); - const asyncResult = disabledProfiler.measureAsync( + const asyncResult = profiler.measureAsync( 'disabled-async', async () => 'async', ); await expect(asyncResult).resolves.toBe('async'); - disabledProfiler.marker('disabled-marker'); + profiler.marker('disabled-marker'); expect(performance.getEntriesByType('mark')).toHaveLength(0); expect(performance.getEntriesByType('measure')).toHaveLength(0); From d45ff5c7d67f5ab346bd27fd5d711d94ef6c9d7a Mon Sep 17 00:00:00 2001 From: Michael Hladky <10064416+BioPhoton@users.noreply.github.com> Date: Fri, 16 Jan 2026 17:53:28 +0100 Subject: [PATCH 07/16] Update packages/utils/src/lib/profiler/constants.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> --- packages/utils/src/lib/profiler/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/utils/src/lib/profiler/constants.ts b/packages/utils/src/lib/profiler/constants.ts index d52e31d90..4e149583b 100644 --- a/packages/utils/src/lib/profiler/constants.ts +++ b/packages/utils/src/lib/profiler/constants.ts @@ -1 +1 @@ -export const PROFILER_ENABLED = 'CP_PROFILING'; +export const PROFILER_ENABLED_ENV_VAR = 'CP_PROFILING'; From 93e207a64f370e0bd694bd97477fa68dfc00779f Mon Sep 17 00:00:00 2001 From: Michael Hladky <10064416+BioPhoton@users.noreply.github.com> Date: Fri, 16 Jan 2026 17:53:46 +0100 Subject: [PATCH 08/16] Update packages/utils/src/lib/profiler/profiler.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> --- packages/utils/src/lib/profiler/profiler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/utils/src/lib/profiler/profiler.ts b/packages/utils/src/lib/profiler/profiler.ts index e50c589f4..bf298b1dc 100644 --- a/packages/utils/src/lib/profiler/profiler.ts +++ b/packages/utils/src/lib/profiler/profiler.ts @@ -58,9 +58,9 @@ export type ProfilerOptions< */ export class Profiler> { #enabled: boolean; - private readonly defaults: ActionTrackEntryPayload; + readonly #defaults: ActionTrackEntryPayload; readonly tracks: Record | undefined; - private readonly ctxOf: ReturnType; + readonly #ctxOf: ReturnType; /** * Creates a new Profiler instance with the specified configuration. From a88f9dad5a30c33291a50d8e41700c7fd1538061 Mon Sep 17 00:00:00 2001 From: Michael Hladky <10064416+BioPhoton@users.noreply.github.com> Date: Fri, 16 Jan 2026 18:00:03 +0100 Subject: [PATCH 09/16] Update packages/utils/src/lib/profiler/profiler.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> --- packages/utils/src/lib/profiler/profiler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/utils/src/lib/profiler/profiler.ts b/packages/utils/src/lib/profiler/profiler.ts index bf298b1dc..00dd76f78 100644 --- a/packages/utils/src/lib/profiler/profiler.ts +++ b/packages/utils/src/lib/profiler/profiler.ts @@ -137,7 +137,7 @@ export class Profiler> { * ] * }); */ - marker(name: string, opt?: EntryMeta & { color?: DevToolsColor }) { + marker(name: string, opt?: EntryMeta & { color?: DevToolsColor }): void { if (!this.#enabled) { return; } From 538c09510552904f740c2bbc4cb745c42c6c0230 Mon Sep 17 00:00:00 2001 From: Michael Hladky <10064416+BioPhoton@users.noreply.github.com> Date: Fri, 16 Jan 2026 18:00:22 +0100 Subject: [PATCH 10/16] Update packages/utils/src/lib/profiler/profiler.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> --- packages/utils/src/lib/profiler/profiler.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/utils/src/lib/profiler/profiler.ts b/packages/utils/src/lib/profiler/profiler.ts index 00dd76f78..90dca4884 100644 --- a/packages/utils/src/lib/profiler/profiler.ts +++ b/packages/utils/src/lib/profiler/profiler.ts @@ -157,6 +157,8 @@ export class Profiler> { /** * Measures the execution time of a synchronous operation. * + * For asynchronous operations, use the {@link measureAsync} method. + * * Creates performance start/end marks and a final measure. * All entries have Chrome DevTools Extensibility API payload and are visualized under custom tracks. * When profiling is disabled, executes the work function directly without overhead. From 3e7e17ab952b50f7759034c2e2fee56cd6634518 Mon Sep 17 00:00:00 2001 From: Michael Hladky <10064416+BioPhoton@users.noreply.github.com> Date: Fri, 16 Jan 2026 18:00:41 +0100 Subject: [PATCH 11/16] Update packages/utils/src/lib/profiler/profiler.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> --- packages/utils/src/lib/profiler/profiler.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/utils/src/lib/profiler/profiler.ts b/packages/utils/src/lib/profiler/profiler.ts index 90dca4884..bdee2670a 100644 --- a/packages/utils/src/lib/profiler/profiler.ts +++ b/packages/utils/src/lib/profiler/profiler.ts @@ -193,6 +193,8 @@ export class Profiler> { /** * Measures the execution time of an asynchronous operation. * + * For synchronous operations, use the {@link measure} method. + * * Creates performance start/end marks and a final measure. * All entries have Chrome DevTools Extensibility API payload and are visualized under custom tracks. * When profiling is disabled, executes and awaits the work function directly without overhead. From 3572fefc1b1468f1a0121430d9227dcef074a4ef Mon Sep 17 00:00:00 2001 From: Michael Hladky <10064416+BioPhoton@users.noreply.github.com> Date: Fri, 16 Jan 2026 18:01:50 +0100 Subject: [PATCH 12/16] Update packages/utils/src/lib/profiler/profiler.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> --- packages/utils/src/lib/profiler/profiler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/utils/src/lib/profiler/profiler.ts b/packages/utils/src/lib/profiler/profiler.ts index bdee2670a..a10c0c861 100644 --- a/packages/utils/src/lib/profiler/profiler.ts +++ b/packages/utils/src/lib/profiler/profiler.ts @@ -221,9 +221,9 @@ export class Profiler> { ); start(); try { - const r = work(); + const r = await work(); success(r); - return await r; + return r; } catch (error_) { error(error_); throw error_; From 3ced34019e8f583a7733f715456ba47c1945ec17 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Fri, 16 Jan 2026 18:03:13 +0100 Subject: [PATCH 13/16] refactor: move type parameter from measureCtx outer to inner function - Move generic type parameter from measureCtx function to the returned function - Update MeasureOptions parameter to use MeasureOptions for proper typing - Remove type casts in profiler measure and measureAsync methods - Fix measureAsync to properly await work function before calling success This change allows the type system to properly infer the result type T throughout the measurement chain without requiring type assertions. --- packages/utils/src/lib/profiler/profiler.ts | 10 ++-------- .../src/lib/user-timing-extensibility-api-utils.ts | 11 +++++++---- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/utils/src/lib/profiler/profiler.ts b/packages/utils/src/lib/profiler/profiler.ts index a10c0c861..e051ea079 100644 --- a/packages/utils/src/lib/profiler/profiler.ts +++ b/packages/utils/src/lib/profiler/profiler.ts @@ -175,10 +175,7 @@ export class Profiler> { return work(); } - const { start, success, error } = this.ctxOf( - event, - options as MeasureOptions, - ); + const { start, success, error } = this.ctxOf(event, options); start(); try { const r = work(); @@ -215,10 +212,7 @@ export class Profiler> { return await work(); } - const { start, success, error } = this.ctxOf( - event, - options as MeasureOptions, - ); + const { start, success, error } = this.ctxOf(event, options); start(); try { const r = await work(); 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 423909cf1..fedae9fa3 100644 --- a/packages/utils/src/lib/user-timing-extensibility-api-utils.ts +++ b/packages/utils/src/lib/user-timing-extensibility-api-utils.ts @@ -330,7 +330,10 @@ export function mergeDevtoolsPayload< {} as MergeResult

& { properties?: DevToolsProperties }, ); } - +export type ActionTrackConfigs = Record< + T, + ActionTrackEntryPayload +>; /** * Sets up tracks with default values merged into each track. * This helps to avoid repetition when defining multiple tracks with common properties. @@ -347,7 +350,7 @@ export function setupTracks< key, mergeDevtoolsPayload(defaults, track), ]), - ); + ) satisfies ActionTrackConfigs; } /** @@ -468,10 +471,10 @@ export type MeasureCtxOptions = ActionTrackEntryPayload & { * - `error(error)`: Completes failed measurement with error metadata */ -export function measureCtx(cfg: MeasureCtxOptions) { +export function measureCtx(cfg: MeasureCtxOptions) { const { prefix, error: globalErr, ...defaults } = cfg; - return (event: string, opt?: MeasureOptions) => { + return (event: string, opt?: MeasureOptions) => { const { success, error, ...measurePayload } = opt ?? {}; const merged = mergeDevtoolsPayload(defaults, measurePayload); const { From ea7f9557e9de0eac7592a491e28ce1eaa2d2605d Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Fri, 16 Jan 2026 18:03:48 +0100 Subject: [PATCH 14/16] feat: export MarkerOptions type for profiler.marker method - Add exported MarkerOptions type for marker method parameters - Replace inline type definition with named MarkerOptions type - Improves API documentation and type reusability --- packages/utils/src/lib/profiler/profiler.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/utils/src/lib/profiler/profiler.ts b/packages/utils/src/lib/profiler/profiler.ts index e051ea079..fb3c8bd30 100644 --- a/packages/utils/src/lib/profiler/profiler.ts +++ b/packages/utils/src/lib/profiler/profiler.ts @@ -28,6 +28,11 @@ type ProfilerMeasureOptions> = enabled?: boolean; }; +/** + * Options for creating a performance marker. + */ +export type MarkerOptions = EntryMeta & { color?: DevToolsColor }; + /** * Options for configuring a Profiler instance. * @@ -137,7 +142,7 @@ export class Profiler> { * ] * }); */ - marker(name: string, opt?: EntryMeta & { color?: DevToolsColor }): void { + marker(name: string, opt?: MarkerOptions): void { if (!this.#enabled) { return; } From fea574514f879ba1f3a5ca56b27e4868b3be191e Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Fri, 16 Jan 2026 18:05:53 +0100 Subject: [PATCH 15/16] refactor: use ActionTrackConfigs type alias instead of inline Record type - Import ActionTrackConfigs from user-timing-extensibility-api-utils - Replace Record with ActionTrackConfigs in type constraints - Improves code consistency and reduces repetition --- packages/utils/src/lib/profiler/profiler.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/utils/src/lib/profiler/profiler.ts b/packages/utils/src/lib/profiler/profiler.ts index fb3c8bd30..77422a495 100644 --- a/packages/utils/src/lib/profiler/profiler.ts +++ b/packages/utils/src/lib/profiler/profiler.ts @@ -1,6 +1,7 @@ import process from 'node:process'; import { isEnvVarEnabled } from '../env.js'; import { + type ActionTrackConfigs, type MeasureCtxOptions, type MeasureOptions, asOptions, @@ -20,7 +21,7 @@ import { PROFILER_ENABLED } from './constants.js'; * * @template T - Record type defining available track names and their configurations */ -type ProfilerMeasureOptions> = +type ProfilerMeasureOptions = MeasureCtxOptions & { /** Custom track configurations that will be merged with default settings */ tracks?: Record>; @@ -48,10 +49,7 @@ export type MarkerOptions = EntryMeta & { color?: DevToolsColor }; * @property tracks - Custom track configurations merged with defaults */ export type ProfilerOptions< - T extends Record = Record< - string, - ActionTrackEntryPayload - >, + T extends ActionTrackConfigs = Record, > = ProfilerMeasureOptions; /** @@ -61,7 +59,7 @@ export type ProfilerOptions< * It supports both synchronous and asynchronous operations with all having smart defaults for custom track data. * */ -export class Profiler> { +export class Profiler { #enabled: boolean; readonly #defaults: ActionTrackEntryPayload; readonly tracks: Record | undefined; From 17636b62f9d52868d82390c5bf8d1baef19d1a2b Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Fri, 16 Jan 2026 18:09:46 +0100 Subject: [PATCH 16/16] refactor: impl feedback --- packages/utils/src/lib/profiler/profiler.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/utils/src/lib/profiler/profiler.ts b/packages/utils/src/lib/profiler/profiler.ts index 77422a495..130e28c44 100644 --- a/packages/utils/src/lib/profiler/profiler.ts +++ b/packages/utils/src/lib/profiler/profiler.ts @@ -14,7 +14,7 @@ import type { DevToolsColor, EntryMeta, } from '../user-timing-extensibility-api.type.js'; -import { PROFILER_ENABLED } from './constants.js'; +import { PROFILER_ENABLED_ENV_VAR } from './constants.js'; /** * Configuration options for creating a Profiler instance. @@ -48,9 +48,8 @@ export type MarkerOptions = EntryMeta & { color?: DevToolsColor }; * @property color - Default color for track entries * @property tracks - Custom track configurations merged with defaults */ -export type ProfilerOptions< - T extends ActionTrackConfigs = Record, -> = ProfilerMeasureOptions; +export type ProfilerOptions = + ProfilerMeasureOptions; /** * Performance profiler that creates structured timing measurements with Chrome DevTools Extensibility API payloads. @@ -81,12 +80,12 @@ export class Profiler { const { tracks, prefix, enabled, ...defaults } = options; const dataType = 'track-entry'; - this.#enabled = enabled ?? isEnvVarEnabled(PROFILER_ENABLED); - this.defaults = { ...defaults, dataType }; + this.#enabled = enabled ?? isEnvVarEnabled(PROFILER_ENABLED_ENV_VAR); + this.#defaults = { ...defaults, dataType }; this.tracks = tracks ? setupTracks({ ...defaults, dataType }, tracks) : undefined; - this.ctxOf = measureCtx({ + this.#ctxOf = measureCtx({ ...defaults, dataType, prefix, @@ -102,7 +101,7 @@ export class Profiler { * @param enabled - Whether profiling should be enabled */ setEnabled(enabled: boolean): void { - process.env[PROFILER_ENABLED] = `${enabled}`; + process.env[PROFILER_ENABLED_ENV_VAR] = `${enabled}`; this.#enabled = enabled; } @@ -150,7 +149,7 @@ export class Profiler { asOptions( markerPayload({ // marker only takes default color, no TrackMeta - ...(this.defaults.color ? { color: this.defaults.color } : {}), + ...(this.#defaults.color ? { color: this.#defaults.color } : {}), ...opt, }), ), @@ -178,7 +177,7 @@ export class Profiler { return work(); } - const { start, success, error } = this.ctxOf(event, options); + const { start, success, error } = this.#ctxOf(event, options); start(); try { const r = work(); @@ -215,7 +214,7 @@ export class Profiler { return await work(); } - const { start, success, error } = this.ctxOf(event, options); + const { start, success, error } = this.#ctxOf(event, options); start(); try { const r = await work();