From a802d27f5e5148d7958a7c9b9b940f2efc9a5515 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Wed, 1 Apr 2026 12:38:54 -0400 Subject: [PATCH 1/5] feat: add profiling utility for sanitizeConfig timing --- packages/payload/src/utilities/profiling.ts | 92 +++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 packages/payload/src/utilities/profiling.ts diff --git a/packages/payload/src/utilities/profiling.ts b/packages/payload/src/utilities/profiling.ts new file mode 100644 index 00000000000..b10bd15868f --- /dev/null +++ b/packages/payload/src/utilities/profiling.ts @@ -0,0 +1,92 @@ +import { performance } from 'perf_hooks' + +const isEnabled = (): boolean => process.env.PAYLOAD_DEBUG_TIMING === 'true' + +// Track nested calls to build hierarchy +const callStack: string[] = [] + +/** + * Wrap a synchronous function with timing instrumentation. + * Only active when PAYLOAD_DEBUG_TIMING=true. + */ +export function timeSync(name: string, fn: () => T): T { + if (!isEnabled()) { + return fn() + } + + const fullName = callStack.length ? `${callStack.at(-1)} > ${name}` : name + callStack.push(name) + + performance.mark(`${fullName}:start`) + const result = fn() + performance.mark(`${fullName}:end`) + performance.measure(fullName, `${fullName}:start`, `${fullName}:end`) + + callStack.pop() + return result +} + +/** + * Wrap an async function with timing instrumentation. + * Only active when PAYLOAD_DEBUG_TIMING=true. + */ +export async function timeAsync(name: string, fn: () => Promise): Promise { + if (!isEnabled()) { + return fn() + } + + const fullName = callStack.length ? `${callStack.at(-1)} > ${name}` : name + callStack.push(name) + + performance.mark(`${fullName}:start`) + const result = await fn() + performance.mark(`${fullName}:end`) + performance.measure(fullName, `${fullName}:start`, `${fullName}:end`) + + callStack.pop() + return result +} + +/** + * Print aggregated timing results to console. + * Shows operation name, total time, percentage, count, and average. + */ +export function printProfileResults(): void { + if (!isEnabled()) { + return + } + + const entries = performance.getEntriesByType('measure') + const grouped = new Map() + + for (const entry of entries) { + const existing = grouped.get(entry.name) ?? { count: 0, total: 0 } + grouped.set(entry.name, { + count: existing.count + 1, + total: existing.total + entry.duration, + }) + } + + // Calculate total time from top-level operations only (no " > " in name) + const topLevelTime = Array.from(grouped.entries()) + .filter(([name]) => !name.includes(' > ')) + .reduce((sum, [, v]) => sum + v.total, 0) + + const sorted = Array.from(grouped.entries()) + .sort((a, b) => b[1].total - a[1].total) + .map(([name, { count, total }]) => ({ + name, + avg: `${(total / count).toFixed(2)}ms`, + count, + pct: `${((total / topLevelTime) * 100).toFixed(1)}%`, + total: `${total.toFixed(2)}ms`, + })) + + // eslint-disable-next-line no-console + console.log('\n=== sanitizeConfig Profile ===') + // eslint-disable-next-line no-console + console.table(sorted) + + performance.clearMeasures() + performance.clearMarks() +} From 29b36a5d44e995bef15ab99c0fafc2586b57b548 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Wed, 1 Apr 2026 12:45:15 -0400 Subject: [PATCH 2/5] chore: add try/finally for callStack error safety --- packages/payload/src/utilities/profiling.ts | 28 ++++++++++++--------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/payload/src/utilities/profiling.ts b/packages/payload/src/utilities/profiling.ts index b10bd15868f..3b0b33ea8a2 100644 --- a/packages/payload/src/utilities/profiling.ts +++ b/packages/payload/src/utilities/profiling.ts @@ -18,12 +18,14 @@ export function timeSync(name: string, fn: () => T): T { callStack.push(name) performance.mark(`${fullName}:start`) - const result = fn() - performance.mark(`${fullName}:end`) - performance.measure(fullName, `${fullName}:start`, `${fullName}:end`) - - callStack.pop() - return result + try { + const result = fn() + performance.mark(`${fullName}:end`) + performance.measure(fullName, `${fullName}:start`, `${fullName}:end`) + return result + } finally { + callStack.pop() + } } /** @@ -39,12 +41,14 @@ export async function timeAsync(name: string, fn: () => Promise): Promise< callStack.push(name) performance.mark(`${fullName}:start`) - const result = await fn() - performance.mark(`${fullName}:end`) - performance.measure(fullName, `${fullName}:start`, `${fullName}:end`) - - callStack.pop() - return result + try { + const result = await fn() + performance.mark(`${fullName}:end`) + performance.measure(fullName, `${fullName}:start`, `${fullName}:end`) + return result + } finally { + callStack.pop() + } } /** From 8fa9dcc0ec7305e39c795a89dc34cc8ee15a0610 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Wed, 1 Apr 2026 12:47:54 -0400 Subject: [PATCH 3/5] feat: add profiling test config with nested fields --- test/profiling/collections/index.ts | 43 +++++++++++++++++++++++++++++ test/profiling/config.ts | 17 ++++++++++++ test/profiling/payload-types.ts | 2 ++ 3 files changed, 62 insertions(+) create mode 100644 test/profiling/collections/index.ts create mode 100644 test/profiling/config.ts create mode 100644 test/profiling/payload-types.ts diff --git a/test/profiling/collections/index.ts b/test/profiling/collections/index.ts new file mode 100644 index 00000000000..dcda2254746 --- /dev/null +++ b/test/profiling/collections/index.ts @@ -0,0 +1,43 @@ +import type { CollectionConfig, Field } from 'payload' + +function createNestedFields(depth: number, prefix: string): Field[] { + if (depth === 0) { + return [{ name: `${prefix}_text`, type: 'text' }] + } + + return [ + { + name: `${prefix}_group`, + type: 'group', + fields: createNestedFields(depth - 1, `${prefix}_g`), + }, + { + name: `${prefix}_array`, + type: 'array', + fields: createNestedFields(depth - 1, `${prefix}_a`), + }, + { + type: 'tabs', + tabs: [ + { label: 'Tab1', fields: createNestedFields(depth - 1, `${prefix}_t1`) }, + { label: 'Tab2', fields: createNestedFields(depth - 1, `${prefix}_t2`) }, + ], + }, + ] +} + +export function generateCollections(count: number): CollectionConfig[] { + return Array.from({ length: count }, (_, i) => ({ + slug: `collection-${i}`, + fields: [ + ...createNestedFields(4, `c${i}`), + { name: 'richText', type: 'richText' }, + { + name: 'virtual', + type: 'text', + virtual: true, + hooks: { beforeRead: [() => 'computed'] }, + }, + ], + })) +} diff --git a/test/profiling/config.ts b/test/profiling/config.ts new file mode 100644 index 00000000000..a05dbd055fa --- /dev/null +++ b/test/profiling/config.ts @@ -0,0 +1,17 @@ +import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' +import { generateCollections } from './collections/index.js' + +export default buildConfigWithDefaults({ + collections: [ + ...generateCollections(30), + { + slug: 'users', + auth: true, + fields: [{ name: 'name', type: 'text' }], + }, + ], + globals: Array.from({ length: 5 }, (_, i) => ({ + slug: `global-${i}`, + fields: [{ name: 'title', type: 'text' }], + })), +}) diff --git a/test/profiling/payload-types.ts b/test/profiling/payload-types.ts new file mode 100644 index 00000000000..2e169443167 --- /dev/null +++ b/test/profiling/payload-types.ts @@ -0,0 +1,2 @@ +// Generated types placeholder +export {} From ebbdbd988dfa5cf0a1e0220bbf0c351c565c333a Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Wed, 1 Apr 2026 12:52:44 -0400 Subject: [PATCH 4/5] feat: add profiling integration test --- test/profiling/int.spec.ts | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 test/profiling/int.spec.ts diff --git a/test/profiling/int.spec.ts b/test/profiling/int.spec.ts new file mode 100644 index 00000000000..6f9263582f9 --- /dev/null +++ b/test/profiling/int.spec.ts @@ -0,0 +1,34 @@ +import type { Payload } from 'payload' + +import { performance } from 'perf_hooks' + +import { initPayloadInt } from '../helpers/initPayloadInt.js' + +let payload: Payload + +describe('sanitizeConfig profiling', () => { + beforeAll(() => { + process.env.PAYLOAD_DEBUG_TIMING = 'true' + }) + + afterAll(async () => { + if (payload) { + await payload.db.destroy() + } + delete process.env.PAYLOAD_DEBUG_TIMING + }) + + it('should profile sanitizeConfig and output timing breakdown', async () => { + const startTime = performance.now() + + ;({ payload } = await initPayloadInt((await import('./config.js')).default)) + + const totalTime = performance.now() - startTime + + console.log(`\nTotal init time: ${totalTime.toFixed(2)}ms`) + + // Verify payload initialized + expect(payload).toBeDefined() + expect(payload.collections['collection-0']).toBeDefined() + }) +}) From 83e285a413b3bd8ed1e6f143511d2d7b3b317d86 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Wed, 1 Apr 2026 12:56:59 -0400 Subject: [PATCH 5/5] chore: fix test helper imports --- test/profiling/int.spec.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/test/profiling/int.spec.ts b/test/profiling/int.spec.ts index 6f9263582f9..78ff1b4676a 100644 --- a/test/profiling/int.spec.ts +++ b/test/profiling/int.spec.ts @@ -1,8 +1,15 @@ import type { Payload } from 'payload' +import path from 'path' import { performance } from 'perf_hooks' +import { fileURLToPath } from 'url' +import { afterAll, beforeAll, describe, expect } from 'vitest' -import { initPayloadInt } from '../helpers/initPayloadInt.js' +import { it } from '../__helpers/int/vitest.js' +import { initPayloadInt } from '../__helpers/shared/initPayloadInt.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) let payload: Payload @@ -21,7 +28,7 @@ describe('sanitizeConfig profiling', () => { it('should profile sanitizeConfig and output timing breakdown', async () => { const startTime = performance.now() - ;({ payload } = await initPayloadInt((await import('./config.js')).default)) + ;({ payload } = await initPayloadInt(dirname)) const totalTime = performance.now() - startTime